From 36dd5389a4c9408a886ab684b41111172d5190ae Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Thu, 19 Dec 2024 13:21:48 +0200 Subject: [PATCH] feat: [AXM-1249] implement accredible issuer level --- credentials/apps/badges/accredible/data.py | 2 - credentials/apps/badges/issuers.py | 120 +++++++++++++++++- credentials/apps/badges/models.py | 44 ++++++- credentials/apps/badges/signals/handlers.py | 18 ++- credentials/apps/badges/signals/signals.py | 6 +- ..._usercredential_credential_content_type.py | 20 +++ credentials/apps/credentials/models.py | 2 +- 7 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 credentials/apps/credentials/migrations/0031_alter_usercredential_credential_content_type.py diff --git a/credentials/apps/badges/accredible/data.py b/credentials/apps/badges/accredible/data.py index a8dd67207..839d7946d 100644 --- a/credentials/apps/badges/accredible/data.py +++ b/credentials/apps/badges/accredible/data.py @@ -23,7 +23,6 @@ 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. """ @@ -31,7 +30,6 @@ class AccredibleCredential: recipient: AccredibleRecipient group_id: int name: str - description: str issued_on: datetime complete: bool diff --git a/credentials/apps/badges/issuers.py b/credentials/apps/badges/issuers.py index a073bbc0c..2f37d1984 100644 --- a/credentials/apps/badges/issuers.py +++ b/credentials/apps/badges/issuers.py @@ -2,15 +2,31 @@ 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 @@ -155,7 +171,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 @@ -197,3 +213,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) + 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) + 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 diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py index 174eb2309..e606e026b 100644 --- a/credentials/apps/badges/models.py +++ b/credentials/apps/badges/models.py @@ -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): """ @@ -752,3 +751,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) Credly badge. + """ + + return self.external_id and (self.state in self.ISSUING_STATES) diff --git a/credentials/apps/badges/signals/handlers.py b/credentials/apps/badges/signals/handlers.py index 2044e0849..0512c8759 100644 --- a/credentials/apps/badges/signals/handlers.py +++ b/credentials/apps/badges/signals/handlers.py @@ -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, @@ -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". @@ -73,11 +73,14 @@ 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). @@ -85,4 +88,7 @@ def handle_badge_regression(sender, username, badge_template_id, **kwargs): # p - 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) diff --git a/credentials/apps/badges/signals/signals.py b/credentials/apps/badges/signals/signals.py index db224ff54..a02dec413 100644 --- a/credentials/apps/badges/signals/signals.py +++ b/credentials/apps/badges/signals/signals.py @@ -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. """ @@ -57,10 +57,11 @@ 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. """ @@ -68,6 +69,7 @@ def notify_progress_incomplete(sender, username, badge_template_id): sender=sender, username=username, badge_template_id=badge_template_id, + origin=origin, ) diff --git a/credentials/apps/credentials/migrations/0031_alter_usercredential_credential_content_type.py b/credentials/apps/credentials/migrations/0031_alter_usercredential_credential_content_type.py new file mode 100644 index 000000000..1e039ecbd --- /dev/null +++ b/credentials/apps/credentials/migrations/0031_alter_usercredential_credential_content_type.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.17 on 2024-12-19 11:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('credentials', '0030_revoke_certificates_management_command'), + ] + + operations = [ + migrations.AlterField( + model_name='usercredential', + name='credential_content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ('coursecertificate', 'programcertificate', 'credlybadgetemplate', 'accrediblegroup')}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + ), + ] diff --git a/credentials/apps/credentials/models.py b/credentials/apps/credentials/models.py index 85e1dac9e..54a477da3 100644 --- a/credentials/apps/credentials/models.py +++ b/credentials/apps/credentials/models.py @@ -169,7 +169,7 @@ class UserCredential(TimeStampedModel): credential_content_type = models.ForeignKey( ContentType, - limit_choices_to={"model__in": ("coursecertificate", "programcertificate", "credlybadgetemplate")}, + limit_choices_to={"model__in": ("coursecertificate", "programcertificate", "credlybadgetemplate", "accrediblegroup")}, on_delete=models.CASCADE, ) credential_id = models.PositiveIntegerField()