diff --git a/credentials/apps/badges/admin.py b/credentials/apps/badges/admin.py index e3724f108..9c7d1e5d4 100644 --- a/credentials/apps/badges/admin.py +++ b/credentials/apps/badges/admin.py @@ -8,7 +8,13 @@ from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ -from .admin_forms import BadgePenaltyForm, BadgeRequirementForm, CredlyOrganizationAdminForm, DataRuleForm, PenaltyDataRuleForm +from .admin_forms import ( + BadgePenaltyForm, + BadgeRequirementForm, + CredlyOrganizationAdminForm, + DataRuleForm, + PenaltyDataRuleForm, +) from .models import ( BadgePenalty, @@ -37,7 +43,7 @@ class BadgePenaltyInline(admin.TabularInline): extra = 0 form = BadgePenaltyForm - + class FulfillmentInline(admin.TabularInline): model = Fulfillment extra = 0 @@ -168,6 +174,7 @@ class BadgePenaltyAdmin(admin.ModelAdmin): """ Badge requirement penalty setup admin. """ + inlines = [ DataRulePenaltyInline, ] diff --git a/credentials/apps/badges/admin_forms.py b/credentials/apps/badges/admin_forms.py index 410506675..100906234 100644 --- a/credentials/apps/badges/admin_forms.py +++ b/credentials/apps/badges/admin_forms.py @@ -60,7 +60,6 @@ def _ensure_organization_exists(self, api_client): raise forms.ValidationError(message=str(err)) - class BadgePenaltyForm(forms.ModelForm): class Meta: model = BadgePenalty @@ -85,6 +84,7 @@ def clean(self): return cleaned_data + class PenaltyDataRuleForm(forms.ModelForm): class Meta: model = PenaltyDataRule @@ -92,11 +92,11 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.instance and hasattr(self.instance, 'penalty') and self.instance.penalty.template.is_active: + if self.instance and hasattr(self.instance, "penalty") and self.instance.penalty.template.is_active: for field_name in self.fields: if field_name in ("data_path", "operator", "value"): self.fields[field_name].disabled = True - + class BadgeRequirementForm(forms.ModelForm): class Meta: @@ -105,9 +105,9 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.instance and hasattr(self.instance, 'template') and self.instance.template.is_active: + if self.instance and hasattr(self.instance, "template") and self.instance.template.is_active: for field_name in self.fields: - if field_name in ("template", "event_type", "description"): + if field_name in ("template", "event_type", "description", "group"): self.fields[field_name].disabled = True @@ -118,7 +118,7 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.instance and hasattr(self.instance, 'requirement') and self.instance.requirement.template.is_active: + if self.instance and hasattr(self.instance, "requirement") and self.instance.requirement.template.is_active: for field_name in self.fields: if field_name in ("data_path", "operator", "value"): self.fields[field_name].disabled = True diff --git a/credentials/apps/badges/credly/api_client.py b/credentials/apps/badges/credly/api_client.py index 945855c15..00f8be138 100644 --- a/credentials/apps/badges/credly/api_client.py +++ b/credentials/apps/badges/credly/api_client.py @@ -62,9 +62,7 @@ def __init__(self, organization_id, api_key=None): self.api_key = api_key self.organization_id = organization_id - self.base_api_url = urljoin( - get_credly_api_base_url(settings), f"organizations/{self.organization_id}/" - ) + self.base_api_url = urljoin(get_credly_api_base_url(settings), f"organizations/{self.organization_id}/") def _get_organization(self, organization_id): """ @@ -93,9 +91,7 @@ def perform_request(self, method, url_suffix, data=None): """ url = urljoin(self.base_api_url, url_suffix) logger.debug(f"Credly API: {method.upper()} {url}") - response = requests.request( - method.upper(), url, headers=self._get_headers(), data=data - ) + response = requests.request(method.upper(), url, headers=self._get_headers(), json=data) self._raise_for_error(response) return response.json() @@ -112,9 +108,7 @@ def _raise_for_error(self, response): try: response.raise_for_status() except HTTPError: - logger.error( - f"Error while processing Credly API request: {response.status_code} - {response.text}" - ) + logger.error(f"Error while processing Credly API request: {response.status_code} - {response.text}") raise CredlyAPIError(f"Credly API:{response.text}({response.status_code})") def _get_headers(self): @@ -167,14 +161,14 @@ def issue_badge(self, issue_badge_data): """ return self.perform_request("post", "badges/", asdict(issue_badge_data)) - def revoke_badge(self, badge_id): + def revoke_badge(self, badge_id, data): """ Revoke a badge with the given badge ID. Args: badge_id (str): ID of the badge to revoke. """ - return self.perform_request("put", f"badges/{badge_id}/revoke/") + return self.perform_request("put", f"badges/{badge_id}/revoke/", data=data) def sync_organization_badge_templates(self, site_id): """ @@ -209,4 +203,4 @@ def sync_organization_badge_templates(self, site_id): }, ) - return len(raw_badge_templates) \ No newline at end of file + return len(raw_badge_templates) diff --git a/credentials/apps/badges/credly/exceptions.py b/credentials/apps/badges/credly/exceptions.py index fbf51c26c..6752803f6 100644 --- a/credentials/apps/badges/credly/exceptions.py +++ b/credentials/apps/badges/credly/exceptions.py @@ -1,14 +1,13 @@ -class BadgesError(Exception): - """ - Generic Badges functionality error. - """ +""" +Specific for Credly exceptions. +""" - pass +from credentials.apps.badges.exceptions import BadgesError class CredlyError(BadgesError): """ - Badges error that is specific to the Credly backend. + Credly backend generic error. """ pass diff --git a/credentials/apps/badges/credly/webhooks.py b/credentials/apps/badges/credly/webhooks.py index f1c2791f8..101b95d43 100644 --- a/credentials/apps/badges/credly/webhooks.py +++ b/credentials/apps/badges/credly/webhooks.py @@ -45,9 +45,7 @@ def post(self, request): """ credly_api_client = CredlyAPIClient(request.data.get("organization_id")) - event_info_response = credly_api_client.fetch_event_information( - request.data.get("id") - ) + event_info_response = credly_api_client.fetch_event_information(request.data.get("id")) event_type = request.data.get("event_type") if event_type == "badge_template.created": diff --git a/credentials/apps/badges/exceptions.py b/credentials/apps/badges/exceptions.py new file mode 100644 index 000000000..c5950e24f --- /dev/null +++ b/credentials/apps/badges/exceptions.py @@ -0,0 +1,27 @@ +""" +Badges exceptions. +""" + + +class BadgesError(Exception): + """ + Badges generic exception. + """ + + pass + + +class BadgesProcessingError(BadgesError): + """ + Exception raised for errors that occur during badge processing. + """ + + pass + + +class StopEventProcessing(BadgesProcessingError): + """ + Exception raised to stop processing an event due to a specific condition. + """ + + pass diff --git a/credentials/apps/badges/management/commands/sync_organization_badge_templates.py b/credentials/apps/badges/management/commands/sync_organization_badge_templates.py index 53673deb2..29da77e48 100644 --- a/credentials/apps/badges/management/commands/sync_organization_badge_templates.py +++ b/credentials/apps/badges/management/commands/sync_organization_badge_templates.py @@ -14,9 +14,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("--site_id", type=int, help="Site ID.") - parser.add_argument( - "--organization_id", type=str, help="UUID of the organization." - ) + parser.add_argument("--organization_id", type=str, help="UUID of the organization.") def handle(self, *args, **options): """ @@ -33,16 +31,12 @@ def handle(self, *args, **options): organization_id = options.get("organization_id") if site_id is None: - logger.warning( - f"Side ID wasn't provided: assuming site_id = {DEFAULT_SITE_ID}" - ) + logger.warning(f"Side ID wasn't provided: assuming site_id = {DEFAULT_SITE_ID}") site_id = DEFAULT_SITE_ID if organization_id: organizations_to_sync.append(organization_id) - logger.info( - f"Syncing badge templates for the single organization: {organization_id}" - ) + logger.info(f"Syncing badge templates for the single organization: {organization_id}") else: organizations_to_sync = CredlyOrganization.get_all_organization_ids() logger.info( @@ -55,4 +49,4 @@ def handle(self, *args, **options): logger.info(f"Organization {organization_id}: got {processed_items} badge templates.") - logger.info("...completed!") \ No newline at end of file + logger.info("...completed!") diff --git a/credentials/apps/badges/migrations/0010_auto_20240409_1326.py b/credentials/apps/badges/migrations/0010_auto_20240409_1326.py new file mode 100644 index 000000000..35240d17c --- /dev/null +++ b/credentials/apps/badges/migrations/0010_auto_20240409_1326.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.20 on 2024-04-09 13:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('badges', '0009_auto_20240408_1316'), + ] + + operations = [ + migrations.AddField( + model_name='badgepenalty', + name='event_type', + field=models.CharField(choices=[('org.openedx.learning.course.passing.status.updated.v1', 'org.openedx.learning.course.passing.status.updated.v1'), ('org.openedx.learning.ccx.course.passing.status.updated.v1', 'org.openedx.learning.ccx.course.passing.status.updated.v1')], default='org.openedx.learning.course.passing.status.updated.v1', help_text='Public signal type. Use namespaced types, e.g: "org.openedx.learning.student.registration.completed.v1"', max_length=255), + preserve_default=False, + ), + ] diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py index 09836cbaa..7025da075 100644 --- a/credentials/apps/badges/models.py +++ b/credentials/apps/badges/models.py @@ -21,12 +21,8 @@ class CredlyOrganization(TimeStampedModel): Credly Organization configuration. """ - uuid = models.UUIDField( - unique=True, help_text=_("Put your Credly Organization ID here.") - ) - api_key = models.CharField( - max_length=255, help_text=_("Credly API shared secret for Credly Organization.") - ) + uuid = models.UUIDField(unique=True, help_text=_("Put your Credly Organization ID here.")) + api_key = models.CharField(max_length=255, help_text=_("Credly API shared secret for Credly Organization.")) name = models.CharField( max_length=255, null=True, @@ -61,18 +57,14 @@ class AbstractDataRule(models.Model): data_path = models.CharField( max_length=255, - help_text=_( - 'Public signal\'s data payload nested property path, e.g: "user.pii.username".' - ), + help_text=_('Public signal\'s data payload nested property path, e.g: "user.pii.username".'), verbose_name=_("key path"), ) operator = models.CharField( max_length=32, choices=OPERATORS, default=OPERATORS.eq, - help_text=_( - "Expected value comparison operator. https://docs.python.org/3/library/operator.html" - ), + help_text=_("Expected value comparison operator. https://docs.python.org/3/library/operator.html"), ) value = models.CharField( max_length=255, @@ -93,17 +85,11 @@ class BadgeTemplate(AbstractCredential): STATES = Choices("draft", "active", "archived") - uuid = models.UUIDField( - unique=True, default=uuid.uuid4, help_text=_("Unique badge template ID.") - ) + uuid = models.UUIDField(unique=True, default=uuid.uuid4, help_text=_("Unique badge template ID.")) name = models.CharField(max_length=255, help_text=_("Badge template name.")) - description = models.TextField( - null=True, blank=True, help_text=_("Badge template description.") - ) + description = models.TextField(null=True, blank=True, help_text=_("Badge template description.")) icon = models.ImageField(upload_to="badge_templates/icons", null=True, blank=True) - origin = models.CharField( - max_length=128, null=True, blank=True, help_text=_("Badge template type.") - ) + origin = models.CharField(max_length=128, null=True, blank=True, help_text=_("Badge template type.")) state = StatusField( choices_name="STATES", help_text=_("Credly badge template state (auto-managed)."), @@ -122,7 +108,7 @@ def save(self, *args, **kwargs): @classmethod def by_uuid(cls, template_uuid): return cls.objects.filter(uuid=template_uuid, origin=cls.ORIGIN).first() - + def user_progress(self, username: str) -> float: """ Calculate user progress for badge template. @@ -131,7 +117,7 @@ def user_progress(self, username: str) -> float: if progress is None: return 0.00 return progress.ratio - + def user_completion(self, username: str) -> bool: """ Check if user completed badge template. @@ -158,7 +144,9 @@ def management_url(self): Build external Credly dashboard URL. """ credly_host_base_url = "https://sandbox.credly.com" - return f"{credly_host_base_url}/mgmt/organizations/{self.organization.uuid}/badges/templates/{self.uuid}/details" + return ( + f"{credly_host_base_url}/mgmt/organizations/{self.organization.uuid}/badges/templates/{self.uuid}/details" + ) class BadgeRequirement(models.Model): @@ -169,7 +157,7 @@ class BadgeRequirement(models.Model): To achieve "OR" processing logic for 2 requirement one must group them (put identical group ID). """ - EVENT_TYPES = Choices(*settings.BADGES_CONFIG['events']) + EVENT_TYPES = Choices(*settings.BADGES_CONFIG["events"]) template = models.ForeignKey( BadgeTemplate, @@ -183,9 +171,7 @@ class BadgeRequirement(models.Model): 'Public signal type. Use namespaced types, e.g: "org.openedx.learning.student.registration.completed.v1"' ), ) - description = models.TextField( - null=True, blank=True, help_text=_("Provide more details if needed.") - ) + description = models.TextField(null=True, blank=True, help_text=_("Provide more details if needed.")) group = models.CharField( max_length=255, @@ -196,16 +182,23 @@ class BadgeRequirement(models.Model): def __str__(self): return f"BadgeRequirement:{self.id}:{self.template.uuid}" - + def save(self, *args, **kwargs): # Check if the related BadgeTemplate is active + if not self.id: + super().save(*args, **kwargs) + return if not self.template.is_active: super().save(*args, **kwargs) else: raise ValidationError("Cannot update BadgeRequirement for active BadgeTemplate") def reset(self, username: str): - Fulfillment.objects.filter(requirement=self, progress__username=username, progress__template=self.template).delete() + Fulfillment.objects.filter( + requirement=self, + progress__username=username, + progress__template=self.template, + ).delete() def is_fullfiled(self, username: str) -> bool: return self.fulfillment_set.filter(progress__username=username, progress__template=self.template).exists() @@ -214,11 +207,16 @@ def fulfill(self, username: str): progress, _ = BadgeProgress.objects.get_or_create(template=self.template, username=username) return Fulfillment.objects.create(progress=progress, requirement=self) + def apply_rules(self, **kwargs): + pass + + class DataRule(AbstractDataRule): """ Specifies expected data attribute value for event payload. NOTE: all data rules for a single requirement follow "AND" processing logic. """ + requirement = models.ForeignKey( BadgeRequirement, on_delete=models.CASCADE, @@ -230,7 +228,7 @@ class Meta: def __str__(self): return f"{self.requirement.template.uuid}:{self.data_path}:{self.operator}:{self.value}" - + def save(self, *args, **kwargs): if not is_datapath_valid(self.data_path, self.requirement.event_type): raise ValidationError("Invalid data path for event type") @@ -246,25 +244,45 @@ class BadgePenalty(models.Model): """ Describes badge regression rules for particular BadgeRequirement. """ - + + EVENT_TYPES = Choices(*settings.BADGES_CONFIG["events"]) + template = models.ForeignKey( BadgeTemplate, on_delete=models.CASCADE, help_text=_("Badge template this penalty serves for."), ) + event_type = models.CharField( + max_length=255, + choices=EVENT_TYPES, + help_text=_( + 'Public signal type. Use namespaced types, e.g: "org.openedx.learning.student.registration.completed.v1"' + ), + ) requirements = models.ManyToManyField( BadgeRequirement, help_text=_("Badge requirements for which this penalty is defined."), ) - description = models.TextField( - null=True, blank=True, help_text=_("Provide more details if needed.") - ) + description = models.TextField(null=True, blank=True, help_text=_("Provide more details if needed.")) + + class Meta: + verbose_name_plural = "Badge penalties" + + def save(self, *args, **kwargs): + if not self.id: + super().save(*args, **kwargs) + return + # Check if the related BadgeTemplate is active + if not self.template.is_active: + super().save(*args, **kwargs) + else: + raise ValidationError("Cannot update BadgePenalty for active BadgeTemplate") def __str__(self): return f"BadgePenalty:{self.id}:{self.template.uuid}" - class Meta: - verbose_name_plural = "Badge penalties" + def apply_rules(self, **kwargs): + pass class PenaltyDataRule(AbstractDataRule): @@ -272,19 +290,26 @@ class PenaltyDataRule(AbstractDataRule): Specifies expected data attribute value for penalty rule. NOTE: all data rules for a single penalty follow "AND" processing logic. """ + penalty = models.ForeignKey( BadgePenalty, on_delete=models.CASCADE, help_text=_("Parent penalty for this data rule."), ) - + + class Meta: + unique_together = ("penalty", "data_path", "operator", "value") + def save(self, *args, **kwargs): + if not is_datapath_valid(self.data_path, self.penalty.event_type): + raise ValidationError("Invalid data path for event type") + # Check if the related BadgeTemplate is active if not self.penalty.template.is_active: super().save(*args, **kwargs) else: raise ValidationError("Cannot update PenaltyDataRule for active BadgeTemplate") - + def __str__(self): return f"{self.penalty.template.uuid}:{self.data_path}:{self.operator}:{self.value}" @@ -318,7 +343,7 @@ class Meta: def __str__(self): return f"BadgeProgress:{self.username}" - + @property def ratio(self) -> float: """ @@ -328,7 +353,9 @@ def ratio(self) -> float: if requirements_count == 0: return 0.00 - fulfilled_requirements_count = Fulfillment.objects.filter(progress=self, requirement__template=self.template).count() + fulfilled_requirements_count = Fulfillment.objects.filter( + progress=self, requirement__template=self.template + ).count() if fulfilled_requirements_count == 0: return 0.00 return round(fulfilled_requirements_count / requirements_count, 2) @@ -358,12 +385,14 @@ class CredlyBadge(UserCredential): - tracks distributed (external Credly service) state for Credly badge. """ - STATES = Choices( - "created", "no_response", "error", "pending", "accepted", "rejected", "revoked" - ) + STATES = Choices("created", "no_response", "error", "pending", "accepted", "rejected", "revoked") state = StatusField( choices_name="STATES", help_text=_("Credly badge issuing state"), default=STATES.created, ) + + @property + def is_issued(self): + return self.uuid and (self.state in ["pending", "accepted", "rejected"]) diff --git a/credentials/apps/badges/services/awarding.py b/credentials/apps/badges/services/awarding.py index 646a7c78d..87bbbdc59 100644 --- a/credentials/apps/badges/services/awarding.py +++ b/credentials/apps/badges/services/awarding.py @@ -1,17 +1,64 @@ """ Awarding pipeline - badge progression. """ -import uuid +import uuid from typing import List -from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData +from openedx_events.learning.data import ( + BadgeData, + BadgeTemplateData, + CoursePassingStatusData, + UserData, + UserPersonalData, +) from openedx_events.learning.signals import BADGE_AWARDED from ..models import BadgeRequirement, CredlyBadgeTemplate +from ..signals import BADGE_PROGRESS_COMPLETE +from ..utils import keypath + + +def discover_requirements(event_type: str) -> List[BadgeRequirement]: + return BadgeRequirement.objects.filter(event_type=event_type) -def notify_badge_awarded(username, badge_template_uuid): # pylint: disable=unused-argument +def process_requirements(event_type, username, payload_dict): + """ + AWARD FLOW: + - check if the related badge template already completed + - if BadgeProgress exists and BadgeProgress.complete == true >> badge already earned - STOP; + - check if it is not fulfilled yet + - if fulfilled (related Fulfillment exists) - STOP; + - apply payload rules (data-rules); + - if applied - fulfill the Requirement: + - create related Fulfillment + - update of create BadgeProgress + - BadgeProgress completeness check - check if it was enough for badge earning + - if BadgeProgress.complete == true + - emit BADGE_PROGRESS_COMPLETE >> handle_badge_completion + """ + + # TEMP: remove this stub after processing is implemented + if keypath(payload_dict, "course_passing_status.status") == CoursePassingStatusData.PASSING: + BADGE_PROGRESS_COMPLETE.send( + sender=None, + username=username, + badge_template_id=CredlyBadgeTemplate.objects.first().id, + ) + + # :TEMP + + requirements = discover_requirements(event_type=event_type) + + # actual processing goes here: + + # for requirement in requirements: + # requirement.apply_rules(**kwargs) + # requirement.fulfill(username) + + +def notify_badge_awarded(user_credential): # pylint: disable=unused-argument """ Emit public event about badge template completion. @@ -19,32 +66,27 @@ def notify_badge_awarded(username, badge_template_uuid): # pylint: disable=unus - badge template ID """ - badge_template = CredlyBadgeTemplate.by_uuid(badge_template_uuid) - # user = get_user_by_username(username) + # TODO: make user-credential responsible for its conversion into signal payload: + # e.g.: badge_data = CredlyBadge.as_badge_data() - # UserCredential.as_badge_data() - user-credential is responsible for its conversion into payload: badge_data = BadgeData( uuid=str(uuid.uuid4()), user=UserData( pii=UserPersonalData( - username='event_user-username', - email='event_user-email', - name='event_user-name', + username="event_user-username", + email="event_user-email", + name="event_user-name", ), id=1, is_active=True, ), template=BadgeTemplateData( - uuid=str(badge_template.uuid), - origin=badge_template.origin, - name=badge_template.name, - description=badge_template.description, - image_url=str(badge_template.icon), + uuid=str(uuid.uuid4()), + origin="faked.origin", + name="faked.name", + description="feaked.description", + image_url="faked.badge_template.icon", ), ) BADGE_AWARDED.send_event(badge=badge_data) - - -def discover_requirements(event_type: str) -> List[BadgeRequirement]: - return BadgeRequirement.objects.filter(event_type=event_type) \ No newline at end of file diff --git a/credentials/apps/badges/services/issuers.py b/credentials/apps/badges/services/issuers.py index 90ec8cb8e..1c81b8a64 100644 --- a/credentials/apps/badges/services/issuers.py +++ b/credentials/apps/badges/services/issuers.py @@ -4,8 +4,13 @@ from django.contrib.contenttypes.models import ContentType from django.db import transaction +from django.utils.translation import gettext as _ +from credentials.apps.badges.credly.api_client import CredlyAPIClient +from credentials.apps.badges.credly.data import IssueBadgeData +from credentials.apps.badges.credly.exceptions import CredlyAPIError from credentials.apps.badges.models import BadgeTemplate, CredlyBadge, CredlyBadgeTemplate, UserCredential +from credentials.apps.core.api import get_user_by_username from credentials.apps.credentials.constants import UserCredentialStatus from credentials.apps.credentials.issuers import AbstractCredentialIssuer @@ -68,23 +73,16 @@ def issue_credential( def award(self, credential_id, username): credential = self.get_credential(credential_id) - user_credential = self.issue_credential( - credential, - username - ) + user_credential = self.issue_credential(credential, username) - notify_badge_awarded(username, credential.uuid) + notify_badge_awarded(user_credential) return user_credential def revoke(self, credential_id, username): credential = self.get_credential(credential_id) - user_credential = self.issue_credential( - credential, - username, - status=UserCredentialStatus.REVOKED - ) + user_credential = self.issue_credential(credential, username, status=UserCredentialStatus.REVOKED) - notify_badge_revoked(username, credential.uuid) + notify_badge_revoked(user_credential) return user_credential @@ -95,3 +93,54 @@ class CredlyBadgeTemplateIssuer(BadgeTemplateIssuer): issued_credential_type = CredlyBadgeTemplate issued_user_credential_type = CredlyBadge + + def issue_credly_badge(self, credential_id, user_credential): + user = get_user_by_username(user_credential.username) + + credential = self.get_credential(credential_id) + credly_api = CredlyAPIClient(credential.organization.uuid) + issue_badge_data = IssueBadgeData( + recipient_email=user.email, + issued_to_first_name=(user.first_name or user.username), + issued_to_last_name=(user.last_name or user.username), + badge_template_id=str(credential.uuid), + issued_at=credential.created.strftime("%Y-%m-%d %H:%M:%S %z"), + ) + try: + response = credly_api.issue_badge(issue_badge_data) + except CredlyAPIError: + user_credential.state = "error" + user_credential.save() + raise + + user_credential.uuid = response.get("data").get("id") + user_credential.state = response.get("data").get("state") + user_credential.save() + + def revoke_credly_badge(self, credential_id, user_credential): + credential = self.get_credential(credential_id) + credly_api = CredlyAPIClient(credential.organization.uuid) + revoke_data = { + "reason": _("Open edX internal user credential was revoked"), + } + try: + response = credly_api.revoke_badge(user_credential.uuid, revoke_data) + except CredlyAPIError: + user_credential.state = "error" + user_credential.save() + raise + + user_credential.state = response.get("data").get("state") + user_credential.save() + + def award(self, credential_id, username): + user_credential = super().award(credential_id, username) + if not user_credential.is_issued: + self.issue_credly_badge(credential_id, user_credential) + return user_credential + + def revoke(self, credential_id, username): + user_credential = super().revoke(credential_id, username) + if user_credential.is_issued: + self.revoke_credly_badge(credential_id, user_credential) + return user_credential diff --git a/credentials/apps/badges/services/processing.py b/credentials/apps/badges/services/processing.py index 5d25bdcbe..ef19f7618 100644 --- a/credentials/apps/badges/services/processing.py +++ b/credentials/apps/badges/services/processing.py @@ -2,15 +2,20 @@ Main processing logic. """ -from openedx_events.learning.data import CoursePassingStatusData +import logging +from credentials.apps.badges.exceptions import ( + BadgesProcessingError, + StopEventProcessing, +) from credentials.apps.core.api import get_or_create_user_from_event_data -from ..models import CredlyBadgeTemplate -from ..signals import BADGE_PROGRESS_COMPLETE, BADGE_PROGRESS_INCOMPLETE -from ..services.awarding import discover_requirements -from ..services.revocation import discover_penalties -from ..utils import keypath, get_user_data +from ..services.awarding import process_requirements +from ..services.revocation import process_penalties +from ..utils import extract_payload, get_user_data + + +logger = logging.getLogger(__name__) def process_event(sender, **kwargs): @@ -19,63 +24,56 @@ def process_event(sender, **kwargs): Responsibilities: - event's User identification (whose action); - - ... + - requirements processing; + - penalties processing; + """ + + event_type = sender.event_type + + try: + # user identification + username = identify_user(event_type=event_type, event_payload=extract_payload(kwargs)) + + # requirements processing + process_requirements(event_type, username, extract_payload(kwargs, as_dict=True)) + + # penalties processing + # process_penalties(event_type, username, extract_payload(kwargs, as_dict=True)) + + except StopEventProcessing: + # controlled processing dropping + return + + except BadgesProcessingError as error: + logger.error(f"Badges processing error: {error}") + return + + +def identify_user(*, event_type, event_payload): + """ + Identifies event user based on provided keyword arguments and returns the username. + + This function extracts user data from the given event's keyword arguments, attempts to identify existing user + or creates a new user based on this data, and then returns the username. + + Args: + **kwargs: public event keyword arguments containing user identification data. + + Returns: + str: The username of the identified (and created if needed) user. + + Raises: + BadgesProcessingError: if user data was not found. """ - # create/update signal User: - # user_data = get_user_data(kwargs) - not yet implemented - # event_user = get_or_create_user_from_event_data(user_data) - - # incoming signals (e.g. Messages) processing pipeline: - # - identify user in the Message; - # - check if such user exist (update or create); - # - collect all Requirements for Message event type; - # - no Requirements - nothing to process - STOP; - # - for each found Requirement: - # - see its `effect` (award | revoke) - - # AWARD FLOW: - # - check if the related badge template already completed - # - if BadgeProgress exists and BadgeProgress.complete == true >> badge already earned - STOP; - # - check if it is not fulfilled yet - # - if fulfilled (related Fulfillment exists) - STOP; - # - apply payload rules (data-rules); - # - if applied - fulfill the Requirement: - # - create related Fulfillment - # - update of create BadgeProgress - # - BadgeProgress completeness check - check if it was enough for badge earning - # - if BadgeProgress.complete == true - # - emit BADGE_PROGRESS_COMPLETE >> handle_badge_completion - # - # REVOKE FLOW: - # - TBD - # - ... - # - BADGE_PROGRESS_INCOMPLETE emitted >> handle_badge_regression (possibly, we need a flag here) - - user_data = get_user_data(kwargs) - username = get_or_create_user_from_event_data(user_data)[0].username - requirements = discover_requirements(sender) - penalties = discover_penalties(sender) - - # faked: related to the BadgeRequirement template (in real processing): - badge_template_id = CredlyBadgeTemplate.objects.first().id - - - if ( - keypath(kwargs, "course_passing_status.status") - == CoursePassingStatusData.PASSING - ): - BADGE_PROGRESS_COMPLETE.send( - sender=sender, - username=keypath(kwargs, "course_passing_status.user.pii.username"), - badge_template_id=badge_template_id, - ) - - if ( - keypath(kwargs, "course_passing_status.status") - == CoursePassingStatusData.FAILING - ): - BADGE_PROGRESS_INCOMPLETE.send( - sender=sender, - username=keypath(kwargs, "course_passing_status.user.pii.username"), - badge_template_id=badge_template_id, - ) + + user_data = get_user_data(event_payload) + + # FIXME: didn't find! + user_data = event_payload["course_passing_status"].user + + if not user_data: + message = f"User data cannot be found (got: {user_data}): {event_payload}. Does event {event_type} include user data at all?" + raise BadgesProcessingError(message) + + user, __ = get_or_create_user_from_event_data(user_data) + return user.username diff --git a/credentials/apps/badges/services/revocation.py b/credentials/apps/badges/services/revocation.py index 0113f409a..ae4d2b2d9 100644 --- a/credentials/apps/badges/services/revocation.py +++ b/credentials/apps/badges/services/revocation.py @@ -1,48 +1,92 @@ """ Revocation pipeline - badge regression. """ -import uuid +import uuid from typing import List -from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData +from openedx_events.learning.data import ( + BadgeData, + BadgeTemplateData, + CoursePassingStatusData, + UserData, + UserPersonalData, +) from openedx_events.learning.signals import BADGE_REVOKED from ..models import BadgePenalty, CredlyBadgeTemplate +from ..signals.signals import BADGE_PROGRESS_INCOMPLETE +from ..utils import keypath + + +def discover_penalties(event_type: str) -> List[BadgePenalty]: + return BadgePenalty.objects.filter(event_type=event_type) -def notify_badge_revoked(username, badge_template_uuid): # pylint: disable=unused-argument +def process_penalties(event_type, username, payload_dict): + """ + REVOKE FLOW: + - check if the related badge template already completed + - if BadgeProgress exists and BadgeProgress.complete == true >> badge already earned - STOP; + - check if it is not fulfilled yet + - if fulfilled (related Fulfillment exists) - STOP; + - apply payload rules (data-rules); + - if applied - fulfill the Requirement: + - create related Fulfillment + - update of create BadgeProgress + - BadgeProgress completeness check - check if it was enough for badge earning + - if BadgeProgress.complete == false + - emit BADGE_PROGRESS_INCOMPLETE >> handle_badge_incompletion + """ + + # TEMP: remove this stub after processing is implemented + if keypath(payload_dict, "course_passing_status.status") == CoursePassingStatusData.FAILING: + BADGE_PROGRESS_INCOMPLETE.send( + sender=None, + username=username, + badge_template_id=CredlyBadgeTemplate.objects.first().id, + ) + + # :TEMP + + penalties = discover_penalties(event_type=event_type) + + # actual processing goes here: + + # for penalty in penalties: + # penalty.apply_rules(**kwargs) + # penalty.assign(username) + + +def notify_badge_revoked(user_credential): # pylint: disable=unused-argument """ Emit public event about badge template regression. - username - badge template ID """ - badge_template = CredlyBadgeTemplate.by_uuid(badge_template_uuid) - # user = get_user_by_username(username) - # UserCredential.as_badge_data(): + # TODO: make user-credential responsible for its conversion into signal payload: + # e.g.: badge_data = CredlyBadge.as_badge_data() + badge_data = BadgeData( uuid=str(uuid.uuid4()), user=UserData( pii=UserPersonalData( - username='event_user-username', - email='event_user-email', - name='event_user-name', + username="event_user-username", + email="event_user-email", + name="event_user-name", ), id=1, is_active=True, ), template=BadgeTemplateData( - uuid=str(badge_template.uuid), - origin=badge_template.origin, - name=badge_template.name, - description=badge_template.description, - image_url=str(badge_template.icon), + uuid=str(uuid.uuid4()), + origin="faked.origin", + name="faked.name", + description="feaked.description", + image_url="faked.badge_template.icon", ), ) BADGE_REVOKED.send_event(badge=badge_data) - -def discover_penalties(event_type: str) -> List[BadgePenalty]: - return BadgePenalty.objects.filter(event_type=event_type) \ No newline at end of file diff --git a/credentials/apps/badges/signals/__init__.py b/credentials/apps/badges/signals/__init__.py index 2b57f271e..4c11d0992 100644 --- a/credentials/apps/badges/signals/__init__.py +++ b/credentials/apps/badges/signals/__init__.py @@ -1 +1 @@ -from .signals import * \ No newline at end of file +from .signals import * diff --git a/credentials/apps/badges/signals/handlers.py b/credentials/apps/badges/signals/handlers.py index fb8911cbb..5a6d32730 100644 --- a/credentials/apps/badges/signals/handlers.py +++ b/credentials/apps/badges/signals/handlers.py @@ -50,10 +50,7 @@ def handle_badge_completion(sender, username, badge_template_id, **kwargs): # p - badge template ID """ - CredlyBadgeTemplateIssuer().award( - badge_template_id, - username - ) + CredlyBadgeTemplateIssuer().award(badge_template_id, username) @receiver(BADGE_PROGRESS_INCOMPLETE) @@ -65,7 +62,4 @@ def handle_badge_regression(sender, username, badge_template_id, **kwargs): # p - badge template ID """ - CredlyBadgeTemplateIssuer().revoke( - badge_template_id, - username, - ) + CredlyBadgeTemplateIssuer().revoke(badge_template_id, username) diff --git a/credentials/apps/badges/tests/test_issuers.py b/credentials/apps/badges/tests/test_issuers.py index 4097f76e1..a81dc7393 100644 --- a/credentials/apps/badges/tests/test_issuers.py +++ b/credentials/apps/badges/tests/test_issuers.py @@ -19,55 +19,45 @@ def setUp(self): # Create a test badge template fake = faker.Faker() credly_organization = CredlyOrganization.objects.create( - uuid=fake.uuid4(), - api_key=fake.uuid4(), - name=fake.word() + uuid=fake.uuid4(), api_key=fake.uuid4(), name=fake.word() ) self.badge_template = self.issued_credential_type.objects.create( origin=self.issued_credential_type.ORIGIN, site_id=1, uuid=fake.uuid4(), name=fake.word(), - state='active', - organization=credly_organization + state="active", + organization=credly_organization, ) def test_create_user_credential_with_status_awared(self): # Call create_user_credential with valid arguments - with mock.patch('credentials.apps.badges.services.issuers.notify_badge_awarded') as mock_notify_badge_awarded: - self.issuer().award( - self.badge_template.id, - 'test_user' - ) + with mock.patch("credentials.apps.badges.services.issuers.notify_badge_awarded") as mock_notify_badge_awarded: + self.issuer().award(self.badge_template.id, "test_user") mock_notify_badge_awarded.assert_called_once() # Check if user credential is created self.assertTrue( self.issued_user_credential_type.objects.filter( - username='test_user', - credential_content_type=ContentType.objects.get_for_model( - self.badge_template), + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), credential_id=self.badge_template.id, ).exists() ) def test_create_user_credential_with_status_revoked(self): # Call create_user_credential with valid arguments - with mock.patch('credentials.apps.badges.services.issuers.notify_badge_revoked') as mock_notify_badge_revoked: - self.issuer().revoke( - self.badge_template.id, - 'test_user' - ) + with mock.patch("credentials.apps.badges.services.issuers.notify_badge_revoked") as mock_notify_badge_revoked: + self.issuer().revoke(self.badge_template.id, "test_user") mock_notify_badge_revoked.assert_called_once() # Check if user credential is created self.assertTrue( self.issued_user_credential_type.objects.filter( - username='test_user', - credential_content_type=ContentType.objects.get_for_model( - self.badge_template), + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), credential_id=self.badge_template.id, status=UserCredentialStatus.REVOKED, ).exists() diff --git a/credentials/apps/badges/tests/test_models.py b/credentials/apps/badges/tests/test_models.py index 60e0a3675..46d67627b 100644 --- a/credentials/apps/badges/tests/test_models.py +++ b/credentials/apps/badges/tests/test_models.py @@ -5,20 +5,24 @@ from ..models import ( BadgeProgress, - BadgeRequirement, + BadgeRequirement, BadgeTemplate, CredlyBadgeTemplate, CredlyOrganization, DataRule, - Fulfillment + Fulfillment, ) class DataRulesTestCase(TestCase): def setUp(self): - self.organization = CredlyOrganization.objects.create(uuid=uuid.uuid4(), api_key="test-api-key", name="test_organization") + self.organization = CredlyOrganization.objects.create( + uuid=uuid.uuid4(), api_key="test-api-key", name="test_organization" + ) self.site = Site.objects.create(domain="test_domain", name="test_name") - self.badge_template = CredlyBadgeTemplate.objects.create(organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) + self.badge_template = CredlyBadgeTemplate.objects.create( + organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) self.requirement = BadgeRequirement.objects.create( template=self.badge_template, event_type="org.openedx.learning.course.passing.status.updated.v1", @@ -48,11 +52,17 @@ def test_multiple_data_rules_for_requirement(self): class BadgeRequirementTestCase(TestCase): def setUp(self): - self.organization = CredlyOrganization.objects.create(uuid=uuid.uuid4(), api_key="test-api-key", name="test_organization") + self.organization = CredlyOrganization.objects.create( + uuid=uuid.uuid4(), api_key="test-api-key", name="test_organization" + ) self.site = Site.objects.create(domain="test_domain", name="test_name") - self.badge_template = BadgeTemplate.objects.create(uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) - self.credlybadge_template = CredlyBadgeTemplate.objects.create(organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) - + self.badge_template = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) + self.credlybadge_template = CredlyBadgeTemplate.objects.create( + organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) + def test_multiple_requirements_for_badgetemplate(self): self.requirement1 = BadgeRequirement.objects.create( template=self.badge_template, @@ -101,33 +111,51 @@ def test_multiple_requirements_for_credlybadgetemplate(self): self.assertIn(self.requirement2, requirements) self.assertIn(self.requirement3, requirements) - + class RequirementFulfillmentCheckTestCase(TestCase): def setUp(self): self.site = Site.objects.create(domain="test_domain", name="test_name") - self.badge_template1 = BadgeTemplate.objects.create(uuid=uuid.uuid4(), name="test_template1", state="draft", site=self.site) - self.badge_template2 = BadgeTemplate.objects.create(uuid=uuid.uuid4(), name="test_template2", state="draft", site=self.site) - self.badge_progress = BadgeProgress.objects.create(template=self.badge_template1, username='test1') - self.badge_requirement = BadgeRequirement.objects.create(template=self.badge_template1, event_type="org.openedx.learning.course.passing.status.updated.v1") + self.badge_template1 = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template1", state="draft", site=self.site + ) + self.badge_template2 = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template2", state="draft", site=self.site + ) + self.badge_progress = BadgeProgress.objects.create(template=self.badge_template1, username="test1") + self.badge_requirement = BadgeRequirement.objects.create( + template=self.badge_template1, event_type="org.openedx.learning.course.passing.status.updated.v1" + ) self.fulfillment = Fulfillment.objects.create(progress=self.badge_progress, requirement=self.badge_requirement) - + def test_fulfillment_check_success(self): - is_fulfilled = self.badge_requirement.is_fullfiled('test1') + is_fulfilled = self.badge_requirement.is_fullfiled("test1") self.assertTrue(is_fulfilled) - + def test_fulfillment_check_wrong_username(self): - is_fulfilled = self.badge_requirement.is_fullfiled('asd') + is_fulfilled = self.badge_requirement.is_fullfiled("asd") self.assertFalse(is_fulfilled) - + class BadgeRequirementGroupTestCase(TestCase): def setUp(self): self.site = Site.objects.create(domain="test_domain", name="test_name") - self.badge_template = BadgeTemplate.objects.create(uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) - self.badge_requirement1 = BadgeRequirement.objects.create(template=self.badge_template, event_type="org.openedx.learning.course.passing.status.updated.v1", group="group1") - self.badge_requirement2 = BadgeRequirement.objects.create(template=self.badge_template, event_type="org.openedx.learning.ccx.course.passing.status.updated.v1", group="group1") - self.badge_requirement3 = BadgeRequirement.objects.create(template=self.badge_template, event_type="org.openedx.learning.course.passing.status.updated.v1") - + self.badge_template = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) + self.badge_requirement1 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + group="group1", + ) + self.badge_requirement2 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.ccx.course.passing.status.updated.v1", + group="group1", + ) + self.badge_requirement3 = BadgeRequirement.objects.create( + template=self.badge_template, event_type="org.openedx.learning.course.passing.status.updated.v1" + ) + def test_requirement_group(self): group = self.badge_template.badgerequirement_set.filter(group="group1") self.assertEqual(group.count(), 2) @@ -136,10 +164,16 @@ def test_requirement_group(self): class BadgeTemplateUserProgressTestCase(TestCase): def setUp(self): - self.organization = CredlyOrganization.objects.create(uuid=uuid.uuid4(), api_key="test-api-key", name="test_organization") + self.organization = CredlyOrganization.objects.create( + uuid=uuid.uuid4(), api_key="test-api-key", name="test_organization" + ) self.site = Site.objects.create(domain="test_domain", name="test_name") - self.badge_template = BadgeTemplate.objects.create(uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) - self.credlybadge_template = CredlyBadgeTemplate.objects.create(organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) + self.badge_template = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) + self.credlybadge_template = CredlyBadgeTemplate.objects.create( + organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) self.requirement1 = BadgeRequirement.objects.create( template=self.badge_template, event_type="org.openedx.learning.course.passing.status.updated.v1", @@ -162,22 +196,28 @@ def test_user_progress_success(self): requirement=self.requirement1, ) self.assertEqual(self.badge_template.user_progress("test_user"), 0.33) - + def test_user_progress_no_fulfillments(self): Fulfillment.objects.filter(progress__template=self.badge_template).delete() self.assertEqual(self.badge_template.user_progress("test_user"), 0.0) - + def test_user_progress_no_requirements(self): BadgeRequirement.objects.filter(template=self.badge_template).delete() self.assertEqual(self.badge_template.user_progress("test_user"), 0.0) - + class BadgeTemplateUserCompletionTestCase(TestCase): def setUp(self): - self.organization = CredlyOrganization.objects.create(uuid=uuid.uuid4(), api_key="test-api", name="test_organization") + self.organization = CredlyOrganization.objects.create( + uuid=uuid.uuid4(), api_key="test-api", name="test_organization" + ) self.site = Site.objects.create(domain="test_domain", name="test_name") - self.badge_template = BadgeTemplate.objects.create(uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) - self.credlybadge_template = CredlyBadgeTemplate.objects.create(organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) + self.badge_template = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) + self.credlybadge_template = CredlyBadgeTemplate.objects.create( + organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) self.requirement1 = BadgeRequirement.objects.create( template=self.badge_template, event_type="org.openedx.learning.course.passing.status.updated.v1", @@ -201,10 +241,16 @@ def test_user_completion_no_requirements(self): class BadgeTemplateRatioTestCase(TestCase): def setUp(self): - self.organization = CredlyOrganization.objects.create(uuid=uuid.uuid4(), api_key="test-api", name="test_organization") + self.organization = CredlyOrganization.objects.create( + uuid=uuid.uuid4(), api_key="test-api", name="test_organization" + ) self.site = Site.objects.create(domain="test_domain", name="test_name") - self.badge_template = BadgeTemplate.objects.create(uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) - self.credlybadge_template = CredlyBadgeTemplate.objects.create(organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site) + self.badge_template = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) + self.credlybadge_template = CredlyBadgeTemplate.objects.create( + organization=self.organization, uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) self.requirement1 = BadgeRequirement.objects.create( template=self.badge_template, event_type="org.openedx.learning.course.passing.status.updated.v1", diff --git a/credentials/apps/badges/tests/test_services.py b/credentials/apps/badges/tests/test_services.py index 4d5bc18ab..e765e5ec8 100644 --- a/credentials/apps/badges/tests/test_services.py +++ b/credentials/apps/badges/tests/test_services.py @@ -10,21 +10,32 @@ class BadgeRequirementDiscoveryTestCase(TestCase): def setUp(self): - self.organization = CredlyOrganization.objects.create(uuid=uuid.uuid4(), api_key="test-api-key", - name="test_organization") + self.organization = CredlyOrganization.objects.create( + uuid=uuid.uuid4(), api_key="test-api-key", name="test_organization" + ) self.site = Site.objects.create(domain="test_domain", name="test_name") - self.badge_template = BadgeTemplate.objects.create(uuid=uuid.uuid4(), name="test_template", state="draft", - site=self.site) + self.badge_template = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) self.COURSE_PASSING_EVENT = "org.openedx.learning.course.passing.status.updated.v1" self.CCX_COURSE_PASSING_EVENT = "org.openedx.learning.ccx.course.passing.status.updated.v1" def test_discovery_eventtype_related_requirements(self): - BadgeRequirement.objects.create(template=self.badge_template, event_type=self.COURSE_PASSING_EVENT, - description="Test course passing award description") - BadgeRequirement.objects.create(template=self.badge_template, event_type=self.CCX_COURSE_PASSING_EVENT, - description="Test ccx course passing award description") - BadgeRequirement.objects.create(template=self.badge_template, event_type=self.CCX_COURSE_PASSING_EVENT, - description="Test ccx course passing revoke description") + BadgeRequirement.objects.create( + template=self.badge_template, + event_type=self.COURSE_PASSING_EVENT, + description="Test course passing award description", + ) + BadgeRequirement.objects.create( + template=self.badge_template, + event_type=self.CCX_COURSE_PASSING_EVENT, + description="Test ccx course passing award description", + ) + BadgeRequirement.objects.create( + template=self.badge_template, + event_type=self.CCX_COURSE_PASSING_EVENT, + description="Test ccx course passing revoke description", + ) course_passing_requirements = discover_requirements(event_type=self.COURSE_PASSING_EVENT) ccx_course_passing_requirements = discover_requirements(event_type=self.CCX_COURSE_PASSING_EVENT) self.assertEqual(course_passing_requirements.count(), 1) @@ -36,31 +47,50 @@ def test_discovery_eventtype_related_requirements(self): class BadgePenaltyDiscoveryTestCase(TestCase): def setUp(self): - self.organization = CredlyOrganization.objects.create(uuid=uuid.uuid4(), api_key="test-api-key", - name="test_organization") + self.organization = CredlyOrganization.objects.create( + uuid=uuid.uuid4(), api_key="test-api-key", name="test_organization" + ) self.site = Site.objects.create(domain="test_domain", name="test_name") - self.badge_template = BadgeTemplate.objects.create(uuid=uuid.uuid4(), name="test_template", state="draft", - site=self.site) + self.badge_template = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) self.COURSE_PASSING_EVENT = "org.openedx.learning.course.passing.status.updated.v1" self.CCX_COURSE_PASSING_EVENT = "org.openedx.learning.ccx.course.passing.status.updated.v1" def test_discovery_eventtype_related_penalties(self): BadgePenalty.objects.create(template=self.badge_template).requirements.set( - BadgeRequirement.objects.create(template=self.badge_template, event_type=self.COURSE_PASSING_EVENT, - description="Test course passing award description")) + BadgeRequirement.objects.create( + template=self.badge_template, + event_type=self.COURSE_PASSING_EVENT, + description="Test course passing award description", + ) + ) BadgePenalty.objects.create(template=self.badge_template).requirements.set( - BadgeRequirement.objects.create(template=self.badge_template, event_type=self.CCX_COURSE_PASSING_EVENT, - description="Test ccx course passing award description")) + BadgeRequirement.objects.create( + template=self.badge_template, + event_type=self.CCX_COURSE_PASSING_EVENT, + description="Test ccx course passing award description", + ) + ) BadgePenalty.objects.create(template=self.badge_template).requirements.set( - BadgeRequirement.objects.create(template=self.badge_template, event_type=self.CCX_COURSE_PASSING_EVENT, - description="Test ccx course passing revoke description")) + BadgeRequirement.objects.create( + template=self.badge_template, + event_type=self.CCX_COURSE_PASSING_EVENT, + description="Test ccx course passing revoke description", + ) + ) course_passing_penalties = discover_penalties(event_type=self.COURSE_PASSING_EVENT) ccx_course_passing_penalties = discover_penalties(event_type=self.CCX_COURSE_PASSING_EVENT) self.assertEqual(course_passing_penalties.count(), 1) self.assertEqual(ccx_course_passing_penalties.count(), 2) - self.assertEqual(course_passing_penalties[0].requirements.first().description, - "Test course passing award description") - self.assertEqual(ccx_course_passing_penalties[0].requirements.first().description, - "Test ccx course passing award description") - self.assertEqual(ccx_course_passing_penalties[1].requirements.first().description, - "Test ccx course passing revoke description") + self.assertEqual( + course_passing_penalties[0].requirements.first().description, "Test course passing award description" + ) + self.assertEqual( + ccx_course_passing_penalties[0].requirements.first().description, + "Test ccx course passing award description", + ) + self.assertEqual( + ccx_course_passing_penalties[1].requirements.first().description, + "Test ccx course passing revoke description", + ) diff --git a/credentials/apps/badges/tests/test_signals.py b/credentials/apps/badges/tests/test_signals.py index 7555d3618..508e259cc 100644 --- a/credentials/apps/badges/tests/test_signals.py +++ b/credentials/apps/badges/tests/test_signals.py @@ -9,22 +9,20 @@ class BadgeSignalReceiverTestCase(TestCase): def setUp(self): # Create a test badge template - self.badge_template = BadgeTemplate.objects.create( - name='test', site_id=1) + self.badge_template = BadgeTemplate.objects.create(name="test", site_id=1) def test_signal_emission_and_receiver_execution(self): # Emit the signal BADGE_PROGRESS_COMPLETE.send( sender=self, - username='test_user', + username="test_user", badge_template_id=self.badge_template.id, ) # UserCredential object user_credential = UserCredential.objects.filter( - username='test_user', - credential_content_type=ContentType.objects.get_for_model( - self.badge_template), + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), credential_id=self.badge_template.id, ) @@ -32,17 +30,15 @@ def test_signal_emission_and_receiver_execution(self): self.assertTrue(user_credential.exists()) # Check if user credential status is 'awarded' - self.assertTrue(user_credential[0].status == 'awarded') + self.assertTrue(user_credential[0].status == "awarded") def test_behavior_for_nonexistent_badge_templates(self): # Emit the signal with a non-existent badge template ID BADGE_PROGRESS_COMPLETE.send( sender=self, - username='test_user', + username="test_user", badge_template_id=999, # Non-existent ID ) # Check that no user credential is created - self.assertFalse( - UserCredential.objects.filter(username='test_user').exists() - ) + self.assertFalse(UserCredential.objects.filter(username="test_user").exists()) diff --git a/credentials/apps/badges/tests/test_utils.py b/credentials/apps/badges/tests/test_utils.py index 4d2ad327e..aa2237202 100644 --- a/credentials/apps/badges/tests/test_utils.py +++ b/credentials/apps/badges/tests/test_utils.py @@ -48,7 +48,7 @@ def setUp(self): def test_datapath_valid_success(self): is_valid = is_datapath_valid("course_passing_status.user.pii.username", self.event_type) self.assertTrue(is_valid) - + def test_datapath_valid_failure(self): is_valid = is_datapath_valid("course_passing_status.user.username", self.event_type) self.assertFalse(is_valid) @@ -56,14 +56,32 @@ def test_datapath_valid_failure(self): class TestGetUserData(unittest.TestCase): def setUp(self): - self.course_data_1 = CourseData(course_key="CS101", display_name="Introduction to Computer Science", start=datetime(2024, 4, 1), end=datetime(2024, 6, 1)) - self.user_data_1 = UserData(id=1, is_active=True, pii=UserPersonalData(username="user1", email="user1@example.com", name="John Doe")) - - self.course_data_2 = CourseData(course_key="PHY101", display_name="Introduction to Physics", start=datetime(2024, 4, 15), end=datetime(2024, 7, 15)) - self.user_data_2 = UserData(id=2, is_active=False, pii=UserPersonalData(username="user2", email="user2@example.com", name="Jane Doe")) - - self.passing_status_1 = CoursePassingStatusData(status=CoursePassingStatusData.PASSING, course=self.course_data_1, user=self.user_data_1) - self.failing_status_1 = CoursePassingStatusData(status=CoursePassingStatusData.FAILING, course=self.course_data_2, user=self.user_data_2) + self.course_data_1 = CourseData( + course_key="CS101", + display_name="Introduction to Computer Science", + start=datetime(2024, 4, 1), + end=datetime(2024, 6, 1), + ) + self.user_data_1 = UserData( + id=1, is_active=True, pii=UserPersonalData(username="user1", email="user1@example.com", name="John Doe") + ) + + self.course_data_2 = CourseData( + course_key="PHY101", + display_name="Introduction to Physics", + start=datetime(2024, 4, 15), + end=datetime(2024, 7, 15), + ) + self.user_data_2 = UserData( + id=2, is_active=False, pii=UserPersonalData(username="user2", email="user2@example.com", name="Jane Doe") + ) + + self.passing_status_1 = CoursePassingStatusData( + status=CoursePassingStatusData.PASSING, course=self.course_data_1, user=self.user_data_1 + ) + self.failing_status_1 = CoursePassingStatusData( + status=CoursePassingStatusData.FAILING, course=self.course_data_2, user=self.user_data_2 + ) def test_get_user_data_from_course_enrollment(self): result_1 = get_user_data(self.passing_status_1) diff --git a/credentials/apps/badges/utils.py b/credentials/apps/badges/utils.py index d09c941d6..ead6a8e11 100644 --- a/credentials/apps/badges/utils.py +++ b/credentials/apps/badges/utils.py @@ -1,6 +1,6 @@ -import attr import inspect +import attr from attrs import asdict from django.conf import settings from openedx_events.learning.data import UserData @@ -58,19 +58,22 @@ def is_datapath_valid(datapath: str, event_type: str) -> bool: event_types = get_badging_event_types() if event_type not in event_types: return False - obj = OpenEdxPublicSignal.get_signal_by_type(event_type).init_data[path[0]] - for key in path[1:]: - try: - field_type = [field for field in attr.fields(obj) if field.name == key][0].type - except IndexError: - return False - else: - obj = field_type - if not attr.has(obj): - if key == path[-1]: - return True + try: + obj = OpenEdxPublicSignal.get_signal_by_type(event_type).init_data[path[0]] + except KeyError: + return False + else: + for key in path[1:]: + try: + field_type = [field for field in attr.fields(obj) if field.name == key][0].type + except IndexError: return False - + else: + obj = field_type + if not attr.has(obj): + if key == path[-1]: + return True + return False def get_user_data(data) -> UserData: @@ -85,7 +88,7 @@ def get_user_data(data) -> UserData: """ if isinstance(data, UserData): return data - + for _, attr_value in inspect.getmembers(data): if isinstance(attr_value, UserData): return attr_value @@ -94,3 +97,19 @@ def get_user_data(data) -> UserData: if user_data: return user_data return None + + +def extract_payload(public_signal_kwargs: dict, as_dict=False) -> dict: + """ + Extracts the event payload from the event data. + + Parameters: + - payload: The event data. + - as_dict: Transform returned dict to primitives. + + Returns: + dict: The event "cleaned" payload. + """ + for key, value in public_signal_kwargs.items(): + if attr.has(value): + return {key: asdict(value)} if as_dict else {key: value} diff --git a/credentials/apps/core/api.py b/credentials/apps/core/api.py index 5214ab8fe..cfc4341df 100644 --- a/credentials/apps/core/api.py +++ b/credentials/apps/core/api.py @@ -62,4 +62,4 @@ def get_or_create_user_from_event_data(user_data): user.email = user_data.pii.email user.save() - return user, created \ No newline at end of file + return user, created diff --git a/credentials/apps/credentials/migrations/0027_auto_20240408_1614.py b/credentials/apps/credentials/migrations/0027_auto_20240408_1614.py new file mode 100644 index 000000000..a8717aa6b --- /dev/null +++ b/credentials/apps/credentials/migrations/0027_auto_20240408_1614.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.20 on 2024-04-08 16:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('credentials', '0026_alter_usercredential_credential_content_type'), + ] + + operations = [ + migrations.AlterField( + model_name='usercredential', + name='credential_content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ('coursecertificate', 'programcertificate', 'credlybadgetemplate')}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='usercredential', + name='uuid', + field=models.UUIDField(blank=True, null=True, unique=True), + ), + ] diff --git a/credentials/apps/credentials/models.py b/credentials/apps/credentials/models.py index c9d6af276..31240dc19 100644 --- a/credentials/apps/credentials/models.py +++ b/credentials/apps/credentials/models.py @@ -162,7 +162,7 @@ class UserCredential(TimeStampedModel): credential_content_type = models.ForeignKey( ContentType, - limit_choices_to={"model__in": ("coursecertificate", "programcertificate", "badgetemplate")}, + limit_choices_to={"model__in": ("coursecertificate", "programcertificate", "credlybadgetemplate")}, on_delete=models.CASCADE, ) credential_id = models.PositiveIntegerField() @@ -177,7 +177,7 @@ class UserCredential(TimeStampedModel): download_url = models.CharField( max_length=255, blank=True, null=True, help_text=_("URL at which the credential can be downloaded") ) - uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + uuid = models.UUIDField(blank=True, null=True, unique=True) class Meta: unique_together = (("username", "credential_content_type", "credential_id"),) diff --git a/credentials/settings/base.py b/credentials/settings/base.py index 40a3d49db..7603b6e3d 100644 --- a/credentials/settings/base.py +++ b/credentials/settings/base.py @@ -604,4 +604,4 @@ "CREDLY_SANDBOX_API_BASE_URL": "https://sandbox-api.credly.com/v1/", "USE_SANDBOX": False, }, -} \ No newline at end of file +}