Skip to content

Commit

Permalink
feat: [AXM-1249] implement accredible issuer level
Browse files Browse the repository at this point in the history
  • Loading branch information
kyrylo-kh committed Dec 23, 2024
1 parent dd1a65e commit 3d344b0
Show file tree
Hide file tree
Showing 14 changed files with 432 additions and 32 deletions.
2 changes: 2 additions & 0 deletions credentials/apps/badges/accredible/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ def sync_groups(self, site_id: int) -> int:
"name": raw_group.get("course_name"),
"description": raw_group.get("course_description"),
"icon": self.fetch_design_image(raw_group.get("primary_design_id")),
"created": raw_group.get("created_at"),
"state": AccredibleGroup.STATES.active,
},
)

Expand Down
2 changes: 0 additions & 2 deletions credentials/apps/badges/accredible/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,13 @@ class AccredibleCredential:
recipient (RecipientData): Information about the recipient.
group_id (int): ID of the credential group.
name (str): Title of the credential.
description (str): Description of the credential.
issued_on (datetime): Date when the credential was issued.
complete (bool): Whether the credential process is complete.
"""

recipient: AccredibleRecipient
group_id: int
name: str
description: str
issued_on: datetime
complete: bool

Expand Down
121 changes: 118 additions & 3 deletions credentials/apps/badges/issuers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,30 @@
This module provides classes for issuing badge credentials to users.
"""

from datetime import datetime
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.utils.translation import gettext as _

from credentials.apps.badges.accredible.api_client import AccredibleAPIClient
from credentials.apps.badges.accredible.data import(
AccredibleRecipient,
AccredibleCredential,
AccredibleBadgeData,
AccredibleExpireBadgeData,
AccredibleExpiredCredential,
)
from credentials.apps.badges.credly.api_client import CredlyAPIClient
from credentials.apps.badges.credly.data import CredlyBadgeData
from credentials.apps.badges.credly.exceptions import CredlyAPIError
from credentials.apps.badges.exceptions import BadgeProviderError
from credentials.apps.badges.models import BadgeTemplate, CredlyBadge, CredlyBadgeTemplate, UserCredential
from credentials.apps.badges.models import (
BadgeTemplate,
CredlyBadge,
CredlyBadgeTemplate,
UserCredential,
AccredibleGroup,
AccredibleBadge
)
from credentials.apps.badges.signals.signals import notify_badge_awarded, notify_badge_revoked
from credentials.apps.core.api import get_user_by_username
from credentials.apps.credentials.constants import UserCredentialStatus
Expand Down Expand Up @@ -155,7 +170,7 @@ def revoke_credly_badge(self, credential_id, user_credential):
}
try:
response = credly_api.revoke_badge(user_credential.external_uuid, revoke_data)
except CredlyAPIError:
except BadgeProviderError:
user_credential.state = "error"
user_credential.save()
raise
Expand Down Expand Up @@ -197,3 +212,103 @@ def revoke(self, credential_id, username):
if user_credential.propagated:
self.revoke_credly_badge(credential_id, user_credential)
return user_credential



class AccredibleBadgeTemplateIssuer(BadgeTemplateIssuer):
"""
Issues AccredibleGroup credentials to users.
"""

issued_credential_type = AccredibleGroup
issued_user_credential_type = AccredibleBadge

def issue_accredible_badge(self, *, user_credential):
"""
Requests Accredible service for external badge issuing based on internal user credential (AccredibleBadge).
"""

user = get_user_by_username(user_credential.username)
group = user_credential.credential

accredible_badge_data = AccredibleBadgeData(
credential=AccredibleCredential(
recipient=AccredibleRecipient(
name=user.get_full_name() or user.username,
email=user.email,
),
group_id=group.id,
name=group.name,
issued_on=user_credential.created.strftime("%Y-%m-%d %H:%M:%S %z"),
complete=True,
)
)

try:
accredible_api = AccredibleAPIClient(group.api_config.id)
response = accredible_api.issue_badge(accredible_badge_data)
except BadgeProviderError:
user_credential.state = "error"
user_credential.save()
raise

user_credential.external_id = response.get("credential").get("id")
user_credential.state = AccredibleBadge.STATES.accepted
user_credential.save()

def revoke_accredible_badge(self, credential_id, user_credential):
"""
Requests Accredible service for external badge expiring based on internal user credential (AccredibleBadge).
"""

credential = self.get_credential(credential_id)
accredible_api_client = AccredibleAPIClient(credential.api_config.id)
revoke_badge_data = AccredibleExpireBadgeData(
credential=AccredibleExpiredCredential(expired_on=datetime.now().strftime("%Y-%m-%d %H:%M:%S %z"))
)

try:
accredible_api_client.revoke_badge(user_credential.external_id, revoke_badge_data)
except BadgeProviderError:
user_credential.state = "error"
user_credential.save()
raise

user_credential.state = AccredibleBadge.STATES.expired
user_credential.save()


def award(self, *, username, credential_id):
"""
Awards a Accredible badge.
- Creates user credential record for the group, for a given user;
- Notifies about the awarded badge (public signal);
- Issues external Accredible badge (Accredible API);
Returns: (AccredibleBadge) user credential
"""

accredible_badge = super().award(username=username, credential_id=credential_id)

# do not issue new badges if the badge was issued already
if not accredible_badge.propagated:
self.issue_accredible_badge(user_credential=accredible_badge)

return accredible_badge

def revoke(self, credential_id, username):
"""
Revokes a Accredible badge.
- Changes user credential status to REVOKED, for a given user;
- Notifies about the revoked badge (public signal);
- Expire external Accredible badge (Accredible API);
Returns: (AccredibleBadge) user credential
"""

user_credential = super().revoke(credential_id, username)
if user_credential.propagated:
self.revoke_accredible_badge(credential_id, user_credential)
return user_credential
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.17 on 2024-12-23 15:08

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("badges", "0002_accredibleapiconfig_accrediblebadge_and_more"),
]

operations = [
migrations.AlterField(
model_name="accredibleapiconfig",
name="name",
field=models.CharField(help_text="Accredible API configuration name.", max_length=255, null=True),
),
]
49 changes: 44 additions & 5 deletions credentials/apps/badges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ def groups(self):

return {
group: BadgeRequirement.is_group_fulfilled(group=group, template=self.template, username=self.username)
for group in self.template.groups
for group in getattr(self.template, "groups", [])
}

@property
Expand All @@ -553,15 +553,14 @@ def progress(self):
"""
Notify about the progress.
"""

notify_progress_complete(self, self.username, self.template.id)
notify_progress_complete(self, self.username, self.template.id, self.template.origin)

def regress(self):
"""
Notify about the regression.
"""

notify_progress_incomplete(self, self.username, self.template.id)
notify_progress_incomplete(self, self.username, self.template.id, self.template.origin)

def reset(self):
"""
Expand Down Expand Up @@ -684,7 +683,7 @@ class AccredibleAPIConfig(TimeStampedModel):
Accredible API configuration.
"""

name = models.CharField(max_length=255, help_text=_("Accredible API configuration name."), null=True, blank=True)
name = models.CharField(max_length=255, help_text=_("Accredible API configuration name."), null=True, blank=False)
api_key = models.CharField(max_length=255, help_text=_("Accredible API key."))

@classmethod
Expand All @@ -694,6 +693,7 @@ def get_all_api_config_ids(cls):
"""
return list(cls.objects.values_list("id", flat=True))


class AccredibleGroup(BadgeTemplate):
"""
Accredible badge group credential type.
Expand Down Expand Up @@ -752,3 +752,42 @@ class AccredibleBadge(UserCredential):
unique=True,
help_text=_("Accredible service badge identifier"),
)


def as_badge_data(self) -> BadgeData:
"""
Represents itself as a BadgeData instance.
"""

user = get_user_by_username(self.username)
group = self.credential

badge_data = BadgeData(
uuid=str(self.uuid),
user=UserData(
pii=UserPersonalData(
username=self.username,
email=user.email,
name=user.get_full_name(),
),
id=user.lms_user_id,
is_active=user.is_active,
),
template=BadgeTemplateData(
uuid=str(group.uuid),
origin=group.origin,
name=group.name,
description=group.description,
image_url=str(group.icon),
),
)

return badge_data

@property
def propagated(self):
"""
Checks if this user credential already has issued (external) Accredible badge.
"""

return self.external_id and (self.state in self.ISSUING_STATES)
18 changes: 12 additions & 6 deletions credentials/apps/badges/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from django.dispatch import receiver
from openedx_events.tooling import OpenEdxPublicSignal, load_all_signals

from credentials.apps.badges.issuers import CredlyBadgeTemplateIssuer
from credentials.apps.badges.models import BadgeProgress
from credentials.apps.badges.issuers import CredlyBadgeTemplateIssuer, AccredibleBadgeTemplateIssuer
from credentials.apps.badges.models import BadgeProgress, CredlyBadgeTemplate, AccredibleGroup
from credentials.apps.badges.processing.generic import process_event
from credentials.apps.badges.signals import (
BADGE_PROGRESS_COMPLETE,
Expand Down Expand Up @@ -63,7 +63,7 @@ def handle_requirement_regressed(sender, username, **kwargs):


@receiver(BADGE_PROGRESS_COMPLETE)
def handle_badge_completion(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument
def handle_badge_completion(sender, username, badge_template_id, origin, **kwargs): # pylint: disable=unused-argument
"""
Fires once ALL requirements for a badge template were marked as "done".
Expand All @@ -73,16 +73,22 @@ def handle_badge_completion(sender, username, badge_template_id, **kwargs): # p

logger.debug("BADGES: progress is complete for %s on the %s", username, badge_template_id)

CredlyBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id)
if origin == CredlyBadgeTemplate.ORIGIN:
CredlyBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id)
elif origin == AccredibleGroup.ORIGIN:
AccredibleBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id)


@receiver(BADGE_PROGRESS_INCOMPLETE)
def handle_badge_regression(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument
def handle_badge_regression(sender, username, badge_template_id, origin, **kwargs): # pylint: disable=unused-argument
"""
On user's Badge regression (incompletion).
- username
- badge template ID
"""

CredlyBadgeTemplateIssuer().revoke(badge_template_id, username)
if origin == CredlyBadgeTemplate.ORIGIN:
CredlyBadgeTemplateIssuer().revoke(badge_template_id, username)
elif origin == AccredibleGroup.ORIGIN:
AccredibleBadgeTemplateIssuer().revoke(badge_template_id, username)
6 changes: 4 additions & 2 deletions credentials/apps/badges/signals/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def notify_requirement_regressed(*, sender, username, badge_template_id):
)


def notify_progress_complete(sender, username, badge_template_id):
def notify_progress_complete(sender, username, badge_template_id, origin):
"""
Notifies about user's completion on the badge template.
"""
Expand All @@ -57,17 +57,19 @@ def notify_progress_complete(sender, username, badge_template_id):
sender=sender,
username=username,
badge_template_id=badge_template_id,
origin=origin,
)


def notify_progress_incomplete(sender, username, badge_template_id):
def notify_progress_incomplete(sender, username, badge_template_id, origin):
"""
Notifies about user's regression on the badge template.
"""
BADGE_PROGRESS_INCOMPLETE.send(
sender=sender,
username=username,
badge_template_id=badge_template_id,
origin=origin,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ def setUp(self):
recipient=AccredibleRecipient(name="Test name", email="test_name@test.com"),
group_id=123,
name="Test Badge",
description="Test Badge Description",
issued_on="2021-01-01 00:00:00 +0000",
complete=True,
)
Expand Down
10 changes: 5 additions & 5 deletions credentials/apps/badges/tests/test_admin_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
DataRuleExtensionsMixin,
ParentMixin,
)
from credentials.apps.badges.credly.exceptions import CredlyAPIError
from credentials.apps.badges.exceptions import BadgeProviderError
from credentials.apps.badges.models import BadgeRequirement, BadgeTemplate


Expand Down Expand Up @@ -132,7 +132,7 @@ def test_clean_with_invalid_organization(self):
) as mock_get_orgs:
mock_get_orgs.return_value = {"test_uuid": "test_org"}

with self.assertRaises(forms.ValidationError) as cm:
with self.assertRaises(BadgeProviderError) as cm:
form.clean()

self.assertIn("You specified an invalid authorization token.", str(cm.exception))
Expand Down Expand Up @@ -170,13 +170,13 @@ def test_ensure_organization_exists(self):
def test_ensure_organization_exists_with_error(self):
form = CredlyOrganizationAdminForm()
api_client = MagicMock()
api_client.fetch_organization.side_effect = CredlyAPIError("API Error")
api_client.fetch_organization.side_effect = BadgeProviderError("API Error")

with self.assertRaises(forms.ValidationError) as cm:
with self.assertRaises(BadgeProviderError) as cm:
form.ensure_organization_exists(api_client)

api_client.fetch_organization.assert_called_once()
self.assertEqual(str(cm.exception), "['API Error']")
self.assertEqual(str(cm.exception), "API Error")


class TestParentMixin(ParentMixin):
Expand Down
Loading

0 comments on commit 3d344b0

Please sign in to comment.