diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 5abfd0ef6..c8f4520f9 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -13,6 +13,22 @@ auth.Group: ".. no_pii:": "This model has no PII" auth.Permission: ".. no_pii:": "This model has no PII" +badges.BadgePenalty: + ".. no_pii:": "This model has no PII" +badges.BadgeProgress: + ".. pii": "Username" + ".. pii_types": other + ".. pii_retirement": retained +badges.BadgeRequirement: + ".. no_pii:": "This model has no PII" +badges.CredlyOrganization: + ".. no_pii:": "This model has no PII" +badges.DataRule: + ".. no_pii:": "This model has no PII" +badges.Fulfillment: + ".. no_pii:": "This model has no PII" +badges.PenaltyDataRule: + ".. no_pii:": "This model has no PII" credentials.HistoricalProgramCompletionEmailConfiguration: ".. no_pii:": "This model has no PII" contenttypes.ContentType: diff --git a/credentials/apps/badges/__init__.py b/credentials/apps/badges/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/admin.py b/credentials/apps/badges/admin.py new file mode 100644 index 000000000..bdf43756b --- /dev/null +++ b/credentials/apps/badges/admin.py @@ -0,0 +1,551 @@ +""" +Admin section configuration. +""" + +from django.contrib import admin, messages +from django.contrib.sites.shortcuts import get_current_site +from django.core.management import call_command +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from credentials.apps.badges.admin_forms import ( + BadgePenaltyForm, + BadgeRequirementForm, + BadgeRequirementFormSet, + CredlyOrganizationAdminForm, + DataRuleForm, + DataRuleFormSet, + PenaltyDataRuleForm, + PenaltyDataRuleFormSet, +) +from credentials.apps.badges.models import ( + BadgePenalty, + BadgeProgress, + BadgeRequirement, + CredlyBadge, + CredlyBadgeTemplate, + CredlyOrganization, + DataRule, + Fulfillment, + PenaltyDataRule, +) +from credentials.apps.badges.toggles import is_badges_enabled + + +class BadgeRequirementInline(admin.TabularInline): + """ + Badge template requirement inline setup. + """ + + model = BadgeRequirement + show_change_link = True + extra = 0 + fields = ( + "event_type", + "rules", + "description", + "blend", + ) + readonly_fields = ("rules",) + ordering = ("blend",) + form = BadgeRequirementForm + formset = BadgeRequirementFormSet + + def rules(self, obj): + """ + Display all data rules for the requirement. + """ + return ( + format_html( + "", + mark_safe( + "".join( + f"
  • {rule.data_path} {rule.OPERATORS[rule.operator]} {rule.value}
  • " + for rule in obj.rules.all() + ) + ), + ) + if obj.rules.exists() + else _("No rules specified.") + ) + + +class BadgePenaltyInline(admin.TabularInline): + """ + Badge template penalty inline setup. + """ + + model = BadgePenalty + show_change_link = True + extra = 0 + fields = ( + "event_type", + "rules", + "requirements", + ) + readonly_fields = ("rules",) + form = BadgePenaltyForm + + def formfield_for_manytomany(self, db_field, request, **kwargs): + """ + Filter requirements by parent badge template. + """ + if db_field.name == "requirements": + template_id = request.resolver_match.kwargs.get("object_id") + if template_id: + kwargs["queryset"] = BadgeRequirement.objects.filter(template_id=template_id) + return super().formfield_for_manytomany(db_field, request, **kwargs) + + def rules(self, obj): + """ + Display all data rules for the penalty. + """ + return ( + format_html( + "", + mark_safe( + "".join( + f"
  • {rule.data_path} {rule.OPERATORS[rule.operator]} {rule.value}
  • " + for rule in obj.rules.all() + ) + ), + ) + if obj.rules.exists() + else _("No rules specified.") + ) + + +class FulfillmentInline(admin.TabularInline): + """ + Badge template fulfillment inline setup. + """ + + model = Fulfillment + extra = 0 + readonly_fields = [ + "requirement", + ] + + +class DataRuleInline(admin.TabularInline): + """ + Data rule inline setup. + """ + + model = DataRule + extra = 0 + form = DataRuleForm + formset = DataRuleFormSet + + +class CredlyOrganizationAdmin(admin.ModelAdmin): + """ + Credly organization admin setup. + """ + + form = CredlyOrganizationAdminForm + list_display = ( + "name", + "uuid", + "api_key_hidden", + ) + fields = [ + "name", + "uuid", + "api_key_hidden", + ] + readonly_fields = [ + "name", + ] + actions = ("sync_organization_badge_templates",) + + @admin.action(description="Sync organization badge templates") + def sync_organization_badge_templates(self, request, queryset): + """ + Sync badge templates for selected organizations. + """ + site = get_current_site(request) + for organization in queryset: + call_command( + "sync_organization_badge_templates", + organization_id=organization.uuid, + site_id=site.id, + ) + + messages.success(request, _("Badge templates were successfully updated.")) + + @admin.display(description=_("API key")) + def api_key_hidden(self, obj): + """ + Hide API key and display text. + """ + + return _("Pre-configured from the environment.") if obj.is_preconfigured else obj.api_key + + def get_fields(self, request, obj=None): + fields = super().get_fields(request, obj) + + if not (obj and obj.is_preconfigured): + fields = [field for field in fields if field != "api_key_hidden"] + fields.append("api_key") + return fields + + def get_readonly_fields(self, request, obj=None): + readonly_fields = list(super().get_readonly_fields(request, obj)) + + if not obj: + return readonly_fields + + if obj.is_preconfigured: + readonly_fields.append("api_key_hidden") + return readonly_fields + + +class CredlyBadgeTemplateAdmin(admin.ModelAdmin): + """ + Badge template admin setup. + """ + + exclude = [ + "icon", + ] + list_display = ( + "organization", + "state", + "name", + "uuid", + "is_active", + "image", + ) + list_filter = ( + "organization", + "is_active", + "state", + ) + search_fields = ( + "name", + "uuid", + ) + readonly_fields = [ + "organization", + "origin", + "state", + "dashboard_link", + "image", + ] + fieldsets = ( + ( + "Generic", + { + "fields": ( + "site", + "is_active", + ), + "description": _( + """ + WARNING: avoid configuration updates on activated badges. + Active badge templates are continuously processed and learners may already have progress on them. + Any changes in badge template requirements (including data rules) will affect learners' experience! + """ + ), + }, + ), + ( + "Badge template", + { + "fields": ( + "uuid", + "name", + "description", + "image", + "origin", + ) + }, + ), + ( + "Credly", + { + "fields": ( + "organization", + "state", + "dashboard_link", + ), + }, + ), + ) + inlines = [ + BadgeRequirementInline, + BadgePenaltyInline, + ] + + def has_add_permission(self, request): + return False + + def dashboard_link(self, obj): + url = obj.management_url + return format_html("{url}", url=url) + + def delete_model(self, request, obj): + """ + Prevent deletion of active badge templates. + """ + if obj.is_active: + messages.set_level(request, messages.ERROR) + messages.error(request, _("Active badge template cannot be deleted.")) + return + super().delete_model(request, obj) + + def delete_queryset(self, request, queryset): + """ + Prevent deletion of active badge templates. + """ + if queryset.filter(is_active=True).exists(): + messages.set_level(request, messages.ERROR) + messages.error(request, _("Active badge templates cannot be deleted.")) + return + super().delete_queryset(request, queryset) + + def image(self, obj): + """ + Badge template preview image. + """ + if obj.icon: + return format_html('', obj.icon) + return None + + image.short_description = _("icon") + + def save_model(self, request, obj, form, change): + pass + + def save_formset(self, request, form, formset, change): + """ + Check if template is active and has requirements. + """ + formset.save() + + if form.instance.is_active and not form.instance.requirements.exists(): + messages.set_level(request, messages.ERROR) + messages.error(request, _("Active badge template must have at least one requirement.")) + return HttpResponseRedirect(request.path) + return form.instance.save() + + +class DataRulePenaltyInline(admin.TabularInline): + model = PenaltyDataRule + extra = 0 + form = PenaltyDataRuleForm + formset = PenaltyDataRuleFormSet + + +class BadgeRequirementAdmin(admin.ModelAdmin): + """ + Badge template requirement admin setup. + """ + + inlines = [ + DataRuleInline, + ] + + list_display = [ + "id", + "__str__", + "event_type", + "template_link", + ] + list_display_links = ( + "id", + "__str__", + ) + list_filter = [ + "template", + "event_type", + ] + readonly_fields = [ + "template", + "event_type", + "template_link", + "blend", + ] + + fields = [ + "template_link", + "event_type", + "description", + "blend", + ] + + def has_add_permission(self, request): + return False + + def template_link(self, instance): + """ + Interactive link to parent (badge template). + """ + url = reverse("admin:badges_credlybadgetemplate_change", args=[instance.template.pk]) + return format_html('{}', url, instance.template) + + template_link.short_description = _("badge template") + + def response_change(self, request, obj): + if "_save" in request.POST: + return HttpResponseRedirect(reverse("admin:badges_credlybadgetemplate_change", args=[obj.template.pk])) + return super().response_change(request, obj) + + +class BadgePenaltyAdmin(admin.ModelAdmin): + """ + Badge requirement penalty setup admin. + """ + + inlines = [ + DataRulePenaltyInline, + ] + + list_display_links = ( + "id", + "template", + ) + list_display = [ + "id", + "__str__", + "event_type", + "template_link", + ] + list_display_links = ( + "id", + "__str__", + ) + list_filter = [ + "template", + "requirements", + ] + fields = [ + "template_link", + "event_type", + "requirements", + ] + readonly_fields = [ + "template_link", + "event_type", + "requirements", + ] + form = BadgePenaltyForm + + def has_add_permission(self, request): + return False + + def template_link(self, instance): + """ + Interactive link to parent (badge template). + """ + url = reverse("admin:badges_credlybadgetemplate_change", args=[instance.template.pk]) + return format_html('{}', url, instance.template) + + template_link.short_description = _("badge template") + + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name == "requirements": + object_id = request.resolver_match.kwargs.get("object_id") + template_id = self.get_object(request, object_id).template_id + if template_id: + kwargs["queryset"] = BadgeRequirement.objects.filter(template_id=template_id) + return super().formfield_for_manytomany(db_field, request, **kwargs) + + def response_change(self, request, obj): + if "_save" in request.POST: + return HttpResponseRedirect(reverse("admin:badges_credlybadgetemplate_change", args=[obj.template.pk])) + return super().response_change(request, obj) + + +class BadgeProgressAdmin(admin.ModelAdmin): + """ + Badge template progress admin setup. + """ + + inlines = [ + FulfillmentInline, + ] + list_display = [ + "id", + "username", + "template", + "complete", + ] + list_display_links = ( + "id", + "username", + "template", + ) + readonly_fields = ( + "username", + "template", + "complete", + "ratio", + ) + + @admin.display(boolean=True) + def complete(self, obj): + """ + Identifies if all requirements are already fulfilled. + + NOTE: (performance) dynamic evaluation. + """ + return obj.completed + + def ratio(self, obj): + """ + Displays progress value. + """ + return obj.ratio + + def has_add_permission(self, request): + return False + + +class CredlyBadgeAdmin(admin.ModelAdmin): + """ + Credly badge admin setup. + """ + + list_display = ( + "uuid", + "username", + "credential", + "status", + "state", + "external_uuid", + ) + list_filter = ( + "status", + "state", + ) + search_fields = ( + "username", + "external_uuid", + ) + readonly_fields = ( + "credential_id", + "credential_content_type", + "username", + "download_url", + "state", + "uuid", + "external_uuid", + ) + + def has_add_permission(self, request): + return False + + +# register admin configurations with respect to the feature flag +if is_badges_enabled(): + admin.site.register(CredlyOrganization, CredlyOrganizationAdmin) + admin.site.register(CredlyBadgeTemplate, CredlyBadgeTemplateAdmin) + admin.site.register(CredlyBadge, CredlyBadgeAdmin) + admin.site.register(BadgeRequirement, BadgeRequirementAdmin) + admin.site.register(BadgePenalty, BadgePenaltyAdmin) + admin.site.register(BadgeProgress, BadgeProgressAdmin) diff --git a/credentials/apps/badges/admin_forms.py b/credentials/apps/badges/admin_forms.py new file mode 100644 index 000000000..89b70be1d --- /dev/null +++ b/credentials/apps/badges/admin_forms.py @@ -0,0 +1,194 @@ +""" +Badges admin forms. +""" + +from django import forms +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from model_utils import Choices + +from credentials.apps.badges.credly.api_client import CredlyAPIClient +from credentials.apps.badges.credly.exceptions import CredlyAPIError +from credentials.apps.badges.models import ( + AbstractDataRule, + BadgePenalty, + BadgeRequirement, + CredlyOrganization, + DataRule, + PenaltyDataRule, +) +from credentials.apps.badges.utils import get_event_type_attr_type_by_keypath, get_event_type_keypaths + + +class CredlyOrganizationAdminForm(forms.ModelForm): + """ + Additional actions for Credly Organization items. + """ + + api_data = {} + + class Meta: + model = CredlyOrganization + fields = "__all__" + + def clean(self): + """ + Perform Credly API check for given organization ID. + + - Credly Organization exists; + - fetch additional data for such organization; + """ + cleaned_data = super().clean() + + uuid = cleaned_data.get("uuid") + api_key = cleaned_data.get("api_key") + + if str(uuid) in CredlyOrganization.get_preconfigured_organizations().keys(): + if api_key: + raise forms.ValidationError(_("You can't provide an API key for a configured organization.")) + + api_key = settings.BADGES_CONFIG["credly"]["ORGANIZATIONS"][str(uuid)] + + credly_api_client = CredlyAPIClient(uuid, api_key) + self.ensure_organization_exists(credly_api_client) + + return cleaned_data + + def save(self, commit=True): + """ + Auto-fill addition properties. + """ + instance = super().save(commit=False) + instance.name = self.api_data.get("name") + instance.save() + + return instance + + def ensure_organization_exists(self, api_client): + """ + Try to fetch organization data by the configured Credly Organization ID. + """ + try: + response_json = api_client.fetch_organization() + if org_data := response_json.get("data"): + self.api_data = org_data + except CredlyAPIError as err: + raise forms.ValidationError(message=str(err)) + + +class BadgePenaltyForm(forms.ModelForm): + """ + Form for BadgePenalty model. + """ + + class Meta: + model = BadgePenalty + fields = "__all__" + + def clean(self): + """ + Ensure that all penalties belong to the same template. + """ + cleaned_data = super().clean() + requirements = cleaned_data.get("requirements") + + if requirements and not all( + requirement.template.id == cleaned_data.get("template").id for requirement in requirements + ): + raise forms.ValidationError(_("All requirements must belong to the same template.")) + return cleaned_data + + +class ParentMixin: + def get_form_kwargs(self, index): + """ + Pass parent instance to the form. + """ + + kwargs = super().get_form_kwargs(index) + kwargs["parent_instance"] = self.instance + return kwargs + + +class DataRuleExtensionsMixin: + """ + Mixin for DataRule form to extend logic. + """ + + def __init__(self, *args, parent_instance=None, **kwargs): + """ + Load data paths based on the parent instance event type. + """ + self.parent_instance = parent_instance + super().__init__(*args, **kwargs) + + if self.parent_instance: + event_type = self.parent_instance.event_type + self.fields["data_path"].choices = Choices(*get_event_type_keypaths(event_type=event_type)) + + def clean(self): + """ + Validate boolean fields. + """ + + cleaned_data = super().clean() + + data_path_type = get_event_type_attr_type_by_keypath( + self.parent_instance.event_type, cleaned_data.get("data_path") + ) + + if data_path_type == bool and cleaned_data.get("value") not in AbstractDataRule.BOOL_VALUES: + raise forms.ValidationError(_("Value must be a boolean.")) + + return cleaned_data + + +class DataRuleFormSet(ParentMixin, forms.BaseInlineFormSet): + pass + + +class DataRuleForm(DataRuleExtensionsMixin, forms.ModelForm): + """ + Form for DataRule model. + """ + + class Meta: + model = DataRule + fields = "__all__" + + data_path = forms.ChoiceField() + + +class BadgeRequirementFormSet(ParentMixin, forms.BaseInlineFormSet): + pass + + +class BadgeRequirementForm(forms.ModelForm): + class Meta: + model = BadgeRequirement + fields = "__all__" + + blend = forms.ChoiceField() + + def __init__(self, *args, parent_instance=None, **kwargs): + self.template = parent_instance + super().__init__(*args, **kwargs) + + self.fields["blend"].choices = Choices(*[(chr(i), chr(i)) for i in range(65, 91)]) + self.fields["blend"].initial = chr(65 + self.template.requirements.count()) + + +class PenaltyDataRuleFormSet(ParentMixin, forms.BaseInlineFormSet): + pass + + +class PenaltyDataRuleForm(DataRuleExtensionsMixin, forms.ModelForm): + """ + Form for PenaltyDataRule model. + """ + + data_path = forms.ChoiceField() + + class Meta: + model = PenaltyDataRule + fields = "__all__" diff --git a/credentials/apps/badges/api.py b/credentials/apps/badges/api.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/apps.py b/credentials/apps/badges/apps.py new file mode 100644 index 000000000..783fd7d1a --- /dev/null +++ b/credentials/apps/badges/apps.py @@ -0,0 +1,30 @@ +from django.apps import AppConfig + +from credentials.apps.badges.toggles import check_badges_enabled + + +class BadgesConfig(AppConfig): + """ + Core badges application configuration. + """ + + name = "credentials.apps.badges" + verbose_name = "Badges" + + @check_badges_enabled + def ready(self): + """ + Performs initial registrations for checks, signals, etc. + """ + + from credentials.apps.badges import signals # pylint: disable=unused-import,import-outside-toplevel + from credentials.apps.badges.checks import ( # pylint: disable=unused-import,import-outside-toplevel + badges_checks, + ) + from credentials.apps.badges.signals.handlers import ( # pylint: disable=import-outside-toplevel + listen_to_badging_events, + ) + + listen_to_badging_events() + + super().ready() diff --git a/credentials/apps/badges/checks.py b/credentials/apps/badges/checks.py new file mode 100644 index 000000000..1cc7bdfc2 --- /dev/null +++ b/credentials/apps/badges/checks.py @@ -0,0 +1,41 @@ +""" +Badges app self-checks. +""" + +from django.core.checks import Error, Tags, register + +from .utils import credly_check, get_badging_event_types + + +@register(Tags.compatibility) +def badges_checks(*args, **kwargs): + """ + Checks the consistency of the badges configurations. + + Raises compatibility Errors upon: + - BADGES_CONFIG['events'] is empty + - Credly settings are not properly configured + + Returns: + List of any Errors. + """ + errors = [] + + if not get_badging_event_types(): + errors.append( + Error( + "BADGES_CONFIG['events'] must include at least one event.", + hint="Add at least one event to BADGES_CONFIG['events'] setting.", + id="badges.E001", + ) + ) + if not credly_check(): + errors.append( + Error( + "Credly settings are not properly configured.", + hint="Make sure all required settings are present in BADGES_CONFIG['credly'].", + id="badges.E002", + ) + ) + + return errors diff --git a/credentials/apps/badges/credly/__init__.py b/credentials/apps/badges/credly/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/credly/api_client.py b/credentials/apps/badges/credly/api_client.py new file mode 100644 index 000000000..793f4c85e --- /dev/null +++ b/credentials/apps/badges/credly/api_client.py @@ -0,0 +1,184 @@ +import base64 +import logging +from functools import lru_cache +from urllib.parse import urljoin + +import requests +from attrs import asdict +from django.conf import settings +from django.contrib.sites.models import Site +from requests.exceptions import HTTPError + +from credentials.apps.badges.credly.exceptions import CredlyAPIError, CredlyError +from credentials.apps.badges.credly.utils import get_credly_api_base_url +from credentials.apps.badges.models import CredlyBadgeTemplate, CredlyOrganization + + +logger = logging.getLogger(__name__) + + +class CredlyAPIClient: + """ + A client for interacting with the Credly API. + + This class provides methods for performing various operations on the Credly API, + such as fetching organization details, fetching badge templates, issuing badges, + and revoking badges. + """ + + def __init__(self, organization_id, api_key=None): + """ + Initializes a CredlyRestAPI object. + + Args: + organization_id (str, uuid): ID of the organization. + api_key (str): optional ID of the organization. + """ + if api_key is None: + self.organization = self._get_organization(organization_id) + api_key = self.organization.api_key + + 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}/") + + def _get_organization(self, organization_id): + """ + Check if Credly Organization with provided ID exists. + """ + try: + organization = CredlyOrganization.objects.get(uuid=organization_id) + return organization + except CredlyOrganization.DoesNotExist: + raise CredlyError(f"CredlyOrganization with the uuid {organization_id} does not exist!") + + def perform_request(self, method, url_suffix, data=None): + """ + Perform an HTTP request to the specified URL suffix. + + Args: + method (str): HTTP method to use for the request. + url_suffix (str): URL suffix to append to the base Credly API URL. + data (dict, optional): Data to send with the request. + + Returns: + dict: JSON response from the API. + + Raises: + requests.HTTPError: If the API returns an error response. + """ + 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(), json=data, timeout=10) + self._raise_for_error(response) + return response.json() + + def _raise_for_error(self, response): + """ + Raises a CredlyAPIError if the response status code indicates an error. + + Args: + response (requests.Response): Response object from the Credly API request. + + Raises: + CredlyAPIError: If the response status code indicates an error. + """ + try: + response.raise_for_status() + except HTTPError: + 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): + """ + Returns the headers for making API requests to Credly. + """ + return { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Basic {self._build_authorization_token()}", + } + + @lru_cache + def _build_authorization_token(self): + """ + Build the authorization token for the Credly API. + + Returns: + str: Authorization token. + """ + return base64.b64encode(self.api_key.encode("ascii")).decode("ascii") + + def fetch_organization(self): + """ + Fetches Credly Organization data. + """ + return self.perform_request("get", "") + + def fetch_badge_templates(self): + """ + Fetches the badge templates from the Credly API. + """ + return self.perform_request("get", f"badge_templates/?filter=state::{CredlyBadgeTemplate.STATES.active}") + + def fetch_event_information(self, event_id): + """ + Fetches the event information from the Credly API. + + Args: + event_id (str): ID of the event. + """ + return self.perform_request("get", f"events/{event_id}/") + + def issue_badge(self, issue_badge_data): + """ + Issues a badge using the Credly REST API. + + Args: + issue_badge_data (IssueBadgeData): Data required to issue the badge. + """ + return self.perform_request("post", "badges/", asdict(issue_badge_data)) + + 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/", data=data) + + def sync_organization_badge_templates(self, site_id): + """ + Pull active badge templates for a given Credly Organization. + + Args: + site_id (int): ID of the site. + + Returns: + int | None: processed items. + """ + try: + site = Site.objects.get(id=site_id) + except Site.DoesNotExist: + logger.error(f"Site with the id {site_id} does not exist!") + raise + + badge_templates_data = self.fetch_badge_templates() + raw_badge_templates = badge_templates_data.get("data", []) + + for raw_badge_template in raw_badge_templates: + CredlyBadgeTemplate.objects.update_or_create( + uuid=raw_badge_template.get("id"), + organization=self.organization, + defaults={ + "site": site, + "name": raw_badge_template.get("name"), + "state": raw_badge_template.get("state"), + "description": raw_badge_template.get("description"), + "icon": raw_badge_template.get("image_url"), + }, + ) + + return len(raw_badge_templates) diff --git a/credentials/apps/badges/credly/data.py b/credentials/apps/badges/credly/data.py new file mode 100644 index 000000000..80d6ec3c6 --- /dev/null +++ b/credentials/apps/badges/credly/data.py @@ -0,0 +1,25 @@ +from datetime import datetime + +import attr + + +@attr.s(auto_attribs=True, frozen=True) +class CredlyBadgeData: + """ + Represents the data required to issue a badge. + + Attributes: + recipient_email (str): Email address of the badge recipient. + issued_to_first_name (str): First name of the badge recipient. + issued_to_last_name (str): Last name of the badge recipient. + badge_template_id (str): ID of the badge template. + issued_at (datetime): Timestamp when the badge was issued. + + Reference: https://credly.com/docs/issued_badges + """ + + recipient_email: str + issued_to_first_name: str + issued_to_last_name: str + badge_template_id: str + issued_at: datetime diff --git a/credentials/apps/badges/credly/exceptions.py b/credentials/apps/badges/credly/exceptions.py new file mode 100644 index 000000000..7fbb2a437 --- /dev/null +++ b/credentials/apps/badges/credly/exceptions.py @@ -0,0 +1,17 @@ +""" +Specific for Credly exceptions. +""" + +from credentials.apps.badges.exceptions import BadgesError + + +class CredlyError(BadgesError): + """ + Credly backend generic error. + """ + + +class CredlyAPIError(CredlyError): + """ + Credly API errors. + """ diff --git a/credentials/apps/badges/credly/utils.py b/credentials/apps/badges/credly/utils.py new file mode 100644 index 000000000..618136f41 --- /dev/null +++ b/credentials/apps/badges/credly/utils.py @@ -0,0 +1,47 @@ +""" +Credly specific utilities. +""" + + +def get_credly_api_base_url(settings): + """ + Determines the base URL for the Credly API based on application settings. + + Parameters: + - settings: A configuration object containing the application's settings, + including those specific to Credly API integration. + + Returns: + - str: The base URL for the Credly API. This will be the URL for the sandbox + environment if `USE_SANDBOX` is set to a truthy value in the configuration; + otherwise, it will be the production environment's URL. + """ + + credly_config = settings.BADGES_CONFIG["credly"] + + if credly_config.get("USE_SANDBOX"): + return credly_config["CREDLY_SANDBOX_API_BASE_URL"] + + return credly_config["CREDLY_API_BASE_URL"] + + +def get_credly_base_url(settings): + """ + Determines the base URL for the Credly service based on application settings. + + Parameters: + - settings: A configuration object containing the application's settings. + + Returns: + - str: The base URL for the Credly service (web site). + This will be the URL for the sandbox environment if `USE_SANDBOX` is + set to a truthy value in the configuration; + otherwise, it will be the production environment's URL. + """ + + credly_config = settings.BADGES_CONFIG["credly"] + + if credly_config.get("USE_SANDBOX"): + return credly_config["CREDLY_SANDBOX_BASE_URL"] + + return credly_config["CREDLY_BASE_URL"] diff --git a/credentials/apps/badges/credly/webhooks.py b/credentials/apps/badges/credly/webhooks.py new file mode 100644 index 000000000..b103ffe68 --- /dev/null +++ b/credentials/apps/badges/credly/webhooks.py @@ -0,0 +1,127 @@ +import logging + +from django.contrib.sites.shortcuts import get_current_site +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..models import CredlyBadgeTemplate, CredlyOrganization +from .api_client import CredlyAPIClient + + +logger = logging.getLogger(__name__) + + +class CredlyWebhook(APIView): + """ + Public API (webhook endpoint) to handle incoming Credly updates. + + Usage: + POST /credly-badges/api/webhook/ + """ + + authentication_classes = [] + permission_classes = [] + + def post(self, request): + """ + Handle incoming update events from the Credly service. + + https://sandbox.credly.com/docs/webhooks#requirements + + Handled events: + - badge_template.created + - badge_template.changed + - badge_template.deleted + + - tries to recognize Credly Organization context; + - validates event type and its payload; + - performs corresponding item (badge template) updates; + + Returned statuses: + - 204 + - 404 + """ + credly_api_client = CredlyAPIClient(request.data.get("organization_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": + self.handle_badge_template_created_event(request, event_info_response) + elif event_type == "badge_template.changed": + self.handle_badge_template_changed_event(request, event_info_response) + elif event_type == "badge_template.deleted": + self.handle_badge_template_deleted_event(request, event_info_response) + else: + logger.error(f"Unknown event type: {event_type}") + + return Response(status=status.HTTP_204_NO_CONTENT) + + @staticmethod + def _get_badge_template_from_data(data): + badge_template = data.get("data", {}).get("badge_template", {}) + return badge_template + + @staticmethod + def handle_badge_template_created_event(request, data): + """ + Create a new badge template. + """ + + badge_template = CredlyWebhook._get_badge_template_from_data(data) + owner = badge_template.get("owner", {}) + + organization = get_object_or_404(CredlyOrganization, uuid=owner.get("id")) + + CredlyBadgeTemplate.objects.update_or_create( + uuid=badge_template.get("id"), + organization=organization, + defaults={ + "site": get_current_site(request), + "name": badge_template.get("name"), + "state": badge_template.get("state"), + "description": badge_template.get("description"), + "icon": badge_template.get("image_url"), + }, + ) + + @staticmethod + def handle_badge_template_changed_event(request, data): + """ + Change the badge template. + """ + + badge_template = CredlyWebhook._get_badge_template_from_data(data) + owner = badge_template.get("owner", {}) + + organization = get_object_or_404(CredlyOrganization, uuid=owner.get("id")) + + CredlyBadgeTemplate.objects.update_or_create( + uuid=badge_template.get("id"), + organization=organization, + defaults={ + "site": get_current_site(request), + "name": badge_template.get("name"), + "state": badge_template.get("state"), + "description": badge_template.get("description"), + "icon": badge_template.get("image_url"), + }, + ) + + if badge_template.get("state") != CredlyBadgeTemplate.STATES.active: + CredlyBadgeTemplate.objects.filter( + uuid=badge_template.get("id"), + organization=organization, + ).update(is_active=False) + + @staticmethod + def handle_badge_template_deleted_event(request, data): + """ + Deletes the badge template by provided uuid. + """ + CredlyBadgeTemplate.objects.filter( + uuid=CredlyWebhook._get_badge_template_from_data(data).get("id"), + site=get_current_site(request), + ).delete() diff --git a/credentials/apps/badges/exceptions.py b/credentials/apps/badges/exceptions.py new file mode 100644 index 000000000..e2719006f --- /dev/null +++ b/credentials/apps/badges/exceptions.py @@ -0,0 +1,15 @@ +""" +Badges exceptions. +""" + + +class BadgesError(Exception): + """ + Badges generic exception. + """ + + +class BadgesProcessingError(BadgesError): + """ + Exception raised for errors that occur during badge processing. + """ diff --git a/credentials/apps/badges/issuers.py b/credentials/apps/badges/issuers.py new file mode 100644 index 000000000..faaa63331 --- /dev/null +++ b/credentials/apps/badges/issuers.py @@ -0,0 +1,198 @@ +""" +This module provides classes for issuing badge credentials to users. +""" + +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 CredlyBadgeData +from credentials.apps.badges.credly.exceptions import CredlyAPIError +from credentials.apps.badges.models import BadgeTemplate, CredlyBadge, CredlyBadgeTemplate, UserCredential +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 +from credentials.apps.credentials.issuers import AbstractCredentialIssuer + + +class BadgeTemplateIssuer(AbstractCredentialIssuer): + """ + Issues BadgeTemplate credentials to users. + """ + + issued_credential_type = BadgeTemplate + issued_user_credential_type = UserCredential + + def get_credential(self, credential_id): + """ + Get credential by id. + """ + + return self.issued_credential_type.objects.get(id=credential_id) + + @transaction.atomic + def issue_credential( + self, + credential, + username, + status=UserCredentialStatus.AWARDED, + attributes=None, + date_override=None, + request=None, + lms_user_id=None, + ): + """ + Issue a credential to the user. + + This action is idempotent. If the user has already earned the credential, a new one WILL NOT be issued. The + existing credential WILL be modified. + + Arguments: + credential (AbstractCredential): Type of credential to issue. + username (str): username of user for which credential required + status (str): status of credential + attributes (List[dict]): optional list of attributes that should be associated with the issued credential. + request (HttpRequest): request object to build program record absolute uris + + Returns: + UserCredential + """ + + user_credential, __ = self.issued_user_credential_type.objects.update_or_create( + username=username, + credential_content_type=ContentType.objects.get_for_model(credential), + credential_id=credential.id, + defaults={ + "status": status, + }, + ) + + self.set_credential_attributes(user_credential, attributes) + self.set_credential_date_override(user_credential, date_override) + + return user_credential + + def award(self, *, username, credential_id): + """ + Awards a badge. + + Creates user credential record for the given badge template, for a given user. + Notifies about the awarded badge (public signal). + + Returns: UserCredential + """ + + credential = self.get_credential(credential_id) + user_credential = self.issue_credential(credential, username) + + notify_badge_awarded(user_credential) + return user_credential + + def revoke(self, credential_id, username): + """ + Revokes a badge. + + Changes user credential status to REVOKED, for a given user. + Notifies about the revoked badge (public signal). + + Returns: UserCredential + """ + + credential = self.get_credential(credential_id) + user_credential = self.issue_credential(credential, username, status=UserCredentialStatus.REVOKED) + + notify_badge_revoked(user_credential) + return user_credential + + +class CredlyBadgeTemplateIssuer(BadgeTemplateIssuer): + """ + Issues CredlyBadgeTemplate credentials to users. + """ + + issued_credential_type = CredlyBadgeTemplate + issued_user_credential_type = CredlyBadge + + def issue_credly_badge(self, *, user_credential): + """ + Requests Credly service for external badge issuing based on internal user credential (CredlyBadge). + """ + + user = get_user_by_username(user_credential.username) + badge_template = user_credential.credential + + credly_badge_data = CredlyBadgeData( + 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(badge_template.uuid), + issued_at=badge_template.created.strftime("%Y-%m-%d %H:%M:%S %z"), + ) + + try: + credly_api = CredlyAPIClient(badge_template.organization.uuid) + response = credly_api.issue_badge(credly_badge_data) + except CredlyAPIError: + user_credential.state = "error" + user_credential.save() + raise + + user_credential.external_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): + """ + Requests Credly service for external badge revoking based on internal user credential (CredlyBadge). + """ + + 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.external_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, *, username, credential_id): + """ + Awards a Credly badge. + + - Creates user credential record for the given badge template, for a given user; + - Notifies about the awarded badge (public signal); + - Issues external Credly badge (Credly API); + + Returns: (CredlyBadge) user credential + """ + + credly_badge = super().award(username=username, credential_id=credential_id) + + # do not issue new badges if the badge was issued already + if not credly_badge.propagated: + self.issue_credly_badge(user_credential=credly_badge) + + return credly_badge + + def revoke(self, credential_id, username): + """ + Revokes a Credly badge. + + - Changes user credential status to REVOKED, for a given user; + - Notifies about the revoked badge (public signal); + - Revokes external Credly badge (Credly API); + + Returns: (CredlyBadge) user credential + """ + + user_credential = super().revoke(credential_id, username) + if user_credential.propagated: + self.revoke_credly_badge(credential_id, user_credential) + return user_credential diff --git a/credentials/apps/badges/management/__init__.py b/credentials/apps/badges/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/management/commands/__init__.py b/credentials/apps/badges/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/management/commands/sync_organization_badge_templates.py b/credentials/apps/badges/management/commands/sync_organization_badge_templates.py new file mode 100644 index 000000000..b55e968ab --- /dev/null +++ b/credentials/apps/badges/management/commands/sync_organization_badge_templates.py @@ -0,0 +1,56 @@ +import logging + +from django.core.management.base import BaseCommand + +from credentials.apps.badges.credly.api_client import CredlyAPIClient +from credentials.apps.badges.models import CredlyOrganization + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Sync badge templates for a specific organization or all organizations" + + 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.") + + def handle(self, *args, **options): + """ + Sync badge templates for a specific organization or all organizations. + + Usage: + site_id=1 + org_id=c117c179-81b1-4f7e-a3a1-e6ae30568c13 + + ./manage.py sync_organization_badge_templates --site_id $site_id + ./manage.py sync_organization_badge_templates --site_id $site_id --organization_id $org_id + """ + DEFAULT_SITE_ID = 1 + organizations_to_sync = [] + + site_id = options.get("site_id") + 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}") + 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}") + else: + organizations_to_sync = CredlyOrganization.get_all_organization_ids() + logger.info( + "Organization ID wasn't provided: syncing badge templates for all organizations - " + f"{organizations_to_sync}", + ) + + for organization_id in organizations_to_sync: + credly_api = CredlyAPIClient(organization_id) + processed_items = credly_api.sync_organization_badge_templates(site_id) + + logger.info(f"Organization {organization_id}: got {processed_items} badge templates.") + + logger.info("...completed!") diff --git a/credentials/apps/badges/migrations/0001_initial.py b/credentials/apps/badges/migrations/0001_initial.py new file mode 100644 index 000000000..8fd88cb41 --- /dev/null +++ b/credentials/apps/badges/migrations/0001_initial.py @@ -0,0 +1,371 @@ +# Generated by Django 4.2.13 on 2024-06-11 17:18 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("credentials", "0029_alter_usercredential_credential_content_type"), + ("sites", "0002_alter_domain_unique"), + ] + + operations = [ + migrations.CreateModel( + name="BadgeProgress", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("username", models.CharField(max_length=255)), + ], + options={ + "verbose_name_plural": "badge progress records", + }, + ), + migrations.CreateModel( + name="BadgeRequirement", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "event_type", + 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", + ), + ], + help_text='Public signal type. Available events are configured in "BADGES_CONFIG" setting. The crucial aspect for event to carry UserData in its payload.', + max_length=255, + ), + ), + ("description", models.TextField(blank=True, help_text="Provide more details if needed.", null=True)), + ( + "blend", + models.CharField( + blank=True, + help_text="Optional. Group requirements together using the same Group ID for interchangeable (OR processing logic).", + max_length=255, + null=True, + verbose_name="group", + ), + ), + ], + ), + migrations.CreateModel( + name="BadgeTemplate", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name="created"), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name="modified"), + ), + ("is_active", models.BooleanField(default=False)), + ("uuid", models.UUIDField(default=uuid.uuid4, help_text="Unique badge template ID.", unique=True)), + ("name", models.CharField(help_text="Badge template name.", max_length=255)), + ("description", models.TextField(blank=True, help_text="Badge template description.", null=True)), + ("icon", models.ImageField(blank=True, null=True, upload_to="badge_templates/icons")), + ("origin", models.CharField(blank=True, help_text="Badge template type.", max_length=128, null=True)), + ( + "state", + model_utils.fields.StatusField( + choices=[("draft", "draft"), ("active", "active"), ("archived", "archived")], + default="draft", + help_text="Credly badge template state (auto-managed).", + max_length=100, + no_check_for_status=True, + ), + ), + ("site", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="sites.site")), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="CredlyBadge", + fields=[ + ( + "usercredential_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="credentials.usercredential", + ), + ), + ( + "state", + model_utils.fields.StatusField( + choices=[ + ("created", "created"), + ("no_response", "no_response"), + ("error", "error"), + ("pending", "pending"), + ("accepted", "accepted"), + ("rejected", "rejected"), + ("revoked", "revoked"), + ("expired", "expired"), + ], + default="created", + help_text="Credly badge issuing state", + max_length=100, + no_check_for_status=True, + ), + ), + ( + "external_uuid", + models.UUIDField(blank=True, help_text="Credly service badge identifier", null=True, unique=True), + ), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + bases=("credentials.usercredential",), + ), + migrations.CreateModel( + name="CredlyOrganization", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name="created"), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name="modified"), + ), + ("uuid", models.UUIDField(help_text="Put your Credly Organization ID here.", unique=True)), + ( + "api_key", + models.CharField( + blank=True, help_text="Credly API shared secret for Credly Organization.", max_length=255 + ), + ), + ( + "name", + models.CharField( + blank=True, help_text="Verbose name for Credly Organization.", max_length=255, null=True + ), + ), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + migrations.CreateModel( + name="Fulfillment", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "blend", + models.CharField( + blank=True, + help_text="Group ID for the requirement.", + max_length=255, + null=True, + verbose_name="group", + ), + ), + ("progress", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="badges.badgeprogress")), + ( + "requirement", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="fulfillments", + to="badges.badgerequirement", + ), + ), + ], + ), + migrations.AddField( + model_name="badgerequirement", + name="template", + field=models.ForeignKey( + help_text="Badge template this requirement serves for.", + on_delete=django.db.models.deletion.CASCADE, + related_name="requirements", + to="badges.badgetemplate", + ), + ), + migrations.AddField( + model_name="badgeprogress", + name="template", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="badges.badgetemplate" + ), + ), + migrations.CreateModel( + name="BadgePenalty", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "event_type", + 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", + ), + ], + help_text='Public signal type. Use namespaced types, e.g: "org.openedx.learning.student.registration.completed.v1"', + max_length=255, + ), + ), + ( + "requirements", + models.ManyToManyField( + help_text="Badge requirements for which this penalty is defined.", to="badges.badgerequirement" + ), + ), + ( + "template", + models.ForeignKey( + help_text="Badge template this penalty serves for.", + on_delete=django.db.models.deletion.CASCADE, + to="badges.badgetemplate", + ), + ), + ], + options={ + "verbose_name_plural": "Badge penalties", + }, + ), + migrations.CreateModel( + name="PenaltyDataRule", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "data_path", + models.CharField( + help_text='Public signal\'s data payload nested property path, e.g: "user.pii.username".', + max_length=255, + verbose_name="key path", + ), + ), + ( + "operator", + models.CharField( + choices=[("eq", "="), ("ne", "!=")], + default="eq", + help_text="Expected value comparison operator. https://docs.python.org/3/library/operator.html", + max_length=32, + ), + ), + ( + "value", + models.CharField( + help_text='Expected value for the nested property, e.g: "cucumber1997".', + max_length=255, + verbose_name="expected value", + ), + ), + ( + "penalty", + models.ForeignKey( + help_text="Parent penalty for this data rule.", + on_delete=django.db.models.deletion.CASCADE, + related_name="rules", + to="badges.badgepenalty", + ), + ), + ], + options={ + "unique_together": {("penalty", "data_path", "operator", "value")}, + }, + ), + migrations.CreateModel( + name="DataRule", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "data_path", + models.CharField( + help_text='Public signal\'s data payload nested property path, e.g: "user.pii.username".', + max_length=255, + verbose_name="key path", + ), + ), + ( + "operator", + models.CharField( + choices=[("eq", "="), ("ne", "!=")], + default="eq", + help_text="Expected value comparison operator. https://docs.python.org/3/library/operator.html", + max_length=32, + ), + ), + ( + "value", + models.CharField( + help_text='Expected value for the nested property, e.g: "cucumber1997".', + max_length=255, + verbose_name="expected value", + ), + ), + ( + "requirement", + models.ForeignKey( + help_text="Parent requirement for this data rule.", + on_delete=django.db.models.deletion.CASCADE, + related_name="rules", + to="badges.badgerequirement", + ), + ), + ], + options={ + "unique_together": {("requirement", "data_path", "operator", "value")}, + }, + ), + migrations.CreateModel( + name="CredlyBadgeTemplate", + fields=[ + ( + "badgetemplate_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="badges.badgetemplate", + ), + ), + ( + "organization", + models.ForeignKey( + help_text="Credly Organization - template owner.", + on_delete=django.db.models.deletion.CASCADE, + to="badges.credlyorganization", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("badges.badgetemplate",), + ), + ] diff --git a/credentials/apps/badges/migrations/__init__.py b/credentials/apps/badges/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py new file mode 100644 index 000000000..84ad3a3e9 --- /dev/null +++ b/credentials/apps/badges/models.py @@ -0,0 +1,677 @@ +""" +Badges DB models. +""" + +import logging +import operator +import uuid +from urllib.parse import urljoin + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_extensions.db.models import TimeStampedModel +from model_utils import Choices +from model_utils.fields import StatusField +from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData + +from credentials.apps.badges.credly.utils import get_credly_base_url +from credentials.apps.badges.signals.signals import ( + notify_progress_complete, + notify_progress_incomplete, + notify_requirement_fulfilled, + notify_requirement_regressed, +) +from credentials.apps.badges.utils import keypath +from credentials.apps.core.api import get_user_by_username +from credentials.apps.credentials.models import AbstractCredential, UserCredential + + +logger = logging.getLogger(__name__) + + +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."), + blank=True, + ) + name = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=_("Verbose name for Credly Organization."), + ) + + def __str__(self): + return f"{self.name or self.uuid}" + + @classmethod + def get_all_organization_ids(cls): + """ + Get all organization IDs. + """ + return list(cls.objects.values_list("uuid", flat=True)) + + @classmethod + def get_preconfigured_organizations(cls): + """ + Get preconfigured organizations. + """ + return settings.BADGES_CONFIG["credly"].get("ORGANIZATIONS", {}) + + @property + def is_preconfigured(self): + """ + Checks if the organization is preconfigured. + """ + + return str(self.uuid) in CredlyOrganization.get_preconfigured_organizations().keys() + + +class BadgeTemplate(AbstractCredential): + """ + Describes badge template credential type. + + NOTE: currently hidden in the admin as a base class (see more details on the CredlyBadgeTemplate). + """ + + ORIGIN = "openedx" + + STATES = Choices("draft", "active", "archived") + + 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.")) + 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.")) + state = StatusField( + choices_name="STATES", + help_text=_("Credly badge template state (auto-managed)."), + ) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + super().save() + # auto-evaluate type: + if not self.origin: + self.origin = self.ORIGIN + self.save(*args, **kwargs) + + @property + def groups(self): + """ + Returns unique groups for the badge template. + """ + + return self.requirements.values_list("blend", flat=True).distinct() + + @classmethod + def by_uuid(cls, template_uuid): + """ + Returns badge template by UUID. + """ + + return cls.objects.filter(uuid=template_uuid, origin=cls.ORIGIN).first() + + def user_progress(self, username: str) -> float: + """ + Determines a completion progress for user. + """ + progress = BadgeProgress.for_user(username=username, template_id=self.id) + return progress.ratio + + def is_completed(self, username: str) -> bool: + """ + Checks if user has completed this badge template. + """ + return self.user_progress(username) == 1.00 + + +class CredlyBadgeTemplate(BadgeTemplate): + """ + Credly badge template credential. + + Credly badge templates should not be created manually, instead they are pulled from the Credly Organization (API). + Before being processed badge template must be activated. + Before activation badge template must be configured (requirements and optional penalties). + """ + + ORIGIN = "credly" + + organization = models.ForeignKey( + CredlyOrganization, + on_delete=models.CASCADE, + help_text=_("Credly Organization - template owner."), + ) + + @property + def management_url(self): + """ + Build external Credly dashboard URL. + """ + credly_host_base_url = get_credly_base_url(settings) + return urljoin( + credly_host_base_url, f"mgmt/organizations/{self.organization.uuid}/badges/templates/{self.uuid}/details" + ) + + +class BadgeRequirement(models.Model): + """ + Describes what must happen for badge template to progress. + + - what unique event is expected to happen; + - what exact conditions the expected event must carry in its payload; + + NOTE: all attached to a badge template requirements must be fulfilled by default; + to achieve "OR" processing logic for 2 attached requirements just group them (put identical group ID). + """ + + EVENT_TYPES = Choices(*settings.BADGES_CONFIG["events"]) + + template = models.ForeignKey( + BadgeTemplate, + on_delete=models.CASCADE, + related_name="requirements", + help_text=_("Badge template this requirement serves for."), + ) + event_type = models.CharField( + max_length=255, + choices=EVENT_TYPES, + help_text=_( + 'Public signal type. Available events are configured in "BADGES_CONFIG" setting. ' + "The crucial aspect for event to carry UserData in its payload." + ), + ) + description = models.TextField(null=True, blank=True, help_text=_("Provide more details if needed.")) + blend = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=_( + "Optional. Group requirements together using the same Group ID for interchangeable (OR processing logic)." + ), + verbose_name=_("group"), + ) + + def __str__(self): + return f"BadgeRequirement:{self.id}:{self.template.uuid}" + + def fulfill(self, username: str): + """ + Marks itself as "done" for the user. + + Side effects: + - notifies about a progression if any; + + Returns: (bool) if progression happened + """ + template_id = self.template.id + progress = BadgeProgress.for_user(username=username, template_id=template_id) + fulfillment, created = Fulfillment.objects.get_or_create(progress=progress, requirement=self, blend=self.blend) + + if created: + notify_requirement_fulfilled( + sender=self, + username=username, + badge_template_id=template_id, + fulfillment_id=fulfillment.id, + ) + return created + + def reset(self, username: str): + """ + Marks itself as "undone" for the user. + + - removes user progress for the requirement if any; + - notifies about the regression if any; + + Returns: (bool) if any progress existed. + """ + template_id = self.template.id + fulfillment = Fulfillment.objects.filter( + requirement=self, + progress__username=username, + ).first() + deleted = self._delete_fulfillment_if_exists(fulfillment) + if deleted: + notify_requirement_regressed( + sender=self, + username=username, + badge_template_id=template_id, + ) + return bool(deleted) + + def is_fulfilled(self, username: str) -> bool: + """ + Checks if the requirement is fulfilled for the user. + """ + + return self.fulfillments.filter(progress__username=username, progress__template=self.template).exists() + + def _delete_fulfillment_if_exists(self, fulfillment): + """ + Deletes the fulfillment if it exists. + """ + + if not fulfillment: + return False + + fulfillment.delete() + return True + + @classmethod + def is_group_fulfilled(cls, *, group: str, template: BadgeTemplate, username: str) -> bool: + """ + Checks if the group is fulfilled. + """ + + progress = BadgeProgress.for_user(username=username, template_id=template.id) + requirements = cls.objects.filter(template=template, blend=group) + fulfilled_requirements = requirements.filter(fulfillments__progress=progress).count() + + return fulfilled_requirements > 0 + + def apply_rules(self, data: dict) -> bool: + """ + Evaluates payload rules. + """ + + return all(rule.apply(data) for rule in self.rules.all()) if self.rules.exists() else False + + @property + def is_active(self): + """ + Checks if the requirement is active. + """ + + return self.template.is_active + + +class AbstractDataRule(models.Model): + """ + Abstract DataRule configuration model. + + .. no_req_or_pen: This model has no requirement or penalty. + """ + + OPERATORS = Choices( + ("eq", "="), + ("ne", "!="), + ) + + TRUE_VALUES = ["True", "true", "Yes", "yes", "+"] + FALSE_VALUES = ["False", "false", "No", "no", "-"] + BOOL_VALUES = TRUE_VALUES + FALSE_VALUES + + data_path = models.CharField( + max_length=255, + 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"), + ) + value = models.CharField( + max_length=255, + help_text=_('Expected value for the nested property, e.g: "cucumber1997".'), + verbose_name=_("expected value"), + ) + + class Meta: + abstract = True + + def apply(self, data: dict) -> bool: + """ + Evaluates itself on the input data (event payload). + + This method retrieves a value specified by a data path within a given dictionary, + converts that value to a string, and then applies a comparison operation against + a predefined value. The comparison operation is determined by the `self.operator` + attribute, which should match the name of an operator function in the `operator` + module. + + Parameters: + - data (dict): A dictionary containing data against which the comparison operation + will be applied. The specific value to be compared is determined by + the `self.data_path` attribute, which specifies the path to the value + within the dictionary. + + Returns: + - bool: True if the rule "worked". + + Example: + Assuming `self.operator` is set to "eq", `self.data_path` is set to "user.age", + and `self.value` is "30", then calling `apply({"user": {"age": 30}})` will return True + because the age matches the specified value. + """ + + comparison_func = getattr(operator, self.operator, None) + + if comparison_func: + data_value = str(keypath(data, self.data_path)) + return comparison_func(data_value, self._value_to_bool()) + return False + + def _value_to_bool(self): + """ + Converts the value to a boolean or returns the original value if it is not a boolean string. + """ + + if self.value in self.TRUE_VALUES: + return "True" + if self.value in self.FALSE_VALUES: + return "False" + return self.value + + +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, + help_text=_("Parent requirement for this data rule."), + related_name="rules", + ) + + class Meta: + unique_together = ("requirement", "data_path", "operator", "value") + + def __str__(self): + return f"{self.requirement.template.uuid}:{self.data_path}:{self.operator}:{self.value}" + + @property + def is_active(self): + """ + Checks if the rule is active. + """ + + return self.requirement.template.is_active + + +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."), + ) + + class Meta: + verbose_name_plural = _("Badge penalties") + + def __str__(self): + return f"BadgePenalty:{self.id}:{self.template.uuid}" + + def apply_rules(self, data: dict) -> bool: + """ + Evaluates payload rules. + """ + + return all(rule.apply(data) for rule in self.rules.all()) if self.rules.exists() else False + + def reset_requirements(self, username: str): + """ + Resets all related requirements for the user. + """ + + for requirement in self.requirements.all(): + requirement.reset(username) + + @property + def is_active(self): + """ + Checks if the penalty is active. + """ + + return self.template.is_active + + +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."), + related_name="rules", + ) + + class Meta: + unique_together = ("penalty", "data_path", "operator", "value") + + def __str__(self): + return f"{self.penalty.template.uuid}:{self.data_path}:{self.operator}:{self.value}" + + @property + def is_active(self): + """ + Checks if the rule is active. + """ + + return self.penalty.template.is_active + + +class BadgeProgress(models.Model): + """ + Tracks a single badge template progress for user. + + - allows multiple requirements status tracking; + - user-centric; + """ + + username = models.CharField(max_length=255) + template = models.ForeignKey( + BadgeTemplate, + models.SET_NULL, + blank=True, + null=True, + ) + + class Meta: + verbose_name_plural = _("badge progress records") + + def __str__(self): + return f"BadgeProgress:{self.username}" + + @classmethod + def for_user(cls, *, username, template_id): + """ + Service shortcut. + """ + + progress, __ = cls.objects.get_or_create(username=username, template_id=template_id) + return progress + + @property + def ratio(self) -> float: + """ + Calculates badge template progress ratio. + """ + + if not self.groups: + return 0.00 + + true_values_count = self._get_groups_true_values_count() + return round(true_values_count / len(self.groups.keys()), 2) + + @property + def groups(self): + """ + Returns gorups and their statuses (fulfilled or not). + """ + + return { + group: BadgeRequirement.is_group_fulfilled(group=group, template=self.template, username=self.username) + for group in self.template.groups + } + + @property + def completed(self): + """ + Checks if the badge template is completed. + """ + + return self.ratio == 1.00 + + def progress(self): + """ + Notify about the progress. + """ + + notify_progress_complete(self, self.username, self.template.id) + + def regress(self): + """ + Notify about the regression. + """ + + notify_progress_incomplete(self, self.username, self.template.id) + + def reset(self): + """ + Resets the progress. + """ + + Fulfillment.objects.filter(progress=self).delete() + + def _get_groups_true_values_count(self): + """ + Returns the count of groups with fulfilled requirements. + """ + + result = 0 + for fulfilled in self.groups.values(): + if fulfilled: + result += 1 + return result + + +class Fulfillment(models.Model): + """ + Tracks completed badge template requirement for user. + """ + + progress = models.ForeignKey(BadgeProgress, on_delete=models.CASCADE) + requirement = models.ForeignKey( + BadgeRequirement, + models.SET_NULL, + blank=True, + null=True, + related_name="fulfillments", + ) + blend = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=_("Group ID for the requirement."), + verbose_name=_("group"), + ) + + +class CredlyBadge(UserCredential): + """ + Earned Credly badge (Badge template credential) for user. + + - tracks distributed (external Credly service) state for Credly badge. + """ + + STATES = Choices( + "created", + "no_response", + "error", + "pending", + "accepted", + "rejected", + "revoked", + "expired", + ) + ISSUING_STATES = { + STATES.pending, + STATES.accepted, + STATES.rejected, + } + + state = StatusField( + choices_name="STATES", + help_text=_("Credly badge issuing state"), + default=STATES.created, + ) + + external_uuid = models.UUIDField( + blank=True, + null=True, + unique=True, + help_text=_("Credly service badge identifier"), + ) + + def as_badge_data(self) -> BadgeData: + """ + Represents itself as a BadgeData instance. + """ + + user = get_user_by_username(self.username) + badge_template = 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(badge_template.uuid), + origin=badge_template.origin, + name=badge_template.name, + description=badge_template.description, + image_url=str(badge_template.icon), + ), + ) + + return badge_data + + @property + def propagated(self): + """ + Checks if this user credential already has issued (external) Credly badge. + """ + + return self.external_uuid and (self.state in self.ISSUING_STATES) diff --git a/credentials/apps/badges/processing/__init__.py b/credentials/apps/badges/processing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/processing/generic.py b/credentials/apps/badges/processing/generic.py new file mode 100644 index 000000000..105831d42 --- /dev/null +++ b/credentials/apps/badges/processing/generic.py @@ -0,0 +1,73 @@ +""" +Main processing logic. +""" + +import logging + +from credentials.apps.badges.exceptions import BadgesProcessingError +from credentials.apps.badges.processing.progression import process_requirements +from credentials.apps.badges.processing.regression import process_penalties +from credentials.apps.badges.utils import extract_payload, get_user_data +from credentials.apps.core.api import get_or_create_user_from_event_data + + +logger = logging.getLogger(__name__) + + +def process_event(sender, **kwargs): + """ + Badge templates configuration interpreter. + + Responsibilities: + - identifies a target User based on event's payload ("whose action"); + - runs badges progressive pipeline (requirements processing); + - runs badges regressive pipeline (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)) + + # penalties processing + process_penalties(event_type, username, extract_payload(kwargs)) + + except BadgesProcessingError as error: + logger.error(f"Badges processing error: {error}") + return None + return None + + +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: + event_type (str): The type of the event. + event_payload (dict): The payload of the event. + + Returns: + str: The username of the identified (and created if needed) user. + + Raises: + BadgesProcessingError: if user data was not found. + """ + + user_data = get_user_data(event_payload) + + if not user_data: + message = ( + f"User data cannot be found (got: {user_data}): {event_payload}. " + f"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/processing/progression.py b/credentials/apps/badges/processing/progression.py new file mode 100644 index 000000000..a967018fb --- /dev/null +++ b/credentials/apps/badges/processing/progression.py @@ -0,0 +1,50 @@ +""" +Badge progression processing. +""" + +import logging +from typing import List + +from attrs import asdict + +from credentials.apps.badges.models import BadgeRequirement + + +logger = logging.getLogger(__name__) + + +def discover_requirements(event_type: str) -> List[BadgeRequirement]: + """ + Picks all relevant requirements based on the event type. + """ + + return BadgeRequirement.objects.filter(event_type=event_type, template__is_active=True) + + +def process_requirements(event_type, username, payload): + """ + Finds all relevant requirements, tests them one by one, marks as completed if needed. + """ + + requirements = discover_requirements(event_type=event_type) + completed_templates = set() + + logger.debug("BADGES: found %s requirements to process.", len(requirements)) + + for requirement in requirements: + + # remember: the badge template is already "done" + if requirement.template.is_completed(username): + completed_templates.add(requirement.template_id) + + # drop early: if the badge template is already "done" + if requirement.template_id in completed_templates: + continue + + # drop early: if the requirement is already "done" + if requirement.is_fulfilled(username): + continue + + # process: payload rules + if requirement.apply_rules(asdict(payload)): + requirement.fulfill(username) diff --git a/credentials/apps/badges/processing/regression.py b/credentials/apps/badges/processing/regression.py new file mode 100644 index 000000000..e88e51842 --- /dev/null +++ b/credentials/apps/badges/processing/regression.py @@ -0,0 +1,37 @@ +""" +Badge regression processing. +""" + +import logging +from typing import List + +from attrs import asdict + +from credentials.apps.badges.models import BadgePenalty + + +logger = logging.getLogger(__name__) + + +def discover_penalties(event_type: str) -> List[BadgePenalty]: + """ + Picks all relevant penalties based on the event type. + """ + + return BadgePenalty.objects.filter(event_type=event_type, template__is_active=True) + + +def process_penalties(event_type, username, payload): + """ + Finds all relevant penalties, tests them one by one, marks related requirement as not completed if needed. + """ + + penalties = discover_penalties(event_type=event_type) + + logger.debug("BADGES: found %s penalties to process.", len(penalties)) + + for penalty in penalties: + + # process: payload rules + if penalty.apply_rules(asdict(payload)): + penalty.reset_requirements(username) diff --git a/credentials/apps/badges/signals/__init__.py b/credentials/apps/badges/signals/__init__.py new file mode 100644 index 000000000..4c11d0992 --- /dev/null +++ b/credentials/apps/badges/signals/__init__.py @@ -0,0 +1 @@ +from .signals import * diff --git a/credentials/apps/badges/signals/handlers.py b/credentials/apps/badges/signals/handlers.py new file mode 100644 index 000000000..2044e0849 --- /dev/null +++ b/credentials/apps/badges/signals/handlers.py @@ -0,0 +1,88 @@ +""" +These signal handlers are auto-subscribed to all expected badging signals (event types). + +See: +""" + +import logging + +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.processing.generic import process_event +from credentials.apps.badges.signals import ( + BADGE_PROGRESS_COMPLETE, + BADGE_PROGRESS_INCOMPLETE, + BADGE_REQUIREMENT_FULFILLED, + BADGE_REQUIREMENT_REGRESSED, +) +from credentials.apps.badges.utils import get_badging_event_types + + +logger = logging.getLogger(__name__) + + +def listen_to_badging_events(): + """ + Subscribes the main processing handler to badging events subset. + """ + + load_all_signals() + + for event_type in get_badging_event_types(): + signal = OpenEdxPublicSignal.get_signal_by_type(event_type) + signal.connect(handle_badging_event, dispatch_uid=event_type) + + +def handle_badging_event(sender, signal, **kwargs): # pylint: disable=unused-argument + """ + Generic handler for incoming from the Event bus public signals. + """ + + logger.debug(f"BADGES: incoming signal - {signal}") + + process_event(signal, **kwargs) + + +@receiver(BADGE_REQUIREMENT_FULFILLED) +def handle_requirement_fulfilled(sender, username, **kwargs): + """ + On user's Badge progression (completion). + """ + BadgeProgress.for_user(username=username, template_id=sender.template.id).progress() + + +@receiver(BADGE_REQUIREMENT_REGRESSED) +def handle_requirement_regressed(sender, username, **kwargs): + """ + On user's Badge regression (incompletion). + """ + BadgeProgress.for_user(username=username, template_id=sender.template.id).regress() + + +@receiver(BADGE_PROGRESS_COMPLETE) +def handle_badge_completion(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument + """ + Fires once ALL requirements for a badge template were marked as "done". + + - username + - badge template ID + """ + + logger.debug("BADGES: progress is complete for %s on the %s", username, badge_template_id) + + CredlyBadgeTemplateIssuer().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 + """ + On user's Badge regression (incompletion). + + - username + - badge template ID + """ + + CredlyBadgeTemplateIssuer().revoke(badge_template_id, username) diff --git a/credentials/apps/badges/signals/signals.py b/credentials/apps/badges/signals/signals.py new file mode 100644 index 000000000..db224ff54 --- /dev/null +++ b/credentials/apps/badges/signals/signals.py @@ -0,0 +1,93 @@ +""" +Badges internal signals. +""" + +import logging + +from django.dispatch import Signal +from openedx_events.learning.signals import BADGE_AWARDED, BADGE_REVOKED + + +logger = logging.getLogger(__name__) + + +# a single requirements for a badge template was finished +BADGE_REQUIREMENT_FULFILLED = Signal() + +# a single penalty worked on a badge template +BADGE_REQUIREMENT_REGRESSED = Signal() + +# all badge template requirements are finished +BADGE_PROGRESS_COMPLETE = Signal() + +# badge template penalty reset some of fulfilled requirements, so badge template became incomplete +BADGE_PROGRESS_INCOMPLETE = Signal() + + +def notify_requirement_fulfilled(*, sender, username, badge_template_id, **kwargs): + """ + Notifies about user's partial progression on the badge template. + """ + + BADGE_REQUIREMENT_FULFILLED.send( + sender=sender, + username=username, + badge_template_id=badge_template_id, + ) + + +def notify_requirement_regressed(*, sender, username, badge_template_id): + """ + Notifies about user's regression on the badge template. + """ + + BADGE_REQUIREMENT_REGRESSED.send( + sender=sender, + username=username, + badge_template_id=badge_template_id, + ) + + +def notify_progress_complete(sender, username, badge_template_id): + """ + Notifies about user's completion on the badge template. + """ + + BADGE_PROGRESS_COMPLETE.send( + sender=sender, + username=username, + badge_template_id=badge_template_id, + ) + + +def notify_progress_incomplete(sender, username, badge_template_id): + """ + Notifies about user's regression on the badge template. + """ + BADGE_PROGRESS_INCOMPLETE.send( + sender=sender, + username=username, + badge_template_id=badge_template_id, + ) + + +def notify_badge_awarded(user_credential): + """ + Emits a public signal about the badge template completion for user. + + - username + - badge template ID + """ + + BADGE_AWARDED.send_event(badge=user_credential.as_badge_data()) + + +def notify_badge_revoked(user_credential): + """ + Emit public event about badge template regression. + + - username + - badge template ID + """ + + BADGE_REVOKED.send_event(badge=user_credential.as_badge_data()) diff --git a/credentials/apps/badges/tests/__init__.py b/credentials/apps/badges/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/tests/test_admin_forms.py b/credentials/apps/badges/tests/test_admin_forms.py new file mode 100644 index 000000000..6a5914e93 --- /dev/null +++ b/credentials/apps/badges/tests/test_admin_forms.py @@ -0,0 +1,247 @@ +import uuid +from unittest.mock import MagicMock, patch + +from django import forms +from django.contrib.sites.models import Site +from django.test import TestCase, override_settings + +from credentials.apps.badges.admin_forms import ( + BadgePenaltyForm, + CredlyOrganizationAdminForm, + DataRuleExtensionsMixin, + ParentMixin, +) +from credentials.apps.badges.credly.exceptions import CredlyAPIError +from credentials.apps.badges.models import BadgeRequirement, BadgeTemplate + + +COURSE_PASSING_EVENT = "org.openedx.learning.course.passing.status.updated.v1" + + +class BadgePenaltyFormTestCase(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_template", state="draft", site=self.site + ) + self.badge_template2 = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site + ) + self.requirement1 = BadgeRequirement.objects.create( + template=self.badge_template1, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description 1", + ) + self.requirement2 = BadgeRequirement.objects.create( + template=self.badge_template2, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description 2", + ) + self.requirement3 = BadgeRequirement.objects.create( + template=self.badge_template2, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description 3", + ) + + def test_clean_requirements_same_template(self): + form = BadgePenaltyForm() + form.cleaned_data = { + "template": self.badge_template2, + "requirements": [ + self.requirement2, + self.requirement3, + ], + } + self.assertEqual( + form.clean(), + { + "template": self.badge_template2, + "requirements": [ + self.requirement2, + self.requirement3, + ], + }, + ) + + def test_clean_requirements_different_template(self): + form = BadgePenaltyForm() + form.cleaned_data = { + "template": self.badge_template1, + "requirements": [ + self.requirement2, + self.requirement1, + ], + } + + with self.assertRaises(forms.ValidationError) as cm: + form.clean() + + self.assertEqual(str(cm.exception), "['All requirements must belong to the same template.']") + + @override_settings(BADGES_CONFIG={"credly": {"ORGANIZATIONS": {}}}) + def test_clean(self): + form = CredlyOrganizationAdminForm() + form.cleaned_data = { + "uuid": "test_uuid", + "api_key": "test_api_key", + } + + with patch( + "credentials.apps.badges.models.CredlyOrganization.get_preconfigured_organizations" + ) as mock_get_orgs: + mock_get_orgs.return_value = {} + + with patch("credentials.apps.badges.admin_forms.CredlyAPIClient") as mock_client: + mock_client.return_value = MagicMock() + + form.clean() + + mock_get_orgs.assert_called_once() + mock_client.assert_called_once_with("test_uuid", "test_api_key") + + @override_settings(BADGES_CONFIG={"credly": {"ORGANIZATIONS": {"test_uuid": "test_api_key"}}}) + def test_clean_with_configured_organization(self): + form = CredlyOrganizationAdminForm() + form.cleaned_data = { + "uuid": "test_uuid", + "api_key": None, + } + + with patch( + "credentials.apps.badges.models.CredlyOrganization.get_preconfigured_organizations" + ) as mock_get_orgs: + mock_get_orgs.return_value = {"test_uuid": "test_org"} + + with patch("credentials.apps.badges.admin_forms.CredlyAPIClient") as mock_client: + mock_client.return_value = MagicMock() + + form.clean() + + mock_get_orgs.assert_called_once() + mock_client.assert_called_once_with("test_uuid", "test_api_key") + + def test_clean_with_invalid_organization(self): + form = CredlyOrganizationAdminForm() + form.cleaned_data = { + "uuid": "invalid_uuid", + "api_key": "test_api_key", + } + + with patch( + "credentials.apps.badges.models.CredlyOrganization.get_preconfigured_organizations" + ) as mock_get_orgs: + mock_get_orgs.return_value = {"test_uuid": "test_org"} + + with self.assertRaises(forms.ValidationError) as cm: + form.clean() + + self.assertIn("You specified an invalid authorization token.", str(cm.exception)) + + def test_clean_cannot_provide_api_key_for_configured_organization(self): + form = CredlyOrganizationAdminForm() + form.cleaned_data = { + "uuid": "test_uuid", + "api_key": "test_api_key", + } + + with patch( + "credentials.apps.badges.models.CredlyOrganization.get_preconfigured_organizations" + ) as mock_get_orgs: + mock_get_orgs.return_value = {"test_uuid": "test_org"} + + with self.assertRaises(forms.ValidationError) as cm: + form.clean() + + self.assertEqual( + str(cm.exception), + '["You can\'t provide an API key for a configured organization."]', + ) + + def test_ensure_organization_exists(self): + form = CredlyOrganizationAdminForm() + api_client = MagicMock() + api_client.fetch_organization.return_value = {"data": {"org_id": "test_org_id"}} + + form.ensure_organization_exists(api_client) + + api_client.fetch_organization.assert_called_once() + self.assertEqual(form.api_data, {"org_id": "test_org_id"}) + + def test_ensure_organization_exists_with_error(self): + form = CredlyOrganizationAdminForm() + api_client = MagicMock() + api_client.fetch_organization.side_effect = CredlyAPIError("API Error") + + with self.assertRaises(forms.ValidationError) as cm: + form.ensure_organization_exists(api_client) + + api_client.fetch_organization.assert_called_once() + self.assertEqual(str(cm.exception), "['API Error']") + + +class TestParentMixin(ParentMixin): + pass + + +class ParentMixinTestCase(TestCase): + def setUp(self): + self.instance = MagicMock() + self.instance.some_attribute = "some_value" + + self.mixin = TestParentMixin() + self.mixin.instance = self.instance + + def test_get_form_kwargs_passes_parent_instance(self): + with patch.object( + TestParentMixin, + "get_form_kwargs", + return_value={"parent_instance": self.instance}, + ) as super_method: + result = self.mixin.get_form_kwargs(0) + + super_method.assert_called_once_with(0) + + self.assertIn("parent_instance", result) + self.assertEqual(result["parent_instance"], self.instance) + + +class TestForm(DataRuleExtensionsMixin, forms.Form): + data_path = forms.ChoiceField(choices=[]) + value = forms.CharField() + + +class DataRuleExtensionsMixinTestCase(TestCase): + def setUp(self): + self.parent_instance = MagicMock() + self.parent_instance.event_type = COURSE_PASSING_EVENT + + def test_init_sets_choices_based_on_event_type(self): + form = TestForm(parent_instance=self.parent_instance) + self.assertEqual( + form.fields["data_path"].choices, + [("is_passing", "is_passing"), ("course.course_key", "course.course_key")], + ) + + def test_clean_with_valid_boolean_value(self): + form = TestForm( + data={"data_path": "is_passing", "value": "True"}, + parent_instance=self.parent_instance, + ) + form.is_valid() + self.assertRaises(KeyError, lambda: form.errors["__all__"]) + + def test_clean_with_invalid_boolean_value(self): + form = TestForm( + data={"data_path": "is_passing", "value": "invalid"}, + parent_instance=self.parent_instance, + ) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors["__all__"], ["Value must be a boolean."]) + + def test_clean_with_non_boolean_data_path(self): + form = TestForm( + data={"data_path": "course.course_key", "value": "some_value"}, + parent_instance=self.parent_instance, + ) + form.is_valid() + self.assertRaises(KeyError, lambda: form.errors["__all__"]) diff --git a/credentials/apps/badges/tests/test_api_client.py b/credentials/apps/badges/tests/test_api_client.py new file mode 100644 index 000000000..72b544b32 --- /dev/null +++ b/credentials/apps/badges/tests/test_api_client.py @@ -0,0 +1,128 @@ +from unittest import mock + +from attrs import asdict +from django.test import TestCase +from faker import Faker +from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData + +from credentials.apps.badges.credly.api_client import CredlyAPIClient +from credentials.apps.badges.credly.exceptions import CredlyError +from credentials.apps.badges.models import BadgeTemplate, CredlyOrganization + + +class CredlyApiClientTestCase(TestCase): + def setUp(self): + fake = Faker() + self.api_client = CredlyAPIClient("test_organization_id", "test_api_key") + self.badge_data = BadgeData( + uuid=fake.uuid4(), + user=UserData( + id=1, + is_active=True, + pii=UserPersonalData(username="test_user", email="test_email@mail.com", name="Test User"), + ), + template=BadgeTemplateData( + uuid=fake.uuid4(), + name="Test Badge", + origin="Credly", + description="Test Badge Description", + image_url="https://test.com/image.png", + ), + ) + self.organization = CredlyOrganization.objects.create( + uuid=fake.uuid4(), api_key="test-api-key", name="test_organization" + ) + + def test_get_organization_nonexistent(self): + with mock.patch("credentials.apps.badges.credly.api_client.CredlyOrganization.objects.get") as mock_get: + mock_get.side_effect = CredlyOrganization.DoesNotExist + with self.assertRaises(CredlyError) as cm: + CredlyAPIClient("nonexistent_organization_id") + self.assertEqual( + str(cm.exception), + "CredlyOrganization with the uuid nonexistent_organization_id does not exist!", + ) + + def test_perform_request(self): + with mock.patch("credentials.apps.badges.credly.api_client.requests.request") as mock_request: + mock_response = mock.Mock() + mock_response.json.return_value = {"key": "value"} + mock_request.return_value = mock_response + result = self.api_client.perform_request("GET", "/api/endpoint") + mock_request.assert_called_once_with( + "GET", + "https://sandbox-api.credly.com/api/endpoint", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": "Basic dGVzdF9hcGlfa2V5", + }, + json=None, + timeout=10, + ) + self.assertEqual(result, {"key": "value"}) + + def test_fetch_organization(self): + with mock.patch.object(CredlyAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"organization": "data"} + result = self.api_client.fetch_organization() + mock_perform_request.assert_called_once_with("get", "") + self.assertEqual(result, {"organization": "data"}) + + def test_fetch_badge_templates(self): + with mock.patch.object(CredlyAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"badge_templates": ["template1", "template2"]} + result = self.api_client.fetch_badge_templates() + mock_perform_request.assert_called_once_with("get", "badge_templates/?filter=state::active") + self.assertEqual(result, {"badge_templates": ["template1", "template2"]}) + + def test_fetch_event_information(self): + event_id = "event123" + with mock.patch.object(CredlyAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"event": "data"} + result = self.api_client.fetch_event_information(event_id) + mock_perform_request.assert_called_once_with("get", f"events/{event_id}/") + self.assertEqual(result, {"event": "data"}) + + def test_issue_badge(self): + issue_badge_data = self.badge_data + with mock.patch.object(CredlyAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"badge": "issued"} + result = self.api_client.issue_badge(issue_badge_data) + mock_perform_request.assert_called_once_with("post", "badges/", asdict(issue_badge_data)) + self.assertEqual(result, {"badge": "issued"}) + + def test_revoke_badge(self): + badge_id = "badge123" + data = {"data": "value"} + with mock.patch.object(CredlyAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"badge": "revoked"} + result = self.api_client.revoke_badge(badge_id, data) + mock_perform_request.assert_called_once_with("put", f"badges/{badge_id}/revoke/", data=data) + self.assertEqual(result, {"badge": "revoked"}) + + def test_sync_organization_badge_templates(self): + with mock.patch.object(CredlyAPIClient, "fetch_badge_templates") as mock_fetch_badge_templates: + mock_fetch_badge_templates.return_value = { + "data": [ + { + "id": Faker().uuid4(), + "name": "Badge Template 1", + "state": "active", + "description": "Badge Template 1 Description", + }, + { + "id": Faker().uuid4(), + "name": "Badge Template 2", + "state": "active", + "description": "Badge Template 2 Description", + }, + ] + } + api_client = CredlyAPIClient(self.organization.uuid) + result = api_client.sync_organization_badge_templates(1) + mock_fetch_badge_templates.assert_called_once() + self.assertEqual(result, 2) + badge_templates = BadgeTemplate.objects.all() + self.assertEqual(badge_templates.count(), 2) + self.assertEqual(badge_templates[0].name, "Badge Template 1") diff --git a/credentials/apps/badges/tests/test_issuers.py b/credentials/apps/badges/tests/test_issuers.py new file mode 100644 index 000000000..787f2ba27 --- /dev/null +++ b/credentials/apps/badges/tests/test_issuers.py @@ -0,0 +1,172 @@ +from unittest import mock +from unittest.mock import patch + +import faker +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from credentials.apps.badges.credly.api_client import CredlyAPIClient +from credentials.apps.badges.credly.exceptions import CredlyAPIError +from credentials.apps.badges.issuers import CredlyBadgeTemplateIssuer +from credentials.apps.badges.models import CredlyBadge, CredlyBadgeTemplate, CredlyOrganization +from credentials.apps.credentials.constants import UserCredentialStatus + + +User = get_user_model() + + +class CredlyBadgeTemplateIssuerTestCase(TestCase): + issued_credential_type = CredlyBadgeTemplate + issued_user_credential_type = CredlyBadge + issuer = CredlyBadgeTemplateIssuer + + def setUp(self): + # Create a test badge template + self.fake = faker.Faker() + credly_organization = CredlyOrganization.objects.create( + uuid=self.fake.uuid4(), api_key=self.fake.uuid4(), name=self.fake.word() + ) + self.badge_template = self.issued_credential_type.objects.create( + origin=self.issued_credential_type.ORIGIN, + site_id=1, + uuid=self.fake.uuid4(), + name=self.fake.word(), + state="active", + organization=credly_organization, + ) + User.objects.create_user(username="test_user", email="test_email@fff.com", password="test_password") + + def _perform_request(self, method, endpoint, data=None): # pylint: disable=unused-argument + fake = faker.Faker() + return {"data": {"id": fake.uuid4(), "state": "issued"}} + + def test_create_user_credential_with_status_awared(self): + # Call create_user_credential with valid arguments + with mock.patch("credentials.apps.badges.issuers.notify_badge_awarded") as mock_notify_badge_awarded: + + with mock.patch.object(self.issuer, "issue_credly_badge") as mock_issue_credly_badge: + self.issuer().award(credential_id=self.badge_template.id, username="test_user") + + mock_notify_badge_awarded.assert_called_once() + mock_issue_credly_badge.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), + credential_id=self.badge_template.id, + ).exists() + ) + + def test_create_user_credential_with_status_revoked(self): + # Call create_user_credential with valid arguments + self.issued_user_credential_type.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), + credential_id=self.badge_template.id, + state=CredlyBadge.STATES.pending, + uuid=self.fake.uuid4(), + external_uuid=self.fake.uuid4(), + ) + + with mock.patch("credentials.apps.badges.issuers.notify_badge_revoked") as mock_notify_badge_revoked: + with mock.patch.object(self.issuer, "revoke_credly_badge") as mock_revoke_credly_badge: + self.issuer().revoke(self.badge_template.id, "test_user") + + mock_revoke_credly_badge.assert_called_once() + 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), + credential_id=self.badge_template.id, + status=UserCredentialStatus.REVOKED, + ).exists() + ) + + @patch.object(CredlyAPIClient, "perform_request", _perform_request) + def test_issue_credly_badge(self): + # Create a test user credential + user_credential = self.issued_user_credential_type.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), + credential_id=self.badge_template.id, + state=CredlyBadge.STATES.pending, + uuid=self.fake.uuid4(), + external_uuid=self.fake.uuid4(), + ) + + # Call the issue_credly_badge method + self.issuer().issue_credly_badge(user_credential=user_credential) + + # Check if the user credential is updated with the external UUID and state + self.assertIsNotNone(user_credential.external_uuid) + self.assertEqual(user_credential.state, "issued") + + # Check if the user credential is saved + user_credential.refresh_from_db() + self.assertIsNotNone(user_credential.external_uuid) + self.assertEqual(user_credential.state, "issued") + + def test_issue_credly_badge_with_error(self): + # Create a test user credential + user_credential = self.issued_user_credential_type.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), + credential_id=self.badge_template.id, + state=CredlyBadge.STATES.pending, + uuid=self.fake.uuid4(), + external_uuid=self.fake.uuid4(), + ) + + # Mock the CredlyAPIClient and its issue_badge method to raise CredlyAPIError + with mock.patch("credentials.apps.badges.credly.api_client.CredlyAPIClient") as mock_credly_api_client: + mock_issue_badge = mock_credly_api_client.return_value.issue_badge + mock_issue_badge.side_effect = CredlyAPIError + + # Call the issue_credly_badge method and expect CredlyAPIError to be raised + with self.assertRaises(CredlyAPIError): + self.issuer().issue_credly_badge(user_credential=user_credential) + + # Check if the user credential state is updated to "error" + user_credential.refresh_from_db() + self.assertEqual(user_credential.state, "error") + + @patch.object(CredlyAPIClient, "revoke_badge") + def test_revoke_credly_badge_success(self, mock_revoke_badge): + user_credential = self.issued_user_credential_type.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), + credential_id=self.badge_template.id, + state=CredlyBadge.STATES.accepted, + uuid=self.fake.uuid4(), + external_uuid=self.fake.uuid4(), + ) + + mock_revoke_badge.return_value = {"data": {"state": "revoked"}} + + self.issuer().revoke_credly_badge(self.badge_template.id, user_credential) + + user_credential.refresh_from_db() + self.assertEqual(user_credential.state, "revoked") + + @patch.object(CredlyAPIClient, "revoke_badge", side_effect=CredlyAPIError("Revocation failed")) + def test_revoke_credly_badge_failure(self, mock_revoke_badge): # pylint: disable=unused-argument + user_credential = self.issued_user_credential_type.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), + credential_id=self.badge_template.id, + state=CredlyBadge.STATES.accepted, + uuid=self.fake.uuid4(), + external_uuid=self.fake.uuid4(), + ) + + with self.assertRaises(CredlyAPIError): + self.issuer().revoke_credly_badge(self.badge_template.id, user_credential) + + user_credential.refresh_from_db() + self.assertEqual(user_credential.state, "error") diff --git a/credentials/apps/badges/tests/test_management_commands.py b/credentials/apps/badges/tests/test_management_commands.py new file mode 100644 index 000000000..2209bdbd3 --- /dev/null +++ b/credentials/apps/badges/tests/test_management_commands.py @@ -0,0 +1,28 @@ +from unittest import mock + +import faker +from django.core.management import call_command +from django.test import TestCase + +from credentials.apps.badges.models import CredlyOrganization + + +class TestSyncOrganizationBadgeTemplatesCommand(TestCase): + def setUp(self): + self.faker = faker.Faker() + self.credly_organization = CredlyOrganization.objects.create( + uuid=self.faker.uuid4(), api_key=self.faker.uuid4(), name=self.faker.word() + ) + CredlyOrganization.objects.bulk_create([CredlyOrganization(uuid=self.faker.uuid4()) for _ in range(5)]) + + @mock.patch("credentials.apps.badges.management.commands.sync_organization_badge_templates.CredlyAPIClient") + def test_handle_no_arguments(self, mock_credly_api_client): + call_command("sync_organization_badge_templates") + self.assertEqual(mock_credly_api_client.call_count, 6) + self.assertEqual(mock_credly_api_client.return_value.sync_organization_badge_templates.call_count, 6) + + @mock.patch("credentials.apps.badges.management.commands.sync_organization_badge_templates.CredlyAPIClient") + def test_handle_with_organization_id(self, mock_credly_api_client): + call_command("sync_organization_badge_templates", "--organization_id", self.credly_organization.uuid) + mock_credly_api_client.assert_called_once_with(self.credly_organization.uuid) + mock_credly_api_client.return_value.sync_organization_badge_templates.assert_called_once_with(1) diff --git a/credentials/apps/badges/tests/test_models.py b/credentials/apps/badges/tests/test_models.py new file mode 100644 index 000000000..cd123b921 --- /dev/null +++ b/credentials/apps/badges/tests/test_models.py @@ -0,0 +1,736 @@ +import uuid +from unittest.mock import patch + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site +from django.test import TestCase +from faker import Faker +from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData + +from credentials.apps.badges.models import ( + BadgePenalty, + BadgeProgress, + BadgeRequirement, + BadgeTemplate, + CredlyBadge, + CredlyBadgeTemplate, + CredlyOrganization, + DataRule, + Fulfillment, + PenaltyDataRule, +) +from credentials.apps.core.models import User + + +class DataRulesTestCase(TestCase): + def setUp(self): + 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.requirement = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + ) + + def test_multiple_data_rules_for_requirement(self): + data_rule1 = DataRule.objects.create( + requirement=self.requirement, + data_path="course_passing_status.user.pii.username", + operator="eq", + value="cucumber1997", + ) + data_rule2 = DataRule.objects.create( + requirement=self.requirement, + data_path="course_passing_status.user.pii.email", + operator="eq", + value="test@example.com", + ) + + data_rules = DataRule.objects.filter(requirement=self.requirement) + + self.assertEqual(data_rules.count(), 2) + self.assertIn(data_rule1, data_rules) + self.assertIn(data_rule2, data_rules) + + +class RequirementApplyRulesCheckTestCase(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_requirement = BadgeRequirement.objects.create( + template=self.badge_template1, + event_type="org.openedx.learning.course.passing.status.updated.v1", + ) + self.data_rule1 = DataRule.objects.create( + requirement=self.badge_requirement, + data_path="course_passing_status.user.pii.username", + operator="eq", + value="test-username", + ) + self.data_rule2 = DataRule.objects.create( + requirement=self.badge_requirement, + data_path="course_passing_status.user.pii.email", + operator="eq", + value="test@example.com", + ) + self.data_rule = DataRule.objects.create + + def test_apply_rules_check_success(self): + data = {"course_passing_status": {"user": {"pii": {"username": "test-username", "email": "test@example.com"}}}} + self.assertTrue(self.badge_requirement.apply_rules(data)) + + def test_apply_rules_check_failed(self): + data = {"course_passing_status": {"user": {"pii": {"username": "test-username2", "email": "test@example.com"}}}} + self.assertFalse(self.badge_requirement.apply_rules(data)) + + +class BadgeRequirementTestCase(TestCase): + def setUp(self): + 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.requirement1 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + ) + self.requirement2 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + ) + self.requirement3 = BadgeRequirement.objects.create( + template=self.credlybadge_template, + event_type="org.openedx.learning.ccx.course.passing.status.updated.v1", + description="Test description", + ) + self.requirement4 = BadgeRequirement.objects.create( + template=self.credlybadge_template, + event_type="org.openedx.learning.ccx.course.passing.status.updated.v1", + description="Test description", + ) + + self.requirement = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + ) + + def test_multiple_requirements_for_badgetemplate(self): + requirements = BadgeRequirement.objects.filter(template=self.badge_template) + + self.assertEqual(requirements.count(), 3) + self.assertIn(self.requirement1, requirements) + self.assertIn(self.requirement2, requirements) + + def test_multiple_requirements_for_credlybadgetemplate(self): + requirements = BadgeRequirement.objects.filter(template=self.credlybadge_template) + + self.assertEqual(requirements.count(), 2) + self.assertIn(self.requirement3, requirements) + self.assertIn(self.requirement4, requirements) + + def test_fulfill(self): + username = "test_user" + template_id = self.badge_template.id + progress = BadgeProgress.objects.create(username=username, template=self.badge_template) + with patch("credentials.apps.badges.models.notify_requirement_fulfilled") as mock_notify: + created = self.requirement.fulfill(username) + fulfillment = Fulfillment.objects.get( + progress=progress, + requirement=self.requirement, + blend=self.requirement.blend, + ) + + self.assertTrue(created) + self.assertTrue(mock_notify.called) + mock_notify.assert_called_with( + sender=self.requirement, + username=username, + badge_template_id=template_id, + fulfillment_id=fulfillment.id, + ) + + def test_is_active(self): + self.requirement.template.is_active = True + self.assertTrue(self.requirement.is_active) + + self.requirement.template.is_active = False + self.assertFalse(self.requirement.is_active) + + +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.fulfillment = Fulfillment.objects.create(progress=self.badge_progress, requirement=self.badge_requirement) + + def test_fulfillment_check_success(self): + is_fulfilled = self.badge_requirement.is_fulfilled("test1") + self.assertTrue(is_fulfilled) + + def test_fulfillment_check_wrong_username(self): + is_fulfilled = self.badge_requirement.is_fulfilled("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", + blend="group1", + ) + self.badge_requirement2 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.ccx.course.passing.status.updated.v1", + blend="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): + groups = self.badge_template.requirements.filter(blend="group1") + self.assertEqual(groups.count(), 2) + self.assertIsNone(self.badge_requirement3.blend) + + +class BadgeTemplateUserProgressTestCase(TestCase): + def setUp(self): + 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.requirement1 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + blend="A", + ) + self.requirement2 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + blend="B", + ) + self.requirement3 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.ccx.course.passing.status.updated.v1", + description="Test description", + blend="C", + ) + + def test_user_progress_success(self): + Fulfillment.objects.create( + progress=BadgeProgress.objects.create(username="test_user", template=self.badge_template), + 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.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.requirement1 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + ) + + def test_is_completed_success(self): + Fulfillment.objects.create( + progress=BadgeProgress.objects.create(username="test_user", template=self.badge_template), + requirement=self.requirement1, + ) + self.assertTrue(self.badge_template.is_completed("test_user")) + + def test_is_completed_failure(self): + self.assertFalse(self.badge_template.is_completed("test_usfer")) + + def test_is_completed_no_requirements(self): + BadgeRequirement.objects.filter(template=self.badge_template).delete() + self.assertEqual(self.badge_template.is_completed("test_user"), 0.0) + + +class BadgeTemplateRatioTestCase(TestCase): + def setUp(self): + 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.requirement1 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + blend="A", + ) + self.requirement2 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + blend="B", + ) + + self.group_requirement1 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + blend="test-group1", + ) + self.group_requirement2 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + blend="test-group1", + ) + + self.group_requirement3 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + blend="test-group2", + ) + self.group_requirement4 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + blend="test-group2", + ) + self.progress = BadgeProgress.objects.create(username="test_user", template=self.badge_template) + + def test_ratio_no_fulfillments(self): + self.assertEqual(self.progress.ratio, 0.00) + + def test_ratio_success(self): + Fulfillment.objects.create( + progress=self.progress, + requirement=self.requirement1, + ) + self.assertEqual(self.progress.ratio, 0.25) + + Fulfillment.objects.create( + progress=self.progress, + requirement=self.requirement2, + ) + self.assertEqual(self.progress.ratio, 0.50) + + Fulfillment.objects.create( + progress=self.progress, + requirement=self.group_requirement1, + ) + self.assertEqual(self.progress.ratio, 0.75) + + Fulfillment.objects.create( + progress=self.progress, + requirement=self.group_requirement3, + ) + self.assertEqual(self.progress.ratio, 1.00) + + def test_ratio_no_requirements(self): + BadgeRequirement.objects.filter(template=self.badge_template).delete() + self.assertEqual(self.progress.ratio, 0.00) + + +class CredlyBadgeAsBadgeDataTestCase(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="test_user", + email="test@example.com", + full_name="Test User", + lms_user_id=1, + ) + self.site = Site.objects.create(domain="test_domain", name="test_name") + self.credential = BadgeTemplate.objects.create( + uuid=uuid.uuid4(), + origin="test_origin", + name="test_template", + description="test_description", + icon="test_icon", + site=self.site, + ) + self.badge = CredlyBadge.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.credential), + credential_id=self.credential.id, + state=CredlyBadge.STATES.pending, + uuid=uuid.uuid4(), + ) + + def test_as_badge_data(self): + expected_badge_data = BadgeData( + uuid=str(self.badge.uuid), + user=UserData( + pii=UserPersonalData( + username=self.user.username, + email=self.user.email, + name=self.user.get_full_name(), + ), + id=self.user.lms_user_id, + is_active=self.user.is_active, + ), + template=BadgeTemplateData( + uuid=str(self.credential.uuid), + origin=self.credential.origin, + name=self.credential.name, + description=self.credential.description, + image_url=str(self.credential.icon), + ), + ) + actual_badge_data = self.badge.as_badge_data() + self.assertEqual(actual_badge_data, expected_badge_data) + + +class BadgePenaltyTestCase(TestCase): + def setUp(self): + self.fake = Faker() + self.badge_template = BadgeTemplate.objects.create( + uuid=self.fake.uuid4(), + name="test_template", + state="draft", + site=Site.objects.create(domain="test_domain", name="test_name"), + is_active=True, + ) + self.badge_requirement = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + ) + self.badge_penalty = BadgePenalty.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.student.registration.completed.v1", + ) + self.badge_penalty.requirements.add(self.badge_requirement) + + def test_apply_rules_with_empty_rules(self): + data = {"key": "value"} + self.assertFalse(self.badge_penalty.apply_rules(data)) + + def test_apply_rules_with_non_empty_rules(self): + data = {"key": "value"} + self.badge_penalty.rules.create(data_path="key", operator="eq", value="value") + self.assertTrue(self.badge_penalty.apply_rules(data)) + + def test_reset_requirements(self): + username = "test-username" + with patch("credentials.apps.badges.models.BadgeRequirement.reset") as mock_reset: + self.badge_penalty.reset_requirements(username) + mock_reset.assert_called_once_with(username) + + def test_is_active(self): + self.assertTrue(self.badge_penalty.is_active) + + +class IsGroupFulfilledTestCase(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", + blend="group1", + ) + self.badge_requirement2 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.ccx.course.passing.status.updated.v1", + blend="group1", + ) + self.badge_requirement3 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + ) + self.username = "test_user" + + def test_is_group_fulfilled_with_fulfilled_requirements(self): + progress = BadgeProgress.objects.create(username=self.username, template=self.badge_template) + Fulfillment.objects.create(progress=progress, requirement=self.badge_requirement1) + + is_fulfilled = BadgeRequirement.is_group_fulfilled( + group="group1", template=self.badge_template, username=self.username + ) + + self.assertTrue(is_fulfilled) + + def test_is_group_fulfilled_with_unfulfilled_requirements(self): + is_fulfilled = BadgeRequirement.is_group_fulfilled( + group="group1", template=self.badge_template, username=self.username + ) + + self.assertFalse(is_fulfilled) + + def test_is_group_fulfilled_with_invalid_group(self): + is_fulfilled = BadgeRequirement.is_group_fulfilled( + group="invalid_group", template=self.badge_template, username=self.username + ) + + self.assertFalse(is_fulfilled) + + +class CredlyOrganizationTestCase(TestCase): + def setUp(self): + self.fake = Faker() + self.uuid = self.fake.uuid4() + self.organization = CredlyOrganization.objects.create( + uuid=self.uuid, api_key="test-api-key", name="test_organization" + ) + + def test_str_representation(self): + self.assertEqual(str(self.organization), "test_organization") + + def test_get_all_organization_ids(self): + organization_ids = [str(uuid) for uuid in CredlyOrganization.get_all_organization_ids()] + self.assertEqual(organization_ids, [self.uuid]) + + def test_get_preconfigured_organizations(self): + preconfigured_organizations = CredlyOrganization.get_preconfigured_organizations() + self.assertEqual( + preconfigured_organizations, + settings.BADGES_CONFIG["credly"].get("ORGANIZATIONS", {}), + ) + + def test_is_preconfigured(self): + with patch( + "credentials.apps.badges.models.CredlyOrganization.get_preconfigured_organizations" + ) as mock_get_preconfigured: + mock_get_preconfigured.return_value = {str(self.uuid): "Test Organization"} + self.assertTrue(self.organization.is_preconfigured) + mock_get_preconfigured.assert_called_once() + + +class BadgeProgressTestCase(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_progress = BadgeProgress.objects.create(template=self.badge_template, username="test_user") + + def test_reset_progress(self): + Fulfillment.objects.create(progress=self.badge_progress) + self.assertEqual(Fulfillment.objects.filter(progress=self.badge_progress).count(), 1) + + self.badge_progress.reset() + + self.assertEqual(Fulfillment.objects.filter(progress=self.badge_progress).count(), 0) + + +class BadgePenaltyIsActiveTestCase(TestCase): + def setUp(self): + 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.requirement = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + ) + self.penalty = BadgePenalty.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + ) + + def test_is_active(self): + self.penalty.template.is_active = True + self.assertTrue(self.penalty.is_active) + + def test_is_not_active(self): + self.penalty.template.is_active = False + self.assertFalse(self.penalty.is_active) + + +class DataRuleIsActiveTestCase(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.requirement = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + ) + self.rule = DataRule.objects.create( + requirement=self.requirement, + data_path="course_passing_status.user.pii.username", + operator="eq", + value="cucumber1997", + ) + + def test_is_active(self): + self.rule.requirement.template.is_active = True + self.assertTrue(self.rule.is_active) + + self.rule.requirement.template.is_active = False + self.assertFalse(self.rule.is_active) + + +class BadgeTemplateTestCase(TestCase): + def test_by_uuid(self): + template_uuid = uuid.uuid4() + site = Site.objects.create(domain="test_domain", name="test_name") + badge_template = BadgeTemplate.objects.create(uuid=template_uuid, origin=BadgeTemplate.ORIGIN, site=site) + + retrieved_template = BadgeTemplate.by_uuid(template_uuid) + + self.assertEqual(retrieved_template, badge_template) + + +class CredlyBadgeTemplateTestCase(TestCase): + def setUp(self): + uuid4 = uuid.uuid4() + self.organization = CredlyOrganization.objects.create( + 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=uuid4, + name="test_template", + state="draft", + site=self.site, + ) + + def test_management_url(self): + credly_host_base_url = "https://example.com/" + with patch("credentials.apps.badges.models.get_credly_base_url") as mock_get_credly_base_url: + mock_get_credly_base_url.return_value = credly_host_base_url + expected_url = ( + f"{credly_host_base_url}mgmt/organizations/" + f"{self.organization.uuid}/badges/templates/{self.badge_template.uuid}/details" + ) + self.assertEqual(self.badge_template.management_url, expected_url) + mock_get_credly_base_url.assert_called_with(settings) + + +class PenaltyDataruleIsActiveTestCase(TestCase): + def setUp(self): + 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.requirement = BadgeRequirement.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + description="Test description", + ) + self.penalty = BadgePenalty.objects.create( + template=self.badge_template, + event_type="org.openedx.learning.course.passing.status.updated.v1", + ) + self.rule = PenaltyDataRule.objects.create( + penalty=self.penalty, + data_path="course_passing_status.user.pii.username", + operator="eq", + value="cucumber1997", + ) + + def test_is_active(self): + self.requirement.template.is_active = True + self.assertTrue(self.rule.is_active) + + self.requirement.template.is_active = False + self.assertFalse(self.rule.is_active) diff --git a/credentials/apps/badges/tests/test_services.py b/credentials/apps/badges/tests/test_services.py new file mode 100644 index 000000000..87dc685e2 --- /dev/null +++ b/credentials/apps/badges/tests/test_services.py @@ -0,0 +1,758 @@ +import uuid +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey +from openedx_events.learning.data import CourseData, CoursePassingStatusData, UserData, UserPersonalData + +from credentials.apps.badges.exceptions import BadgesProcessingError +from credentials.apps.badges.models import ( + BadgePenalty, + BadgeProgress, + BadgeRequirement, + BadgeTemplate, + CredlyBadgeTemplate, + CredlyOrganization, + DataRule, + Fulfillment, + PenaltyDataRule, +) +from credentials.apps.badges.processing.generic import identify_user, process_event +from credentials.apps.badges.processing.progression import discover_requirements, process_requirements +from credentials.apps.badges.processing.regression import discover_penalties, process_penalties +from credentials.apps.badges.signals import BADGE_PROGRESS_COMPLETE +from credentials.apps.badges.signals.handlers import handle_badge_completion + + +COURSE_PASSING_EVENT = "org.openedx.learning.course.passing.status.updated.v1" +COURSE_PASSING_DATA = CoursePassingStatusData( + is_passing=True, + course=CourseData(course_key=CourseKey.from_string("course-v1:edX+DemoX.1+2014"), display_name="A"), + user=UserData( + id=1, + is_active=True, + pii=UserPersonalData(username="test_username", email="test_email", name="John Doe"), + ), +) +User = get_user_model() + + +class BadgeRequirementDiscoveryTestCase(TestCase): + def setUp(self): + 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, is_active=True + ) + + 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=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=COURSE_PASSING_EVENT) + ccx_course_passing_requirements = discover_requirements(event_type=self.CCX_COURSE_PASSING_EVENT) + self.assertEqual(course_passing_requirements.count(), 1) + self.assertEqual(ccx_course_passing_requirements.count(), 2) + self.assertEqual(course_passing_requirements[0].description, "Test course passing award description") + self.assertEqual(ccx_course_passing_requirements[0].description, "Test ccx course passing award description") + self.assertEqual(ccx_course_passing_requirements[1].description, "Test ccx course passing revoke description") + + +class BadgePenaltyDiscoveryTestCase(TestCase): + def setUp(self): + 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, is_active=True + ) + + self.CCX_COURSE_PASSING_EVENT = "org.openedx.learning.ccx.course.passing.status.updated.v1" + + def test_discovery_eventtype_related_penalties(self): + penalty1 = BadgePenalty.objects.create(template=self.badge_template, event_type=COURSE_PASSING_EVENT) + penalty1.requirements.add( + BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description", + ) + ) + penalty2 = BadgePenalty.objects.create(template=self.badge_template, event_type=self.CCX_COURSE_PASSING_EVENT) + penalty2.requirements.add( + BadgeRequirement.objects.create( + template=self.badge_template, + event_type=self.CCX_COURSE_PASSING_EVENT, + description="Test ccx course passing award description", + ) + ) + penalty3 = BadgePenalty.objects.create(template=self.badge_template, event_type=self.CCX_COURSE_PASSING_EVENT) + penalty3.requirements.add( + 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=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", + ) + + +class TestProcessPenalties(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="test_username", email="test@example.com", full_name="Test User", lms_user_id=1 + ) + 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( + uuid=uuid.uuid4(), + name="test_template", + state="draft", + site=self.site, + is_active=True, + organization=self.organization, + ) + + self.CCX_COURSE_PASSING_EVENT = "org.openedx.learning.ccx.course.passing.status.updated.v1" + + def test_process_penalties_all_datarules_success(self): + requirement1 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description 1", + ) + requirement2 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description 2", + ) + DataRule.objects.create( + requirement=requirement1, + data_path="course.display_name", + operator="eq", + value="A", + ) + DataRule.objects.create( + requirement=requirement2, + data_path="course.display_name", + operator="ne", + value="B", + ) + + progress = BadgeProgress.objects.create(username="test_username", template=self.badge_template) + Fulfillment.objects.create(progress=progress, requirement=requirement1) + Fulfillment.objects.create(progress=progress, requirement=requirement2) + + self.assertEqual(BadgeProgress.objects.filter(username="test_username").count(), 1) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 2) + self.assertEqual(Fulfillment.objects.filter(progress=progress, requirement=requirement1).count(), 1) + self.assertEqual(Fulfillment.objects.filter(progress=progress, requirement=requirement1).count(), 1) + + bp = BadgePenalty.objects.create(template=self.badge_template, event_type=COURSE_PASSING_EVENT) + bp.requirements.set( + (requirement1, requirement2), + ) + PenaltyDataRule.objects.create( + penalty=bp, + data_path="course.display_name", + operator="ne", + value="test_username1", + ) + self.badge_template.is_active = True + self.badge_template.save() + process_penalties(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 0) + + def test_process_penalties_one_datarule_fail(self): + requirement1 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description 1", + ) + requirement2 = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description 2", + ) + DataRule.objects.create( + requirement=requirement1, + data_path="course.display_name", + operator="eq", + value="A", + ) + DataRule.objects.create( + requirement=requirement2, + data_path="course.display_name", + operator="eq", + value="B", + ) + + progress = BadgeProgress.objects.create(username="test_username") + Fulfillment.objects.create(progress=progress, requirement=requirement1) + Fulfillment.objects.create(progress=progress, requirement=requirement2) + + self.assertEqual(BadgeProgress.objects.filter(username="test_username").count(), 1) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 2) + self.assertEqual(Fulfillment.objects.filter(progress=progress, requirement=requirement1).count(), 1) + self.assertEqual(Fulfillment.objects.filter(progress=progress, requirement=requirement1).count(), 1) + + BadgePenalty.objects.create(template=self.badge_template, event_type=COURSE_PASSING_EVENT).requirements.set( + (requirement1, requirement2), + ) + PenaltyDataRule.objects.create( + penalty=BadgePenalty.objects.first(), + data_path="course.display_name", + operator="eq", + value="A", + ) + PenaltyDataRule.objects.create( + penalty=BadgePenalty.objects.first(), + data_path="course.display_name", + operator="ne", + value="A", + ) + process_penalties(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 2) + + def test_process_single_requirement_penalty(self): + requirement = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description", + ) + DataRule.objects.create( + requirement=requirement, + data_path="course.display_name", + operator="ne", + value="B", + ) + progress = BadgeProgress.objects.create(username="test_username", template=self.badge_template) + Fulfillment.objects.create(progress=progress, requirement=requirement) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 1) + + penalty = BadgePenalty.objects.create(template=self.badge_template, event_type=COURSE_PASSING_EVENT) + penalty.requirements.add(requirement) + PenaltyDataRule.objects.create( + penalty=penalty, + data_path="course.display_name", + operator="eq", + value="A", + ) + process_penalties(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 0) + + def test_process_one_of_grouped_requirements_penalty(self): + requirement_a = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description", + blend="a_or_b", + ) + requirement_b = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description", + blend="a_or_b", + ) + DataRule.objects.create( + requirement=requirement_a, + data_path="course.display_name", + operator="eq", + value="A", + ) + DataRule.objects.create( + requirement=requirement_b, + data_path="course.display_name", + operator="eq", + value="A", + ) + progress = BadgeProgress.objects.create(username="test_username", template=self.badge_template) + Fulfillment.objects.create(progress=progress, requirement=requirement_a) + Fulfillment.objects.create(progress=progress, requirement=requirement_b) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 2) + + penalty = BadgePenalty.objects.create(template=self.badge_template, event_type=COURSE_PASSING_EVENT) + penalty.requirements.add(requirement_b) + PenaltyDataRule.objects.create( + penalty=penalty, + data_path="course.display_name", + operator="ne", + value="B", + ) + process_penalties(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 1) + + def test_process_mixed_penalty(self): + requirement_a = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description", + blend="a_or_b", + ) + requirement_b = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description", + blend="a_or_b", + ) + requirement_c = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="Test course passing award description", + ) + DataRule.objects.create( + requirement=requirement_a, + data_path="course.display_name", + operator="eq", + value="A", + ) + DataRule.objects.create( + requirement=requirement_b, + data_path="course.display_name", + operator="eq", + value="B", + ) + DataRule.objects.create( + requirement=requirement_c, + data_path="course.display_name", + operator="ne", + value="C", + ) + progress = BadgeProgress.objects.create(username="test_username", template=self.badge_template) + Fulfillment.objects.create(progress=progress, requirement=requirement_a) + Fulfillment.objects.create(progress=progress, requirement=requirement_c) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 2) + + penalty = BadgePenalty.objects.create(template=self.badge_template, event_type=COURSE_PASSING_EVENT) + penalty.requirements.add(requirement_a, requirement_c) + PenaltyDataRule.objects.create( + penalty=penalty, + data_path="course.display_name", + operator="ne", + value="B", + ) + process_penalties(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(progress=progress).count(), 0) + + +class TestProcessRequirements(TestCase): + def setUp(self): + 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( + uuid=uuid.uuid4(), + name="test_template", + state="draft", + site=self.site, + organization=self.organization, + is_active=True, + ) + + self.CCX_COURSE_PASSING_EVENT = "org.openedx.learning.ccx.course.passing.status.updated.v1" + self.user = identify_user(event_type=COURSE_PASSING_EVENT, event_payload=COURSE_PASSING_DATA) + + # disconnect BADGE_PROGRESS_COMPLETE signal + BADGE_PROGRESS_COMPLETE.disconnect(handle_badge_completion) + + def tearDown(self): + BADGE_PROGRESS_COMPLETE.connect(handle_badge_completion) + + # test cases + # A course completion - course A w/o a group; + # A or B course completion - courses A, B have the same group value; + # A or B or C course completion - courses A, B, C have the same group value; + # A or - courses A is the only course in the group; + # (A or B) and C - A, B have the same group value; course C w/o a group; + # (A or B) and (C or D) - courses A, B have the same group value; courses C, D have the same group value; + + def test_course_a_completion(self): + """ + Test course A completion. + + A course completion - course A w/o a group. + """ + + requirement = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A course passing award description", + ) + DataRule.objects.create( + requirement=requirement, + data_path="course.display_name", + operator="eq", + value="A", + ) + process_requirements(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement).count(), 1) + self.assertTrue(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + def test_course_a_or_b_completion(self): + """ + Test course A or B completion. + + A or B course completion - courses A, B have the same group value. + """ + + requirement_a = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or B course passing award description", + blend="a_or_b", + ) + requirement_b = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or B course passing award description", + blend="a_or_b", + ) + DataRule.objects.create( + requirement=requirement_a, + data_path="course.display_name", + operator="eq", + value="A", + ) + DataRule.objects.create( + requirement=requirement_b, + data_path="course.display_name", + operator="eq", + value="B", + ) + process_requirements(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_a).count(), 1) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_b).count(), 0) + self.assertTrue(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + def test_course_a_or_b_or_c_completion(self): + """ + Test course A or B or C completion. + + A or B or C course completion - courses A, B, C have the same group value. + """ + + requirement_a = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or B or C course passing award description", + blend="a_or_b_or_c", + ) + requirement_b = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or B or C course passing award description", + blend="a_or_b_or_c", + ) + requirement_c = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or B or C course passing award description", + blend="a_or_b_or_c", + ) + DataRule.objects.create( + requirement=requirement_a, + data_path="course.display_name", + operator="eq", + value="A", + ) + DataRule.objects.create( + requirement=requirement_b, + data_path="course.display_name", + operator="eq", + value="B", + ) + DataRule.objects.create( + requirement=requirement_c, + data_path="course.display_name", + operator="eq", + value="C", + ) + process_requirements(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_a).count(), 1) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_b).count(), 0) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_c).count(), 0) + self.assertTrue(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + def test_course_a_or_completion(self): + """ + Test course A or completion. + + A or - courses A is the only course in the group. + """ + + requirement = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or course passing award description", + blend="a_or", + ) + DataRule.objects.create( + requirement=requirement, + data_path="course.display_name", + operator="eq", + value="A", + ) + process_requirements(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement).count(), 1) + self.assertTrue(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + def test_course_a_or_b_and_c_completion(self): + """ + Test course A or B and C completion. + + (A or B) and C - A, B have the same group value; course C w/o a group. + """ + + requirement_a = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or B course passing award description", + blend="a_or_b", + ) + requirement_b = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or B course passing award description", + blend="a_or_b", + ) + requirement_c = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="C course passing award description", + ) + DataRule.objects.create( + requirement=requirement_a, + data_path="course.display_name", + operator="ne", + value="A", + ) + DataRule.objects.create( + requirement=requirement_c, + data_path="course.display_name", + operator="eq", + value="A", + ) + + process_requirements(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_a).count(), 0) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_b).count(), 0) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_c).count(), 1) + self.assertFalse(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + DataRule.objects.create( + requirement=requirement_b, + data_path="course.display_name", + operator="eq", + value="A", + ) + + process_requirements(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_a).count(), 0) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_b).count(), 1) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_c).count(), 1) + + self.assertTrue(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + def test_course_a_or_b_and_c_or_d_completion(self): + """ + Test course A or B and C or D completion. + + (A or B) and (C or D) - courses A, B have the same group value; courses C, D have the same group value. + """ + + requirement_a = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or B course passing award description", + blend="a_or_b", + ) + requirement_b = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="A or B course passing award description", + blend="a_or_b", + ) + requirement_c = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="C or D course passing award description", + blend="c_or_d", + ) + requirement_d = BadgeRequirement.objects.create( + template=self.badge_template, + event_type=COURSE_PASSING_EVENT, + description="C or D course passing award description", + blend="c_or_d", + ) + DataRule.objects.create( + requirement=requirement_a, + data_path="course.display_name", + operator="eq", + value="A", + ) + DataRule.objects.create( + requirement=requirement_b, + data_path="course.display_name", + operator="eq", + value="B", + ) + DataRule.objects.create( + requirement=requirement_d, + data_path="course.display_name", + operator="eq", + value="D", + ) + + self.assertFalse(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + process_requirements(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_a).count(), 1) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_b).count(), 0) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_c).count(), 0) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_d).count(), 0) + self.assertFalse(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + DataRule.objects.create( + requirement=requirement_c, + data_path="course.display_name", + operator="eq", + value="A", + ) + process_requirements(COURSE_PASSING_EVENT, "test_username", COURSE_PASSING_DATA) + + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_a).count(), 1) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_b).count(), 0) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_c).count(), 1) + self.assertEqual(Fulfillment.objects.filter(requirement=requirement_d).count(), 0) + self.assertTrue(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + +class TestIdentifyUser(TestCase): + def test_identify_user(self): + username = identify_user(event_type=COURSE_PASSING_EVENT, event_payload=COURSE_PASSING_DATA) + self.assertEqual(username, "test_username") + + def test_identify_user_not_found(self): + event_type = "unknown_event_type" + event_payload = None + + with self.assertRaises(BadgesProcessingError) as cm: + identify_user(event_type="unknown_event_type", event_payload=event_payload) + + self.assertEqual( + str(cm.exception), + f"User data cannot be found (got: None): {event_payload}. " + f"Does event {event_type} include user data at all?", + ) + + +def mock_progress_regress(*args, **kwargs): + return None + + +class TestProcessEvent(TestCase): + def setUp(self): + 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( + uuid=uuid.uuid4(), + name="test_template", + state="draft", + site=self.site, + organization=self.organization, + is_active=True, + ) + DataRule.objects.create( + requirement=BadgeRequirement.objects.create(template=self.badge_template, event_type=COURSE_PASSING_EVENT), + data_path="is_passing", + operator="eq", + value="True", + ) + PenaltyDataRule.objects.create( + penalty=BadgePenalty.objects.create(template=self.badge_template, event_type=COURSE_PASSING_EVENT), + data_path="is_passing", + operator="eq", + value="False", + ) + self.sender = MagicMock() + self.sender.event_type = COURSE_PASSING_EVENT + + @patch.object(BadgeProgress, "progress", mock_progress_regress) + def test_process_event_passing(self): + event_payload = COURSE_PASSING_DATA + process_event(sender=self.sender, kwargs=event_payload) + self.assertTrue(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + def test_process_event_not_passing(self): + event_payload = CoursePassingStatusData( + is_passing=False, + course=CourseData(course_key=CourseKey.from_string("course-v1:edX+DemoX.1+2014"), display_name="A"), + user=UserData( + id=1, + is_active=True, + pii=UserPersonalData(username="test_username", email="test_email", name="John Doe"), + ), + ) + process_event(sender=self.sender, kwargs=event_payload) + self.assertFalse(BadgeProgress.for_user(username="test_username", template_id=self.badge_template.id).completed) + + @patch.object(BadgeProgress, "regress", mock_progress_regress) + def test_process_event_not_found(self): + sender = MagicMock() + sender.event_type = "unknown_event_type" + event_payload = None + + with patch("credentials.apps.badges.processing.generic.logger.error") as mock_event_not_found: + process_event(sender=sender, kwargs=event_payload) + mock_event_not_found.assert_called_once() + + def test_process_event_no_user_data(self): + event_payload = CoursePassingStatusData( + is_passing=True, + course=CourseData(course_key=CourseKey.from_string("course-v1:edX+DemoX.1+2014"), display_name="A"), + user=None, + ) + + with patch("credentials.apps.badges.processing.generic.logger.error") as mock_no_user_data: + process_event(sender=self.sender, kwargs=event_payload) + mock_no_user_data.assert_called_once() diff --git a/credentials/apps/badges/tests/test_signals.py b/credentials/apps/badges/tests/test_signals.py new file mode 100644 index 000000000..0a79663b4 --- /dev/null +++ b/credentials/apps/badges/tests/test_signals.py @@ -0,0 +1,67 @@ +from unittest import mock + +import faker +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from credentials.apps.badges.issuers import CredlyBadgeTemplateIssuer +from credentials.apps.badges.models import CredlyBadge, CredlyBadgeTemplate, CredlyOrganization +from credentials.apps.badges.signals.signals import BADGE_PROGRESS_COMPLETE, BADGE_PROGRESS_INCOMPLETE + + +class BadgeSignalReceiverTestCase(TestCase): + 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() + ) + self.badge_template = CredlyBadgeTemplate.objects.create( + name="test", site_id=1, organization=credly_organization + ) + + def test_progression_signal_emission_and_receiver_execution(self): + # Emit the signal + with mock.patch("credentials.apps.badges.issuers.notify_badge_awarded"): + with mock.patch.object(CredlyBadgeTemplateIssuer, "issue_credly_badge"): + BADGE_PROGRESS_COMPLETE.send( + sender=self, + username="test_user", + badge_template_id=self.badge_template.id, + ) + + # UserCredential object + user_credential = CredlyBadge.objects.filter( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), + credential_id=self.badge_template.id, + ) + + # Check if user credential is created + self.assertTrue(user_credential.exists()) + + # Check if user credential status is 'awarded' + self.assertEqual(user_credential[0].status, "awarded") + + def test_regression_signal_emission_and_receiver_execution(self): + # Emit the signal + with mock.patch("credentials.apps.badges.issuers.notify_badge_revoked"): + with mock.patch.object(CredlyBadgeTemplateIssuer, "revoke_credly_badge"): + BADGE_PROGRESS_INCOMPLETE.send( + sender=self, + username="test_user", + badge_template_id=self.badge_template.id, + ) + + # UserCredential object + user_credential = CredlyBadge.objects.filter( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.badge_template), + credential_id=self.badge_template.id, + ) + + # Check if user credential is created + self.assertTrue(user_credential.exists()) + + # Check if user credential status is 'revoked' + self.assertEqual(user_credential[0].status, "revoked") diff --git a/credentials/apps/badges/tests/test_utils.py b/credentials/apps/badges/tests/test_utils.py new file mode 100644 index 000000000..2d79f873c --- /dev/null +++ b/credentials/apps/badges/tests/test_utils.py @@ -0,0 +1,242 @@ +import unittest +from datetime import datetime +from unittest.mock import patch + +from attr import asdict +from django.conf import settings +from opaque_keys.edx.keys import CourseKey +from openedx_events.learning.data import CourseData, CoursePassingStatusData, UserData, UserPersonalData + +from credentials.apps.badges.checks import badges_checks +from credentials.apps.badges.credly.utils import get_credly_api_base_url, get_credly_base_url +from credentials.apps.badges.utils import ( + credly_check, + extract_payload, + get_event_type_attr_type_by_keypath, + get_event_type_keypaths, + get_user_data, + keypath, +) + + +COURSE_PASSING_EVENT = "org.openedx.learning.course.passing.status.updated.v1" + + +class TestKeypath(unittest.TestCase): + def test_keypath_exists(self): + payload = { + "course": { + "key": "105-3332", + } + } + result = keypath(payload, "course.key") + self.assertEqual(result, "105-3332") + + def test_keypath_not_exists(self): + payload = { + "course": { + "id": "105-3332", + } + } + result = keypath(payload, "course.key") + self.assertIsNone(result) + + def test_keypath_deep(self): + payload = {"course": {"data": {"identification": {"id": 25}}}} + result = keypath(payload, "course.data.identification.id") + self.assertEqual(result, 25) + + def test_keypath_invalid_path(self): + payload = { + "course": { + "key": "105-3332", + } + } + result = keypath(payload, "course.id") + self.assertIsNone(result) + + +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 = { + "course_passing_status": CoursePassingStatusData( + is_passing=True, course=self.course_data_1, user=self.user_data_1 + ) + } + + def test_get_user_data_from_course_enrollment(self): + result_1 = get_user_data(extract_payload(self.passing_status_1)) + self.assertIsNotNone(result_1) + self.assertEqual(result_1.id, 1) + self.assertTrue(result_1.is_active) + self.assertEqual(result_1.pii.username, "user1") + self.assertEqual(result_1.pii.email, "user1@example.com") + self.assertEqual(result_1.pii.name, "John Doe") + + +class TestExtractPayload(unittest.TestCase): + def setUp(self): + self.course_data = CourseData( + course_key="105-3332", + display_name="Introduction to Computer Science", + start=datetime(2024, 4, 1), + end=datetime(2024, 6, 1), + ) + + def test_extract_payload(self): + user_data = UserData( + id=1, is_active=True, pii=UserPersonalData(username="user1", email="user1@example.com ", name="John Doe") + ) + course_passing_status = CoursePassingStatusData(is_passing=True, course=self.course_data, user=user_data) + public_signal_kwargs = {"course_passing_status": course_passing_status} + result = extract_payload(public_signal_kwargs) + self.assertIsNotNone(result) + self.assertEqual(asdict(result), asdict(course_passing_status)) + + def test_extract_payload_empty_payload(self): + public_signal_kwargs = {"public_signal_kwargs": {}} + result = extract_payload(**public_signal_kwargs) + self.assertIsNone(result) + + +class TestBadgesChecks(unittest.TestCase): + @patch("credentials.apps.badges.checks.get_badging_event_types") + def test_badges_checks_empty_events(self, mock_get_badging_event_types): + mock_get_badging_event_types.return_value = [] + errors = badges_checks() + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].msg, "BADGES_CONFIG['events'] must include at least one event.") + self.assertEqual(errors[0].hint, "Add at least one event to BADGES_CONFIG['events'] setting.") + self.assertEqual(errors[0].id, "badges.E001") + + @patch("credentials.apps.badges.checks.get_badging_event_types") + def test_badges_checks_non_empty_events(self, mock_get_badging_event_types): + mock_get_badging_event_types.return_value = ["event1", "event2"] + errors = badges_checks() + self.assertEqual(len(errors), 0) + + @patch("credentials.apps.badges.checks.credly_check") + def test_badges_checks_credly_not_configured(self, mock_credly_check): + mock_credly_check.return_value = False + errors = badges_checks() + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].msg, "Credly settings are not properly configured.") + self.assertEqual(errors[0].hint, "Make sure all required settings are present in BADGES_CONFIG['credly'].") + self.assertEqual(errors[0].id, "badges.E002") + + +class TestCredlyCheck(unittest.TestCase): + def test_credly_configured(self): + settings.BADGES_CONFIG = { + "credly": { + "CREDLY_BASE_URL": "https://www.credly.com", + "CREDLY_API_BASE_URL": "https://api.credly.com", + "CREDLY_SANDBOX_BASE_URL": "https://sandbox.credly.com", + "CREDLY_SANDBOX_API_BASE_URL": "https://sandbox.api.credly.com", + "USE_SANDBOX": True, + } + } + result = credly_check() + self.assertTrue(result) + + def test_credly_not_configured(self): + settings.BADGES_CONFIG = {} + result = credly_check() + self.assertFalse(result) + + def test_credly_missing_keys(self): + settings.BADGES_CONFIG = { + "credly": { + "CREDLY_BASE_URL": "https://www.credly.com", + "CREDLY_API_BASE_URL": "https://api.credly.com", + "USE_SANDBOX": True, + } + } + result = credly_check() + self.assertFalse(result) + + +class TestGetEventTypeKeypaths(unittest.TestCase): + def test_get_event_type_keypaths(self): + result = get_event_type_keypaths(COURSE_PASSING_EVENT) + + for ignored_keypath in settings.BADGES_CONFIG.get("rules", {}).get("ignored_keypaths", []): + self.assertNotIn(ignored_keypath, result) + + +class TestGetCredlyBaseUrl(unittest.TestCase): + def test_get_credly_base_url_sandbox(self): + settings.BADGES_CONFIG["credly"] = { + "CREDLY_BASE_URL": "https://www.credly.com", + "CREDLY_SANDBOX_BASE_URL": "https://sandbox.credly.com", + "USE_SANDBOX": True, + } + result = get_credly_base_url(settings) + self.assertEqual(result, "https://sandbox.credly.com") + + def test_get_credly_base_url_production(self): + settings.BADGES_CONFIG["credly"] = { + "CREDLY_BASE_URL": "https://www.credly.com", + "CREDLY_SANDBOX_BASE_URL": "https://sandbox.credly.com", + "USE_SANDBOX": False, + } + result = get_credly_base_url(settings) + self.assertEqual(result, "https://www.credly.com") + + +class TestGetCredlyApiBaseUrl(unittest.TestCase): + def test_get_credly_api_base_url_sandbox(self): + settings.BADGES_CONFIG["credly"] = { + "CREDLY_API_BASE_URL": "https://api.credly.com", + "CREDLY_SANDBOX_API_BASE_URL": "https://sandbox.api.credly.com", + "USE_SANDBOX": True, + } + + result = get_credly_api_base_url(settings) + self.assertEqual(result, "https://sandbox.api.credly.com") + + def test_get_credly_api_base_url_production(self): + settings.BADGES_CONFIG["credly"] = { + "CREDLY_API_BASE_URL": "https://api.credly.com", + "CREDLY_SANDBOX_API_BASE_URL": "https://sandbox.api.credly.com", + "USE_SANDBOX": False, + } + result = get_credly_api_base_url(settings) + self.assertEqual(result, "https://api.credly.com") + + +class TestGetEventTypeAttrTypeByKeypath(unittest.TestCase): + def test_get_event_type_attr_type_by_keypath(self): + key_path = "course.course_key" + result = get_event_type_attr_type_by_keypath(COURSE_PASSING_EVENT, key_path) + self.assertEqual(result, CourseKey) + + def test_get_event_type_attr_type_by_keypath_bool(self): + key_path = "is_passing" + result = get_event_type_attr_type_by_keypath(COURSE_PASSING_EVENT, key_path) + self.assertEqual(result, bool) + + def test_get_event_type_attr_type_by_keypath_not_found(self): + key_path = "course.id" + result = get_event_type_attr_type_by_keypath(COURSE_PASSING_EVENT, key_path) + self.assertIsNone(result) diff --git a/credentials/apps/badges/tests/test_webhooks.py b/credentials/apps/badges/tests/test_webhooks.py new file mode 100644 index 000000000..d9365fcfd --- /dev/null +++ b/credentials/apps/badges/tests/test_webhooks.py @@ -0,0 +1,122 @@ +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.test.client import RequestFactory +from faker import Faker + +from credentials.apps.badges.credly.api_client import CredlyAPIClient +from credentials.apps.badges.credly.webhooks import CredlyWebhook +from credentials.apps.badges.models import CredlyBadgeTemplate, CredlyOrganization + + +def get_organization(self, organization_id): # pylint: disable=unused-argument + organization = MagicMock(spec=CredlyOrganization) + organization.uuid = organization_id + organization.api_key = "test_api_key" + return organization + + +def perform_request(self, method, endpoint, data=None): # pylint: disable=unused-argument + return {"key": "value"} + + +class CredlyWebhookTestCase(TestCase): + def setUp(self): + self.rf = RequestFactory() + self.fake = Faker() + self.organization = CredlyOrganization.objects.create(uuid=self.fake.uuid4(), api_key="test_api_key") + + @patch.object(CredlyAPIClient, "_get_organization", get_organization) + @patch.object(CredlyAPIClient, "perform_request", perform_request) + def test_webhook_created_event(self): + with patch( + "credentials.apps.badges.credly.webhooks.CredlyWebhook.handle_badge_template_created_event" + ) as mock_handle: + req = self.rf.post( + "/credly/webhook/", + data={ + "id": self.fake.uuid4(), + "organization_id": self.organization.uuid, + "event_type": "badge_template.created", + "occurred_at": "2021-01-01T00:00:00Z", + }, + ) + res = CredlyWebhook.as_view()(req) + self.assertEqual(res.status_code, 204) + mock_handle.assert_called_once() + + @patch.object(CredlyAPIClient, "_get_organization", get_organization) + @patch.object(CredlyAPIClient, "perform_request", perform_request) + def test_webhook_changed_event(self): + with patch( + "credentials.apps.badges.credly.webhooks.CredlyWebhook.handle_badge_template_changed_event" + ) as mock_handle: + req = self.rf.post( + "/credly/webhook/", + data={ + "id": self.fake.uuid4(), + "organization_id": self.organization.uuid, + "event_type": "badge_template.changed", + "occurred_at": "2021-01-01T00:00:00Z", + }, + ) + res = CredlyWebhook.as_view()(req) + self.assertEqual(res.status_code, 204) + mock_handle.assert_called_once() + + @patch.object(CredlyAPIClient, "_get_organization", get_organization) + @patch.object(CredlyAPIClient, "perform_request", perform_request) + def test_webhook_deleted_event(self): + with patch( + "credentials.apps.badges.credly.webhooks.CredlyWebhook.handle_badge_template_deleted_event" + ) as mock_handle: + req = self.rf.post( + "/credly/webhook/", + data={ + "id": self.fake.uuid4(), + "organization_id": self.fake.uuid4(), + "event_type": "badge_template.deleted", + "occurred_at": "2021-01-01T00:00:00Z", + }, + ) + res = CredlyWebhook.as_view()(req) + self.assertEqual(res.status_code, 204) + mock_handle.assert_called_once() + + @patch.object(CredlyAPIClient, "_get_organization", get_organization) + @patch.object(CredlyAPIClient, "perform_request", perform_request) + def test_webhook_nonexistent_event(self): + with patch("credentials.apps.badges.credly.webhooks.logger.error") as mock_handle: + req = self.rf.post( + "/credly/webhookd/", + data={ + "id": self.fake.uuid4(), + "organization_id": self.fake.uuid4(), + "event_type": "unknown_event", + "occurred_at": "2021-01-01T00:00:00Z", + }, + ) + CredlyWebhook.as_view()(req) + mock_handle.assert_called_once() + + def test_handle_badge_template_deleted_event(self): + request_data = { + "organization_id": "test_organization_id", + "id": "test_event_id", + "event_type": "badge_template.deleted", + "data": { + "badge_template": { + "id": self.fake.uuid4(), + "owner": {"id": self.fake.uuid4()}, + "name": "Test Template", + "state": "active", + "description": "Test Description", + "image_url": "http://example.com/image.png", + } + }, + } + request = self.rf.post("/credly/webhook/", data=request_data) + + CredlyWebhook.handle_badge_template_deleted_event(request, request_data) + + self.assertEqual(CredlyBadgeTemplate.objects.count(), 0) diff --git a/credentials/apps/badges/toggles.py b/credentials/apps/badges/toggles.py new file mode 100644 index 000000000..b82d510d5 --- /dev/null +++ b/credentials/apps/badges/toggles.py @@ -0,0 +1,37 @@ +""" +Badges app toggles. +""" + +from edx_toggles.toggles import SettingToggle + + +# .. toggle_name: BADGES_ENABLED +# .. toggle_implementation: DjangoSetting +# .. toggle_default: False +# .. toggle_description: Determines if the Credentials IDA uses badges functionality. +# .. toggle_life_expectancy: permanent +# .. toggle_permanent_justification: Badges are optional for usage. +# .. toggle_creation_date: 2024-01-12 +# .. toggle_use_cases: open_edx +ENABLE_BADGES = SettingToggle("BADGES_ENABLED", default=False, module_name=__name__) + + +def is_badges_enabled(): + """ + Check main feature flag. + """ + + return ENABLE_BADGES.is_enabled() + + +def check_badges_enabled(func): + """ + Decorator for checking the applicability of a badges app. + """ + + def wrapper(*args, **kwargs): + if is_badges_enabled(): + return func(*args, **kwargs) + return None + + return wrapper diff --git a/credentials/apps/badges/urls.py b/credentials/apps/badges/urls.py new file mode 100644 index 000000000..c2d906812 --- /dev/null +++ b/credentials/apps/badges/urls.py @@ -0,0 +1,12 @@ +""" +URLs for badges. +""" + +from django.urls import path + +from .credly.webhooks import CredlyWebhook + + +urlpatterns = [ + path("credly/webhook/", CredlyWebhook.as_view(), name="credly-webhook"), +] diff --git a/credentials/apps/badges/utils.py b/credentials/apps/badges/utils.py new file mode 100644 index 000000000..719ae66ea --- /dev/null +++ b/credentials/apps/badges/utils.py @@ -0,0 +1,203 @@ +import inspect +from typing import Union + +import attr +from attrs import asdict +from django.conf import settings +from openedx_events.learning.data import UserData +from openedx_events.tooling import OpenEdxPublicSignal + + +def get_badging_event_types(): + """ + Figures out which events are available for badges. + """ + return settings.BADGES_CONFIG.get("events", []) + + +def credly_check(): + """ + Checks if Credly is configured. + """ + + credly_settings = settings.BADGES_CONFIG.get("credly", None) + if credly_settings is None: + return False + keys = ( + "CREDLY_BASE_URL", + "CREDLY_API_BASE_URL", + "CREDLY_SANDBOX_BASE_URL", + "CREDLY_SANDBOX_API_BASE_URL", + "USE_SANDBOX", + ) + return all(key in credly_settings.keys() for key in keys) + + +def keypath(payload, keys_path): + """ + Retrieve the value from a nested dictionary using a dot-separated key path. + + Traverses a nested dictionary `payload` to find the value specified by the dot-separated + key path `keys_path`. Each key in `keys_path` represents a level in the nested dictionary. + + Parameters: + - payload (dict): The nested dictionary to search. + - keys_path (str): The dot-separated path of keys to traverse in the dictionary. + + Returns: + - The value found at the specified key path in the dictionary, or None if any key in the path + does not exist or the traversal leads to a non-dictionary object before reaching the final key. + + Example: + >>> payload = {'a': {'b': {'c': 1}}} + >>> keypath(payload, 'a.b.c') + 1 + >>> keypath(payload, 'a.b.d') + None + """ + + keys = keys_path.split(".") + current = payload + + def traverse(current, keys): + """ + Recursive function to traverse the dictionary. + """ + + if not keys: + return current + key = keys[0] + if attr.has(current): + current = asdict(current) + if isinstance(current, dict) and key in current: + return traverse(current[key], keys[1:]) + else: + return None + + return traverse(current, keys) + + +def get_user_data(data: attr.s) -> UserData: + """ + Extracts UserData object from any dataclass that contains UserData as a field. + + Parameters: + - data: Input dict that contains attr class, which has UserData somewhere deep. + + Returns: + UserData: UserData object contained within the input dataclass. + """ + + if isinstance(data, UserData): + return data + + for _, attr_value in inspect.getmembers(data): + if isinstance(attr_value, UserData): + return attr_value + elif attr.has(attr_value): + user_data = get_user_data(attr_value) + if user_data: + return user_data + return None + + +def extract_payload(public_signal_kwargs: dict) -> Union[None, attr.s]: + """ + Extracts the event payload from the event data. + + Parameters: + - public_signal_kwargs: The event data. + + Returns: + attr.s: The extracted event payload. + """ + for value in public_signal_kwargs.values(): + if attr.has(value): + return value + return None + + +def get_event_type_data(event_type: str) -> attr.s: + """ + Extracts the dataclass for a given event type. + + Parameters: + - event_type: The event type to extract dataclass for. + + Returns: + attr.s: The dataclass for the given event type. + """ + + signal = OpenEdxPublicSignal.get_signal_by_type(event_type) + return extract_payload(signal.init_data) + + +def get_event_type_keypaths(event_type: str) -> list: + """ + Extracts all possible keypaths for a given event type. + + Parameters: + - event_type: The event type to extract keypaths for. + + Returns: + list: A list of all possible keypaths for the given event type. + """ + + data = get_event_type_data(event_type) + + def get_data_keypaths(data): + """ + Extracts all possible keypaths for a given dataclass. + """ + + keypaths = [] + for field in attr.fields(data): + if attr.has(field.type): + keypaths += [f"{field.name}.{keypath}" for keypath in get_data_keypaths(field.type)] + else: + keypaths.append(field.name) + return keypaths + + keypaths = [] + for field in attr.fields(data): + if attr.has(field.type): + keypaths += [ + f"{field.name}.{keypath}" + for keypath in get_data_keypaths(field.type) + if f"{field.name}.{keypath}" not in settings.BADGES_CONFIG.get("rules", {}).get("ignored_keypaths", []) + ] + else: + keypaths.append(field.name) + return keypaths + + +def get_event_type_attr_type_by_keypath(event_type: str, key_path: str): + """ + Extracts the attribute type for a given keypath in the event type. + + Parameters: + - event_type: The event type to extract dataclass for. + - key_path: The keypath to extract attribute type for. + + Returns: + type: The attribute type for the given keypath in the event data. + """ + + data = get_event_type_data(event_type) + data_attrs = attr.fields(data) + + def get_attr_type_by_keypath(data_attrs, key_path): + """ + Extracts the attribute type for a given keypath in the dataclass. + """ + + keypath_parts = key_path.split(".") + for attr_ in data_attrs: + if attr_.name == keypath_parts[0]: + if len(keypath_parts) == 1: + return attr_.type + elif attr.has(attr_.type): + return get_attr_type_by_keypath(attr.fields(attr_.type), ".".join(keypath_parts[1:])) + return None + + return get_attr_type_by_keypath(data_attrs, key_path) diff --git a/credentials/apps/credentials/migrations/0029_alter_usercredential_credential_content_type.py b/credentials/apps/credentials/migrations/0029_alter_usercredential_credential_content_type.py new file mode 100644 index 000000000..5430aa8e5 --- /dev/null +++ b/credentials/apps/credentials/migrations/0029_alter_usercredential_credential_content_type.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.13 on 2024-06-11 17:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("credentials", "0029_signatory_not_mandatory"), + ] + + 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", + ), + ), + ] diff --git a/credentials/apps/credentials/models.py b/credentials/apps/credentials/models.py index 414cc04d4..0f6fd98bf 100644 --- a/credentials/apps/credentials/models.py +++ b/credentials/apps/credentials/models.py @@ -168,7 +168,7 @@ class UserCredential(TimeStampedModel): credential_content_type = models.ForeignKey( ContentType, - limit_choices_to={"model__in": ("coursecertificate", "programcertificate")}, + limit_choices_to={"model__in": ("coursecertificate", "programcertificate", "credlybadgetemplate")}, on_delete=models.CASCADE, ) credential_id = models.PositiveIntegerField() diff --git a/credentials/apps/credentials/tests/test_api.py b/credentials/apps/credentials/tests/test_api.py index a54bb3abc..ddb8de1c0 100644 --- a/credentials/apps/credentials/tests/test_api.py +++ b/credentials/apps/credentials/tests/test_api.py @@ -474,8 +474,8 @@ def test_award_course_credential(self): assert credential.username == self.user.username assert credential.credential_id == course_cert_config.id assert credential.status == "awarded" - # 12 is the content type for "Course Certificate" - assert credential.credential_content_type_id == 12 + # 22 is the content type for "Course Certificate" + assert credential.credential_content_type_id == 22 def test_revoke_course_credential(self): """ @@ -498,8 +498,8 @@ def test_revoke_course_credential(self): assert credential.username == self.user.username assert credential.credential_id == course_cert_config.id assert credential.status == "revoked" - # 12 is the content type for "Course Certificate" - assert credential.credential_content_type_id == 12 + # 22 is the content type for "Course Certificate" + assert credential.credential_content_type_id == 22 def test_update_existing_cert(self): """ @@ -565,8 +565,8 @@ def test_award_course_cert_no_course_certificate(self): assert credential.username == self.user.username assert credential.credential_id == course_cert_config.id assert credential.status == "awarded" - # 12 is the content type for "Course Certificate" - assert credential.credential_content_type_id == 12 + # 22 is the content type for "Course Certificate" + assert credential.credential_content_type_id == 22 def test_award_course_cert_no_course_certificate_exception_occurs(self): """ diff --git a/credentials/conf/locale/eo/LC_MESSAGES/django.mo b/credentials/conf/locale/eo/LC_MESSAGES/django.mo index ee2f42d9c..66074e39a 100644 Binary files a/credentials/conf/locale/eo/LC_MESSAGES/django.mo and b/credentials/conf/locale/eo/LC_MESSAGES/django.mo differ diff --git a/credentials/conf/locale/eo/LC_MESSAGES/django.po b/credentials/conf/locale/eo/LC_MESSAGES/django.po index f7bf930bb..190bb2654 100644 --- a/credentials/conf/locale/eo/LC_MESSAGES/django.po +++ b/credentials/conf/locale/eo/LC_MESSAGES/django.po @@ -17,6 +17,253 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: apps/badges/admin.py +msgid "No rules specified." +msgstr "Nö rülés spéçïfïéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#" + +#: apps/badges/admin.py +msgid "Badge templates were successfully updated." +msgstr "" +"Bädgé témplätés wéré süççéssfüllý üpdätéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя #" + +#: apps/badges/admin.py +msgid "API key" +msgstr "ÀPÌ kéý Ⱡ'σяєм ιρѕυм #" + +#: apps/badges/admin.py +msgid "Pre-configured from the environment." +msgstr "" +"Pré-çönfïgüréd fröm thé énvïrönmént. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυ#" + +#: apps/badges/admin.py +msgid "" +"\n" +" WARNING: avoid configuration updates on activated badges.\n" +" Active badge templates are continuously processed and learners may already have progress on them.\n" +" Any changes in badge template requirements (including data rules) will affect learners' experience!\n" +" " +msgstr "" +"\n" +" WÀRNÌNG: ävöïd çönfïgürätïön üpdätés ön äçtïvätéd ßädgés.\n" +" Àçtïvé ßädgé témplätés äré çöntïnüöüslý pröçésséd änd léärnérs mäý älréädý hävé prögréss ön thém.\n" +" Àný çhängés ïn ßädgé témpläté réqüïréménts (ïnçlüdïng dätä rülés) wïll äfféçt léärnérs' éxpérïénçé!\n" +" Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя ιη¢ι∂ι∂υ#" + +#: apps/badges/admin.py +msgid "Active badge template cannot be deleted." +msgstr "" +"Àçtïvé ßädgé témpläté çännöt ßé délétéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя#" + +#: apps/badges/admin.py +msgid "Active badge templates cannot be deleted." +msgstr "" +"Àçtïvé ßädgé témplätés çännöt ßé délétéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя #" + +#: apps/badges/admin.py +msgid "icon" +msgstr "ïçön Ⱡ'σяєм ι#" + +#: apps/badges/admin.py +msgid "Active badge template must have at least one requirement." +msgstr "" +"Àçtïvé ßädgé témpläté müst hävé ät léäst öné réqüïrémént. Ⱡ'σяєм ιρѕυм ∂σłσя" +" ѕιт αмєт, ¢σηѕє¢тєтυя α#" + +#: apps/badges/admin.py +msgid "badge template" +msgstr "ßädgé témpläté Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#" + +#: apps/badges/admin_forms.py +msgid "You can't provide an API key for a configured organization." +msgstr "" +"Ýöü çän't prövïdé än ÀPÌ kéý för ä çönfïgüréd örgänïzätïön. Ⱡ'σяєм ιρѕυм " +"∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" + +#: apps/badges/admin_forms.py +msgid "All requirements must belong to the same template." +msgstr "" +"Àll réqüïréménts müst ßélöng tö thé sämé témpläté. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт " +"αмєт, ¢σηѕє¢тєтυя α#" + +#: apps/badges/admin_forms.py +msgid "Value must be a boolean." +msgstr "Välüé müst ßé ä ßööléän. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢ση#" + +#: apps/badges/issuers.py +msgid "Open edX internal user credential was revoked" +msgstr "" +"Öpén édX ïntérnäl üsér çrédéntïäl wäs révökéd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя #" + +#: apps/badges/models.py +msgid "Put your Credly Organization ID here." +msgstr "" +"Püt ýöür Çrédlý Örgänïzätïön ÌD héré. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυ#" + +#: apps/badges/models.py +msgid "Credly API shared secret for Credly Organization." +msgstr "" +"Çrédlý ÀPÌ shäréd séçrét för Çrédlý Örgänïzätïön. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт " +"αмєт, ¢σηѕє¢тєтυя α#" + +#: apps/badges/models.py +msgid "Verbose name for Credly Organization." +msgstr "" +"Vérßösé nämé för Çrédlý Örgänïzätïön. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυ#" + +#: apps/badges/models.py +msgid "Unique badge template ID." +msgstr "Ûnïqüé ßädgé témpläté ÌD. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" + +#: apps/badges/models.py +msgid "Badge template name." +msgstr "Bädgé témpläté nämé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" + +#: apps/badges/models.py +msgid "Badge template description." +msgstr "Bädgé témpläté désçrïptïön. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#" + +#: apps/badges/models.py +msgid "Badge template type." +msgstr "Bädgé témpläté týpé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" + +#: apps/badges/models.py +msgid "Credly badge template state (auto-managed)." +msgstr "" +"Çrédlý ßädgé témpläté stäté (äütö-mänägéd). Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя #" + +#: apps/badges/models.py +msgid "Credly Organization - template owner." +msgstr "" +"Çrédlý Örgänïzätïön - témpläté öwnér. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυ#" + +#: apps/badges/models.py +msgid "Badge template this requirement serves for." +msgstr "" +"Bädgé témpläté thïs réqüïrémént sérvés för. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя #" + +#: apps/badges/models.py +msgid "" +"Public signal type. Available events are configured in \"BADGES_CONFIG\" " +"setting. The crucial aspect for event to carry UserData in its payload." +msgstr "" +"Püßlïç sïgnäl týpé. Àväïläßlé événts äré çönfïgüréd ïn \"BÀDGÉS_ÇÖNFÌG\" " +"séttïng. Thé çrüçïäl äspéçt för évént tö çärrý ÛsérDätä ïn ïts päýlöäd. " +"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ " +"тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм νєηιαм," +" qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα ¢σммσ∂σ " +"¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт єѕѕє " +"¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт ¢υρι∂αтαт " +"ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт αηιм ι∂ єѕт#" + +#: apps/badges/models.py +msgid "Provide more details if needed." +msgstr "Prövïdé möré détäïls ïf néédéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" + +#: apps/badges/models.py +msgid "" +"Optional. Group requirements together using the same Group ID for " +"interchangeable (OR processing logic)." +msgstr "" +"Öptïönäl. Gröüp réqüïréménts tögéthér üsïng thé sämé Gröüp ÌD för " +"ïntérçhängéäßlé (ÖR pröçéssïng lögïç). Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#" + +#: apps/badges/models.py +msgid "group" +msgstr "gröüp Ⱡ'σяєм ιρѕ#" + +#: apps/badges/models.py +msgid "" +"Public signal's data payload nested property path, e.g: " +"\"user.pii.username\"." +msgstr "" +"Püßlïç sïgnäl's dätä päýlöäd néstéd pröpértý päth, é.g: " +"\"üsér.pïï.üsérnämé\". Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυ#" + +#: apps/badges/models.py +msgid "key path" +msgstr "kéý päth Ⱡ'σяєм ιρѕυм ∂#" + +#: apps/badges/models.py +msgid "" +"Expected value comparison operator. " +"https://docs.python.org/3/library/operator.html" +msgstr "" +"Éxpéçtéd välüé çömpärïsön öpérätör. " +"https://döçs.pýthön.örg/3/lïßrärý/öpérätör.html Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт," +" ¢σηѕє¢т#" + +#: apps/badges/models.py +msgid "Expected value for the nested property, e.g: \"cucumber1997\"." +msgstr "" +"Éxpéçtéd välüé för thé néstéd pröpértý, é.g: \"çüçümßér1997\". Ⱡ'σяєм ιρѕυм " +"∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" + +#: apps/badges/models.py +msgid "expected value" +msgstr "éxpéçtéd välüé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#" + +#: apps/badges/models.py +msgid "Parent requirement for this data rule." +msgstr "" +"Pärént réqüïrémént för thïs dätä rülé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя#" + +#: apps/badges/models.py +msgid "Badge template this penalty serves for." +msgstr "" +"Bädgé témpläté thïs pénältý sérvés för. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя#" + +#: apps/badges/models.py +msgid "" +"Public signal type. Use namespaced types, e.g: " +"\"org.openedx.learning.student.registration.completed.v1\"" +msgstr "" +"Püßlïç sïgnäl týpé. Ûsé näméspäçéd týpés, é.g: " +"\"örg.öpénédx.léärnïng.stüdént.régïsträtïön.çömplétéd.v1\" Ⱡ'σяєм ιρѕυм " +"∂σłσя ѕιт αм#" + +#: apps/badges/models.py +msgid "Badge requirements for which this penalty is defined." +msgstr "" +"Bädgé réqüïréménts för whïçh thïs pénältý ïs défïnéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт" +" αмєт, ¢σηѕє¢тєтυя α#" + +#: apps/badges/models.py +msgid "Badge penalties" +msgstr "Bädgé pénältïés Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#" + +#: apps/badges/models.py +msgid "Parent penalty for this data rule." +msgstr "" +"Pärént pénältý för thïs dätä rülé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєт#" + +#: apps/badges/models.py +msgid "badge progress records" +msgstr "ßädgé prögréss réçörds Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢#" + +#: apps/badges/models.py +msgid "Group ID for the requirement." +msgstr "Gröüp ÌD för thé réqüïrémént. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#" + +#: apps/badges/models.py +msgid "Credly badge issuing state" +msgstr "Çrédlý ßädgé ïssüïng stäté Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" + +#: apps/badges/models.py +msgid "Credly service badge identifier" +msgstr "Çrédlý sérvïçé ßädgé ïdéntïfïér Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" + #: apps/core/admin.py msgid "Activate selected entries" msgstr "Àçtïväté séléçtéd éntrïés Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/django.mo b/credentials/conf/locale/rtl/LC_MESSAGES/django.mo index a7b91c13b..cca532ada 100644 Binary files a/credentials/conf/locale/rtl/LC_MESSAGES/django.mo and b/credentials/conf/locale/rtl/LC_MESSAGES/django.mo differ diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/django.po b/credentials/conf/locale/rtl/LC_MESSAGES/django.po index af28e4f9f..469f79ecd 100644 --- a/credentials/conf/locale/rtl/LC_MESSAGES/django.po +++ b/credentials/conf/locale/rtl/LC_MESSAGES/django.po @@ -17,6 +17,208 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: apps/badges/admin.py +msgid "No rules specified." +msgstr "Nø ɹnlǝs sdǝɔᴉɟᴉǝd." + +#: apps/badges/admin.py +msgid "Badge templates were successfully updated." +msgstr "Ƀɐdƃǝ ʇǝɯdlɐʇǝs ʍǝɹǝ snɔɔǝssɟnllʎ nddɐʇǝd." + +#: apps/badges/admin.py +msgid "API key" +msgstr "ȺⱣƗ ʞǝʎ" + +#: apps/badges/admin.py +msgid "Pre-configured from the environment." +msgstr "Ᵽɹǝ-ɔønɟᴉƃnɹǝd ɟɹøɯ ʇɥǝ ǝnʌᴉɹønɯǝnʇ." + +#: apps/badges/admin.py +msgid "" +"\n" +" WARNING: avoid configuration updates on activated badges.\n" +" Active badge templates are continuously processed and learners may already have progress on them.\n" +" Any changes in badge template requirements (including data rules) will affect learners' experience!\n" +" " +msgstr "" +"\n" +" WȺɌNƗNǤ: ɐʌøᴉd ɔønɟᴉƃnɹɐʇᴉøn nddɐʇǝs øn ɐɔʇᴉʌɐʇǝd bɐdƃǝs.\n" +" Ⱥɔʇᴉʌǝ bɐdƃǝ ʇǝɯdlɐʇǝs ɐɹǝ ɔønʇᴉnnønslʎ dɹøɔǝssǝd ɐnd lǝɐɹnǝɹs ɯɐʎ ɐlɹǝɐdʎ ɥɐʌǝ dɹøƃɹǝss øn ʇɥǝɯ.\n" +" Ⱥnʎ ɔɥɐnƃǝs ᴉn bɐdƃǝ ʇǝɯdlɐʇǝ ɹǝbnᴉɹǝɯǝnʇs (ᴉnɔlndᴉnƃ dɐʇɐ ɹnlǝs) ʍᴉll ɐɟɟǝɔʇ lǝɐɹnǝɹs' ǝxdǝɹᴉǝnɔǝ!\n" +" " + +#: apps/badges/admin.py +msgid "Active badge template cannot be deleted." +msgstr "Ⱥɔʇᴉʌǝ bɐdƃǝ ʇǝɯdlɐʇǝ ɔɐnnøʇ bǝ dǝlǝʇǝd." + +#: apps/badges/admin.py +msgid "Active badge templates cannot be deleted." +msgstr "Ⱥɔʇᴉʌǝ bɐdƃǝ ʇǝɯdlɐʇǝs ɔɐnnøʇ bǝ dǝlǝʇǝd." + +#: apps/badges/admin.py +msgid "icon" +msgstr "ᴉɔøn" + +#: apps/badges/admin.py +msgid "Active badge template must have at least one requirement." +msgstr "Ⱥɔʇᴉʌǝ bɐdƃǝ ʇǝɯdlɐʇǝ ɯnsʇ ɥɐʌǝ ɐʇ lǝɐsʇ ønǝ ɹǝbnᴉɹǝɯǝnʇ." + +#: apps/badges/admin.py +msgid "badge template" +msgstr "bɐdƃǝ ʇǝɯdlɐʇǝ" + +#: apps/badges/admin_forms.py +msgid "You can't provide an API key for a configured organization." +msgstr "Ɏøn ɔɐn'ʇ dɹøʌᴉdǝ ɐn ȺⱣƗ ʞǝʎ ɟøɹ ɐ ɔønɟᴉƃnɹǝd øɹƃɐnᴉzɐʇᴉøn." + +#: apps/badges/admin_forms.py +msgid "All requirements must belong to the same template." +msgstr "Ⱥll ɹǝbnᴉɹǝɯǝnʇs ɯnsʇ bǝlønƃ ʇø ʇɥǝ sɐɯǝ ʇǝɯdlɐʇǝ." + +#: apps/badges/admin_forms.py +msgid "Value must be a boolean." +msgstr "Vɐlnǝ ɯnsʇ bǝ ɐ bøølǝɐn." + +#: apps/badges/issuers.py +msgid "Open edX internal user credential was revoked" +msgstr "Ødǝn ǝdX ᴉnʇǝɹnɐl nsǝɹ ɔɹǝdǝnʇᴉɐl ʍɐs ɹǝʌøʞǝd" + +#: apps/badges/models.py +msgid "Put your Credly Organization ID here." +msgstr "Ᵽnʇ ʎønɹ Ȼɹǝdlʎ Øɹƃɐnᴉzɐʇᴉøn ƗĐ ɥǝɹǝ." + +#: apps/badges/models.py +msgid "Credly API shared secret for Credly Organization." +msgstr "Ȼɹǝdlʎ ȺⱣƗ sɥɐɹǝd sǝɔɹǝʇ ɟøɹ Ȼɹǝdlʎ Øɹƃɐnᴉzɐʇᴉøn." + +#: apps/badges/models.py +msgid "Verbose name for Credly Organization." +msgstr "Vǝɹbøsǝ nɐɯǝ ɟøɹ Ȼɹǝdlʎ Øɹƃɐnᴉzɐʇᴉøn." + +#: apps/badges/models.py +msgid "Unique badge template ID." +msgstr "Ʉnᴉbnǝ bɐdƃǝ ʇǝɯdlɐʇǝ ƗĐ." + +#: apps/badges/models.py +msgid "Badge template name." +msgstr "Ƀɐdƃǝ ʇǝɯdlɐʇǝ nɐɯǝ." + +#: apps/badges/models.py +msgid "Badge template description." +msgstr "Ƀɐdƃǝ ʇǝɯdlɐʇǝ dǝsɔɹᴉdʇᴉøn." + +#: apps/badges/models.py +msgid "Badge template type." +msgstr "Ƀɐdƃǝ ʇǝɯdlɐʇǝ ʇʎdǝ." + +#: apps/badges/models.py +msgid "Credly badge template state (auto-managed)." +msgstr "Ȼɹǝdlʎ bɐdƃǝ ʇǝɯdlɐʇǝ sʇɐʇǝ (ɐnʇø-ɯɐnɐƃǝd)." + +#: apps/badges/models.py +msgid "Credly Organization - template owner." +msgstr "Ȼɹǝdlʎ Øɹƃɐnᴉzɐʇᴉøn - ʇǝɯdlɐʇǝ øʍnǝɹ." + +#: apps/badges/models.py +msgid "Badge template this requirement serves for." +msgstr "Ƀɐdƃǝ ʇǝɯdlɐʇǝ ʇɥᴉs ɹǝbnᴉɹǝɯǝnʇ sǝɹʌǝs ɟøɹ." + +#: apps/badges/models.py +msgid "" +"Public signal type. Available events are configured in \"BADGES_CONFIG\" " +"setting. The crucial aspect for event to carry UserData in its payload." +msgstr "" +"Ᵽnblᴉɔ sᴉƃnɐl ʇʎdǝ. Ⱥʌɐᴉlɐblǝ ǝʌǝnʇs ɐɹǝ ɔønɟᴉƃnɹǝd ᴉn \"ɃȺĐǤɆS_ȻØNFƗǤ\" " +"sǝʇʇᴉnƃ. Ŧɥǝ ɔɹnɔᴉɐl ɐsdǝɔʇ ɟøɹ ǝʌǝnʇ ʇø ɔɐɹɹʎ ɄsǝɹĐɐʇɐ ᴉn ᴉʇs dɐʎløɐd." + +#: apps/badges/models.py +msgid "Provide more details if needed." +msgstr "Ᵽɹøʌᴉdǝ ɯøɹǝ dǝʇɐᴉls ᴉɟ nǝǝdǝd." + +#: apps/badges/models.py +msgid "" +"Optional. Group requirements together using the same Group ID for " +"interchangeable (OR processing logic)." +msgstr "" +"Ødʇᴉønɐl. Ǥɹønd ɹǝbnᴉɹǝɯǝnʇs ʇøƃǝʇɥǝɹ nsᴉnƃ ʇɥǝ sɐɯǝ Ǥɹønd ƗĐ ɟøɹ " +"ᴉnʇǝɹɔɥɐnƃǝɐblǝ (ØɌ dɹøɔǝssᴉnƃ løƃᴉɔ)." + +#: apps/badges/models.py +msgid "group" +msgstr "ƃɹønd" + +#: apps/badges/models.py +msgid "" +"Public signal's data payload nested property path, e.g: " +"\"user.pii.username\"." +msgstr "" +"Ᵽnblᴉɔ sᴉƃnɐl's dɐʇɐ dɐʎløɐd nǝsʇǝd dɹødǝɹʇʎ dɐʇɥ, ǝ.ƃ: " +"\"nsǝɹ.dᴉᴉ.nsǝɹnɐɯǝ\"." + +#: apps/badges/models.py +msgid "key path" +msgstr "ʞǝʎ dɐʇɥ" + +#: apps/badges/models.py +msgid "" +"Expected value comparison operator. " +"https://docs.python.org/3/library/operator.html" +msgstr "" +"Ɇxdǝɔʇǝd ʌɐlnǝ ɔøɯdɐɹᴉsøn ødǝɹɐʇøɹ. " +"ɥʇʇds://døɔs.dʎʇɥøn.øɹƃ/3/lᴉbɹɐɹʎ/ødǝɹɐʇøɹ.ɥʇɯl" + +#: apps/badges/models.py +msgid "Expected value for the nested property, e.g: \"cucumber1997\"." +msgstr "Ɇxdǝɔʇǝd ʌɐlnǝ ɟøɹ ʇɥǝ nǝsʇǝd dɹødǝɹʇʎ, ǝ.ƃ: \"ɔnɔnɯbǝɹ1997\"." + +#: apps/badges/models.py +msgid "expected value" +msgstr "ǝxdǝɔʇǝd ʌɐlnǝ" + +#: apps/badges/models.py +msgid "Parent requirement for this data rule." +msgstr "Ᵽɐɹǝnʇ ɹǝbnᴉɹǝɯǝnʇ ɟøɹ ʇɥᴉs dɐʇɐ ɹnlǝ." + +#: apps/badges/models.py +msgid "Badge template this penalty serves for." +msgstr "Ƀɐdƃǝ ʇǝɯdlɐʇǝ ʇɥᴉs dǝnɐlʇʎ sǝɹʌǝs ɟøɹ." + +#: apps/badges/models.py +msgid "" +"Public signal type. Use namespaced types, e.g: " +"\"org.openedx.learning.student.registration.completed.v1\"" +msgstr "" +"Ᵽnblᴉɔ sᴉƃnɐl ʇʎdǝ. Ʉsǝ nɐɯǝsdɐɔǝd ʇʎdǝs, ǝ.ƃ: " +"\"øɹƃ.ødǝnǝdx.lǝɐɹnᴉnƃ.sʇndǝnʇ.ɹǝƃᴉsʇɹɐʇᴉøn.ɔøɯdlǝʇǝd.ʌ1\"" + +#: apps/badges/models.py +msgid "Badge requirements for which this penalty is defined." +msgstr "Ƀɐdƃǝ ɹǝbnᴉɹǝɯǝnʇs ɟøɹ ʍɥᴉɔɥ ʇɥᴉs dǝnɐlʇʎ ᴉs dǝɟᴉnǝd." + +#: apps/badges/models.py +msgid "Badge penalties" +msgstr "Ƀɐdƃǝ dǝnɐlʇᴉǝs" + +#: apps/badges/models.py +msgid "Parent penalty for this data rule." +msgstr "Ᵽɐɹǝnʇ dǝnɐlʇʎ ɟøɹ ʇɥᴉs dɐʇɐ ɹnlǝ." + +#: apps/badges/models.py +msgid "badge progress records" +msgstr "bɐdƃǝ dɹøƃɹǝss ɹǝɔøɹds" + +#: apps/badges/models.py +msgid "Group ID for the requirement." +msgstr "Ǥɹønd ƗĐ ɟøɹ ʇɥǝ ɹǝbnᴉɹǝɯǝnʇ." + +#: apps/badges/models.py +msgid "Credly badge issuing state" +msgstr "Ȼɹǝdlʎ bɐdƃǝ ᴉssnᴉnƃ sʇɐʇǝ" + +#: apps/badges/models.py +msgid "Credly service badge identifier" +msgstr "Ȼɹǝdlʎ sǝɹʌᴉɔǝ bɐdƃǝ ᴉdǝnʇᴉɟᴉǝɹ" + #: apps/core/admin.py msgid "Activate selected entries" msgstr "Ⱥɔʇᴉʌɐʇǝ sǝlǝɔʇǝd ǝnʇɹᴉǝs" diff --git a/credentials/settings/base.py b/credentials/settings/base.py index 7f0e49912..3a344d559 100644 --- a/credentials/settings/base.py +++ b/credentials/settings/base.py @@ -76,6 +76,7 @@ "credentials.apps.records", "credentials.apps.plugins", "credentials.apps.verifiable_credentials", + "credentials.apps.badges", ] INSTALLED_APPS += THIRD_PARTY_APPS @@ -546,6 +547,37 @@ # disable indexing on history_date SIMPLE_HISTORY_DATE_INDEX = False +# Badges settings +BADGES_ENABLED = False +# .. setting_name: BADGES_CONFIG +# .. setting_description: Dictionary with badges settings including enabled badge events, processors, collectors, etc. +BADGES_CONFIG = { + # # list of the events that should be available in rules configuration interface: + "events": [ + "org.openedx.learning.course.passing.status.updated.v1", + "org.openedx.learning.ccx.course.passing.status.updated.v1", + ], + "credly": { + "CREDLY_BASE_URL": "https://credly.com/", + "CREDLY_API_BASE_URL": "https://api.credly.com/v1/", + "CREDLY_SANDBOX_BASE_URL": "https://sandbox.credly.com/", + "CREDLY_SANDBOX_API_BASE_URL": "https://sandbox-api.credly.com/v1/", + "USE_SANDBOX": False, + }, + "rules": { + "ignored_keypaths": [ + "user.id", + "user.is_active", + "user.pii.username", + "user.pii.email", + "user.pii.name", + "course.display_name", + "course.start", + "course.end", + ], + }, +} + # Event Bus Settings EVENT_BUS_PRODUCER = "edx_event_bus_redis.create_producer" EVENT_BUS_CONSUMER = "edx_event_bus_redis.RedisEventConsumer" @@ -555,6 +587,26 @@ # .. setting_default: all events disabled # .. setting_description: Dictionary of event_types mapped to dictionaries of topic to topic-related configuration. EVENT_BUS_PRODUCER_CONFIG = { + # .. setting_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.badge.awarded.v1'] + # ['learning-badges-lifecycle']['enabled'] + # .. toggle_implementation: SettingToggle + # .. toggle_default: True + # .. toggle_description: Enables sending org.openedx.learning.badge.awarded.v1 events over the event bus. + # .. toggle_warning: The default may be changed in a later release. + # .. toggle_use_cases: opt_in + "org.openedx.learning.badge.awarded.v1": { + "learning-badges-lifecycle": {"event_key_field": "badge.uuid", "enabled": BADGES_ENABLED}, + }, + # .. setting_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.badge.revoked.v1'] + # ['learning-badges-lifecycle']['enabled'] + # .. toggle_implementation: SettingToggle + # .. toggle_default: True + # .. toggle_description: Enables sending org.openedx.learning.badge.revoked.v1 events over the event bus. + # .. toggle_warning: The default may be changed in a later release. + # .. toggle_use_cases: opt_in + "org.openedx.learning.badge.revoked.v1": { + "learning-badges-lifecycle": {"event_key_field": "badge.uuid", "enabled": BADGES_ENABLED}, + }, # .. setting_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.program.certificate.awarded.v1'] # ['learning-program-certificate-lifecycle']['enabled'] # .. toggle_implementation: DjangoSetting diff --git a/credentials/settings/production.py b/credentials/settings/production.py index 9d9853098..74c644a56 100644 --- a/credentials/settings/production.py +++ b/credentials/settings/production.py @@ -29,6 +29,9 @@ AWS_SES_REGION_NAME = environ.get("AWS_SES_REGION_NAME", "us-east-1") AWS_SES_REGION_ENDPOINT = environ.get("AWS_SES_REGION_ENDPOINT", "email.us-east-1.amazonaws.com") +# Inject plugin settings before the configuration file overrides (so it is possible to manage those settings via environment). +add_plugins(__name__, PROJECT_TYPE, SettingsType.PRODUCTION) + CONFIG_FILE = get_env_setting("CREDENTIALS_CFG") with open(CONFIG_FILE, encoding="utf-8") as f: config_from_yaml = yaml.safe_load(f) @@ -60,5 +63,3 @@ for override, value in DB_OVERRIDES.items(): DATABASES["default"][override] = value - -add_plugins(__name__, PROJECT_TYPE, SettingsType.PRODUCTION) diff --git a/credentials/settings/test.py b/credentials/settings/test.py index 730a5290c..3efd26246 100644 --- a/credentials/settings/test.py +++ b/credentials/settings/test.py @@ -83,3 +83,6 @@ } LEARNER_RECORD_MFE_RECORDS_PAGE_URL = "http://learner-record-mfe" +add_plugins(__name__, PROJECT_TYPE, SettingsType.TEST) + +BADGES_CONFIG["credly"]["USE_SANDBOX"] = True diff --git a/credentials/tests/test_utils.py b/credentials/tests/test_utils.py new file mode 100644 index 000000000..7d803cd7e --- /dev/null +++ b/credentials/tests/test_utils.py @@ -0,0 +1,159 @@ +import platform +import sys +import unittest +from logging.handlers import SysLogHandler +from os import path +from unittest.mock import patch + +from django.core.exceptions import ImproperlyConfigured + +from credentials.settings.utils import get_env_setting, get_logger_config, str2bool + + +class TestGetEnvSetting(unittest.TestCase): + @patch.dict("os.environ", {"TEST_SETTING": "test_value"}) + def test_get_env_setting_existing_setting(self): + result = get_env_setting("TEST_SETTING") + self.assertEqual(result, "test_value") + + @patch.dict("os.environ", {}) + def test_get_env_setting_missing_setting(self): + with self.assertRaises(ImproperlyConfigured): + get_env_setting("MISSING_SETTING") + + +class TestGetLoggerConfig(unittest.TestCase): + def test_default_config(self): + config = get_logger_config() + self.assertEqual(config["version"], 1) + self.assertFalse(config["disable_existing_loggers"]) + self.assertIn("standard", config["formatters"]) + self.assertIn("syslog_format", config["formatters"]) + self.assertIn("raw", config["formatters"]) + self.assertIn("console", config["handlers"]) + self.assertIn("django", config["loggers"]) + self.assertIn("requests", config["loggers"]) + self.assertIn("factory", config["loggers"]) + self.assertIn("django.request", config["loggers"]) + self.assertIn("", config["loggers"]) + + def test_dev_env_true(self): + config = get_logger_config(dev_env=True) + self.assertIn("local", config["handlers"]) + self.assertEqual(config["handlers"]["local"]["class"], "logging.handlers.RotatingFileHandler") + self.assertEqual(config["handlers"]["local"]["level"], "INFO") + self.assertEqual(config["handlers"]["local"]["formatter"], "standard") + self.assertEqual(config["handlers"]["local"]["filename"], path.join("/var/tmp", "edx.log")) + self.assertEqual(config["handlers"]["local"]["maxBytes"], 1024 * 1024 * 2) + self.assertEqual(config["handlers"]["local"]["backupCount"], 5) + + def test_dev_env_false(self): + config = get_logger_config(dev_env=False) + self.assertIn("local", config["handlers"]) + self.assertEqual(config["handlers"]["local"]["level"], "INFO") + self.assertEqual(config["handlers"]["local"]["class"], "logging.handlers.SysLogHandler") + self.assertEqual( + config["handlers"]["local"]["address"], "/var/run/syslog" if sys.platform == "darwin" else "/dev/log" + ) + self.assertEqual(config["handlers"]["local"]["formatter"], "syslog_format") + self.assertEqual(config["handlers"]["local"]["facility"], SysLogHandler.LOG_LOCAL0) + + def test_debug_true(self): + config = get_logger_config(debug=True) + self.assertEqual(config["handlers"]["console"]["level"], "DEBUG") + + def test_debug_false(self): + config = get_logger_config(debug=False) + self.assertEqual(config["handlers"]["console"]["level"], "INFO") + + def test_local_loglevel_invalid(self): + config = get_logger_config(local_loglevel="INVALID") + self.assertEqual(config["handlers"]["local"]["level"], "INFO") + + def test_local_loglevel_info(self): + config = get_logger_config(local_loglevel="INFO") + self.assertEqual(config["handlers"]["local"]["level"], "INFO") + + def test_local_loglevel_debug(self): + config = get_logger_config(local_loglevel="DEBUG") + self.assertEqual(config["handlers"]["local"]["level"], "DEBUG") + + def test_local_loglevel_warning(self): + config = get_logger_config(local_loglevel="WARNING") + self.assertEqual(config["handlers"]["local"]["level"], "WARNING") + + def test_local_loglevel_error(self): + config = get_logger_config(local_loglevel="ERROR") + self.assertEqual(config["handlers"]["local"]["level"], "ERROR") + + def test_local_loglevel_critical(self): + config = get_logger_config(local_loglevel="CRITICAL") + self.assertEqual(config["handlers"]["local"]["level"], "CRITICAL") + + def test_hostname(self): + config = get_logger_config() + hostname = platform.node().split(".")[0] + self.assertIn(hostname, config["formatters"]["syslog_format"]["format"]) + + def test_service_variant(self): + config = get_logger_config() + self.assertIn("service_variant=credentials", config["formatters"]["syslog_format"]["format"]) + + def test_logging_env(self): + config = get_logger_config() + self.assertIn("env:no_env", config["formatters"]["syslog_format"]["format"]) + + def test_edx_filename(self): + config = get_logger_config(dev_env=True) + self.assertIn("/var/tmp/edx.log", config["handlers"]["local"]["filename"]) + + def test_edx_filename_not_used(self): + config = get_logger_config(dev_env=False) + self.assertNotIn("filename", config["handlers"]["local"]) + + def test_invalid_platform(self): + original_platform = sys.platform + sys.platform = "invalid" + config = get_logger_config(dev_env=False) + self.assertEqual(config["handlers"]["local"]["address"], "/dev/log") + sys.platform = original_platform + + +class TestStr2Bool(unittest.TestCase): + def test_str2bool_true(self): + result = str2bool("true") + self.assertTrue(result) + + def test_str2bool_false(self): + result = str2bool("false") + self.assertFalse(result) + + def test_str2bool_yes(self): + result = str2bool("yes") + self.assertTrue(result) + + def test_str2bool_no(self): + result = str2bool("no") + self.assertFalse(result) + + def test_str2bool_t(self): + result = str2bool("t") + self.assertTrue(result) + + def test_str2bool_f(self): + result = str2bool("f") + self.assertFalse(result) + + def test_str2bool_1(self): + result = str2bool("1") + self.assertTrue(result) + + def test_str2bool_0(self): + result = str2bool("0") + self.assertFalse(result) + + def test_str2bool_case_insensitive(self): + result = str2bool("TrUe") + self.assertTrue(result) + result = str2bool("FaLsE") + self.assertFalse(result) diff --git a/credentials/urls.py b/credentials/urls.py index c68e740b9..2deef43f6 100644 --- a/credentials/urls.py +++ b/credentials/urls.py @@ -27,6 +27,7 @@ from edx_django_utils.plugins import get_plugin_url_patterns from rest_framework import permissions +from credentials.apps.badges.toggles import is_badges_enabled from credentials.apps.core import views as core_views from credentials.apps.plugins.constants import PROJECT_TYPE from credentials.apps.records.views import ProgramListingView @@ -78,6 +79,11 @@ ), ] +if is_badges_enabled(): + urlpatterns += [ + re_path(r"^badges/", include(("credentials.apps.badges.urls", "badges"), namespace="badges")), + ] + # edx-drf-extensions csrf app urlpatterns += [ path("", include("csrf.urls")), diff --git a/docs/_static/images/badges/badges-admin-credly-templates-list.png b/docs/_static/images/badges/badges-admin-credly-templates-list.png new file mode 100644 index 000000000..2cfbe04f1 Binary files /dev/null and b/docs/_static/images/badges/badges-admin-credly-templates-list.png differ diff --git a/docs/_static/images/badges/badges-admin-credly-templates-sync.png b/docs/_static/images/badges/badges-admin-credly-templates-sync.png new file mode 100644 index 000000000..a7d2eea74 Binary files /dev/null and b/docs/_static/images/badges/badges-admin-credly-templates-sync.png differ diff --git a/docs/_static/images/badges/badges-admin-data-rules.png b/docs/_static/images/badges/badges-admin-data-rules.png new file mode 100644 index 000000000..68f9570da Binary files /dev/null and b/docs/_static/images/badges/badges-admin-data-rules.png differ diff --git a/docs/_static/images/badges/badges-admin-penalty-rules.png b/docs/_static/images/badges/badges-admin-penalty-rules.png new file mode 100644 index 000000000..1b726a9bb Binary files /dev/null and b/docs/_static/images/badges/badges-admin-penalty-rules.png differ diff --git a/docs/_static/images/badges/badges-admin-progress-records.png b/docs/_static/images/badges/badges-admin-progress-records.png new file mode 100644 index 000000000..31754994f Binary files /dev/null and b/docs/_static/images/badges/badges-admin-progress-records.png differ diff --git a/docs/_static/images/badges/badges-admin-requirement-rules.png b/docs/_static/images/badges/badges-admin-requirement-rules.png new file mode 100644 index 000000000..41ee1f7cf Binary files /dev/null and b/docs/_static/images/badges/badges-admin-requirement-rules.png differ diff --git a/docs/_static/images/badges/badges-admin-rules-group.png b/docs/_static/images/badges/badges-admin-rules-group.png new file mode 100644 index 000000000..a6061824c Binary files /dev/null and b/docs/_static/images/badges/badges-admin-rules-group.png differ diff --git a/docs/_static/images/badges/badges-admin-template-details.png b/docs/_static/images/badges/badges-admin-template-details.png new file mode 100644 index 000000000..ff222b48d Binary files /dev/null and b/docs/_static/images/badges/badges-admin-template-details.png differ diff --git a/docs/_static/images/badges/badges-admin-template-requirements.png b/docs/_static/images/badges/badges-admin-template-requirements.png new file mode 100644 index 000000000..0568ecdd2 Binary files /dev/null and b/docs/_static/images/badges/badges-admin-template-requirements.png differ diff --git a/docs/_static/images/badges/badges-admin.png b/docs/_static/images/badges/badges-admin.png new file mode 100644 index 000000000..e05d8e9c5 Binary files /dev/null and b/docs/_static/images/badges/badges-admin.png differ diff --git a/docs/badges/configuration.rst b/docs/badges/configuration.rst new file mode 100644 index 000000000..04f30e1cc --- /dev/null +++ b/docs/badges/configuration.rst @@ -0,0 +1,195 @@ +Configuration +============= + +.. note:: + + This section provides information on how and where to set up badge templates and organizations. + +The Badges feature is configured in the Credentials admin panel. + +.. image:: ../_static/images/badges/badges-admin.png + :alt: Badges administration + +Credly Organizations +-------------------- + +Multiple Credly Organizations can be configured. + +**All communication between Open edX Credentials and Credly service happens on behalf of a Credly Organization.** + +Go to the Credly Organization section in the admin panel and create a new item: + +1. to set the UUID use your Credly Organization identifier; +2. to set the authorization token, used to sync the Credly Organization. + +Check: the system pulls the Organization's details and updates its name. + +In case of errors, check the credentials used for the Organization + +Badge templates +--------------- + +*Credly badge templates* (badge templates for short) are created in the Credly Organization's dashboard and then, if published, they are retrieved by the Credentials via API. + +Webhooks +~~~~~~~~~~~~~~~ + +.. note:: + + Webhooks is a connection with Credly and external platform that allows your server to be notified about events occuring within Credly. + +Webhooks are set up on Credly management dashboard as Credly is a main initiator of the syncronization. + +You should be able to select an action from the list so that whenever the specified action occurs internally, the external system is alerted. + +Without this synchronization, the external system will not be notified of any significant changes (e.g. a badge template update, or a badge template has been archived) and may incorrectly issue erroneous or outdated badges. + +Synchronization +~~~~~~~~~~~~~~~ + +To synchronize Credly badge templates for the Organization one should: + +- navigate "Credly badge templates" list page; +- select the Organization; +- use ``Sync organization badge templates`` action; + +.. image:: ../_static/images/badges/badges-admin-credly-templates-sync.png + :alt: Credly badge templates synchronization + +On success, the system will update the list of Credly badge templates for the Organization: + +- only badge templates with ``active`` state are pulled; +- Credly badge template records are created inactive (disabled); + +.. image:: ../_static/images/badges/badges-admin-credly-templates-list.png + :alt: Credly badge templates list + +For a badge template to be considered during the processing it must be configured (to have at least 1 requirement) and activated (enabled) first. + +Badge Requirements +------------------ + + Requirements describe **what** and **how** must happen on the system to earn a badge. + +Badge Requirement(s) specification is a crucial part of badge template configuration. +At least one badge requirement must be associated with a badge template. + +Badge Requirements are listed inline on a badge template detail page. + +.. image:: ../_static/images/badges/badges-admin-template-requirements.png + :alt: Credly badge template requirements + +A badge template can have multiple requirements. All badge requirements must be *fulfilled* before the system will issue a badge to a learner. + +Event type +~~~~~~~~~~ + + Describes **what is expected to happen**. + +Available event type subset is pre-configured in the application settings. + +.. note:: + + Technically, any public signal from the `openedx-events`_ library can be used for badge template requirements setup, if it includes user PII (UserData), so users can be identified. + +Rules +~~~~~ + +A list of configured data rules (if any), see "Data Rules". + +Description +~~~~~~~~~~~ + +**Description** is an optional human-readable reminder that describes what the requirement is about. + + Badge Requirement can be **deeper specified** via its Data Rules. + +Group +~~~~~ + +Optional configuration (by default each badge requirement is assigned a separate Group). + +Allows putting 2 or more badge requirements as a Group. +Requirements group is fulfilled if any of its requirements is fulfilled. + + "OR" logic is applied inside a Group. + +.. image:: ../_static/images/badges/badges-admin-rules-group.png + :alt: Badge requirement rules group + +See `configuration examples`_. + +Data Rules +---------- + + Describes **how it is expected to happen** + +Data Rules detail their parent Badge Requirement based on the expected event payload. + +To edit/update a Data Rule: + +- navigate to the Badge Requirement detail page (use ``Change`` inline link); +- find the "Data Rules" section and add a new item; + +.. image:: ../_static/images/badges/badges-admin-requirement-rules.png + :alt: Badge requirement rules edit + +**Each data rule describes a single expected payload value:** + +All key paths are generated based on the event type specified for the parent Badge Requirement. + +.. image:: ../_static/images/badges/badges-admin-data-rules.png + :alt: Badge requirement data rules + +1. **Key path** - payload path to the target attribute + - dot-separated string; + - each event type has its unique pre-defined set of key paths; +2. **Operator** - comparison operation to apply between expected and actual values; + - available operators: (payload) + - ``"="`` (equals); + - ``"!="`` (not equals); +3. **Expected value** - an expected value for the target attribute + - payload boolean positive values allowed: ``"true", "True", "yes", "Yes", "+"``; + - payload boolean negative values allowed: ``"false", "False", "no", "No", "-"``; + + +Please, see `configuration examples`_ for clarity. + +Badge Penalties +--------------- + + Penalties allow badge progress resetting based on user activity. + +Badge penalties are optional. +There could be 0 or more badge penalties configured for a badge template. + +Each badge penalty is *targeted* to 1 or more badge requirements. +A penalty setup is similar to a badge requirement, but has different effect: it decreases badge progress for a user. + +When all penalty rules have been applied, a learner's progress towards a badge is reset. + +.. image:: ../_static/images/badges/badges-admin-penalty-rules.png + :alt: Badge penalty rules edit + +Activation +---------- + +Configured badge template can be activated: + +- navigate to the badge template detail page; +- check ``Is active`` checkbox; + + Activated badge template starts "working" immediately. + +.. image:: ../_static/images/badges/badges-admin-template-details.png + :alt: Badge template data structure + +Credly badge template record includes: + +1. Core credential attributes; +2. Badge template credential attributes; +3. Credly service attributes (state, dashboard link); +4. Configured requirements; + +.. _`configuration examples`: examples.html +.. _openedx-events: https://github.com/openedx/openedx-events \ No newline at end of file diff --git a/docs/badges/examples.rst b/docs/badges/examples.rst new file mode 100644 index 000000000..7a526cac8 --- /dev/null +++ b/docs/badges/examples.rst @@ -0,0 +1,257 @@ +Configuration examples +====================== + +These examples will show how to configure requirements and ``data rules`` for necessary use cases. + +.. note:: + + Any of the following examples can be combined for more specific use cases. + + +Implemented use cases +---------------------- + + +1. ANY COURSE GRADE update +~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Not that useful. Any interaction (e.g. submit button click) with gradable block in any course leads to a badge. + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.course.passing.status.updated.v1`` + b. description: ``On any grade update.`` + + +2. ANY COURSE completion +~~~~~~~~~~~~~~~~~~~~~~~~ + + Requires **passing grade** for any course. Once course'd grade becomes "passing" after gradable problem submission, + a badge is awarded. + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.course.passing.status.updated.v1`` + b. description: ``On any (not CCX) course completion.`` + c. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + + +3. ANY CCX course completion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Requires **passing grade** for any CCX course. + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.ccx.course.passing.status.updated.v1`` + b. description: ``On any CCX course completion.`` + c. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + + +4. ANY COURSE completion EXCEPT a specific course +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Requires **passing grade** for any course **excluding** the "Demo" course. + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.course.passing.status.updated.v1`` + b. description: ``On any course completion, but not the "Demo" course.`` + c. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + d. **Data rule 2**: + i. key path: ``course.course_key`` + ii. operator: ``not equals`` + iii. value: ``course-v1:edX+DemoX+Demo_Course`` + + +5. SPECIFIC COURSE completion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Requires **passing grade** for exact course ("Demo" course). + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.course.passing.status.updated.v1`` + b. description: ``On the Demo course completion.`` + c. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + d. **Data rule 2**: + i. key path: ``course.course_key`` + ii. operator: ``equals`` + iii. value: ``course-v1:edX+DemoX+Demo_Course`` + + +6. MULTIPLE SPECIFIC COURSES completion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + **All** specified courses must be completed. + Different requirement groups force each requirement to be fulfilled. + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.course.passing.status.updated.v1`` + b. description: ``On the "Demo" course completion.`` + c. group: ``A`` + d. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + e. **Data rule 2**: + i. key path: ``course.course_key`` + ii. operator: ``equals`` + iii. value: ``course-v1:edX+DemoX+Demo_Course`` + +2. **Requirement 2**: + a. event type: ``org.openedx.learning.course.passing.status.updated.v1`` + b. description: ``On the "Other" course completion.`` + c. group: ``B`` + d. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + e. **Data rule 2**: + i. key path: ``course.course_key`` + ii. operator: ``equals`` + iii. value: ``course-v1:edX+DemoX+OTHER_Course`` + + +7. SPECIFIC CCX course completion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Requires **passing grade** for exact CCX course ("Demo CCX1" course). + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.ccx.course.passing.status.updated.v1`` + b. description: ``On the Demo CCX1 course completion.`` + c. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + d. **Data rule 2**: + i. key path: ``course.ccx_course_key`` + ii. operator: ``equals`` + iii. value: ``ccx-v1:edX+DemoX+Demo_Course+ccx@1`` + +8. ANY CCX course completion ON a SPECIFIC MASTER course +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Requires **passing grade** for any "child" CCX course that based on the master "Demo" course. + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.ccx.course.passing.status.updated.v1`` + b. description: ``On any Demo CCX course completion.`` + c. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + d. **Data rule 2**: + i. key path: ``course.master_course_key`` + ii. operator: ``equals`` + iii. value: ``course-v1:edX+DemoX+Demo_Course`` + +9. ANY CCX course completion ON a SPECIFIC MASTER course EXCEPT a SPECIFIC CCX course +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Complicated. + Requires **passing grade** for **any "child" CCX course** that based on the master "Demo" course, **excluding** the "Demo CCX2" course. + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.ccx.course.passing.status.updated.v1`` + b. description: ``On any Demo CCX course completion.`` + c. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + d. **Data rule 2**: + i. key path: ``course.master_course_key`` + ii. operator: ``equals`` + iii. value: ``course-v1:edX+DemoX+Demo_Course`` + e. **Data rule 3**: + i. key path: ``course.ccx_course_key`` + ii. operator: ``not equals`` + iii. value: ``ccx-v1:edX+DemoX+Demo_Course+ccx@2`` + +10. ONE OF MULTIPLE SPECIFIC COURSES completion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + At least a single from the specified courses must be completed. + Grouped requirements are processed as **"ANY FROM A GROUP"**. + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.course.passing.status.updated.v1`` + b. description: ``On the "Demo" course completion.`` + c. group: ``A`` + d. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + e. **Data rule 2**: + i. key path: ``course.course_key`` + ii. operator: ``equals`` + iii. value: ``course-v1:edX+DemoX+Demo_Course`` + +2. **Requirement 2**: + a. event type: ``org.openedx.learning.course.passing.status.updated.v1`` + b. description: ``On the "Other" course completion.`` + c. group: ``A`` + d. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + e. **Data rule 2**: + i. key path: ``course.course_key`` + ii. operator: ``equals`` + iii. value: ``course-v1:edX+DemoX+OTHER_Course`` + + +11. SPECIFIC MASTER course OR ANY of its CCX courses EXCEPT a SPECIFIC CCX course completion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Here requirements 1 and 2 are grouped, so any of them lead to a badge. + +1. **Requirement 1**: + a. event type: ``org.openedx.learning.course.passing.status.updated.v1`` + b. description: ``On the "Demo" course completion OR...`` + c. group: ``A`` + d. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + e. **Data rule 2**: + i. key path: ``course.course_key`` + ii. operator: ``equals`` + iii. value: ``course-v1:edX+DemoX+Demo_Course`` + +2. **Requirement 2**: + a. event type: ``org.openedx.learning.ccx.course.passing.status.updated.v1`` + b. description: ``...OR any Demo CCX courses completion EXCLUDING CCX3.`` + c. group: ``A`` + d. **Data rule 1**: + i. key path: ``is_passing`` + ii. operator: ``equals`` + iii. value: ``true`` + e. **Data rule 2**: + i. key path: ``course.master_course_key`` + ii. operator: ``equals`` + iii. value: ``course-v1:edX+DemoX+Demo_Course`` + f. **Data rule 3**: + i. key path: ``course.ccx_course_key`` + ii. operator: ``not equals`` + iii. value: ``ccx-v1:edX+DemoX+Demo_Course+ccx@3`` + +----- + +Future work +----------- + +1. Events set extension (e.g. "Email activation", "Profile data completion", "Course section completion", ...); +2. Repetitive events (e.g. "5 arbitrary courses completion"); +3. Prerequisite events (e.g. "5 specific courses completion in a specified order"); +4. Time-ranged event (e.g. "Arbitrary course completion during the February 2022"); +5. Badge dependencies (e.g. "Badge A + Badge B = Badge C"); +6. Multiple times same badge earning (e.g. "3 arbitrary course completions make badge earned x3"); diff --git a/docs/badges/index.rst b/docs/badges/index.rst new file mode 100644 index 000000000..eea58b2f0 --- /dev/null +++ b/docs/badges/index.rst @@ -0,0 +1,37 @@ +Badges +====== + + The Badges feature allows learners to earn achievements (badges) for their learning activities. + +- **Badge Template** is another kind of **credential**. +- **Badge** is another kind of **user credential**. + +Current Badges version is highly integrated with the `Credly (by Pearson)`_ service, but it is fully prepared to be used separately. + + What is Credly? + + **Credly** is a end-to-end solution for creating, issuing, and managing digital Credentials. Organizations use **Credly** to recognize their learners' achievements. + Users use Credly as a professional board to store badges to visualize their professional success – which courses were completed and when. Badges provide employers and peers concrete evidence of what you had to do to earn your credential and what you're now capable of. Credly also offers labor market insights based on users’ skills. Users can search and apply for job opportunities right through Credly. + + +Glossary +-------- + +1. **Badge template** – a template of a badge (with design, name, and description) that will be used in settings to set up special rules to create a badge for users to receive on the platform. + +2. **Authorization token** – It's a temporary key that verifies identity and authorizes resource access. A token can be computer-generated or hardware-based. A valid token allows a user to retain access to an online service or web application until the token expires. + +3. **UUID** – Universally Unique Identifier – is a value used to identify an object or entity on the internet uniquely. Depending on the specific mechanisms used, a UUID is either guaranteed to be different or is, at least, extremely likely to be different from any other UUID generated. + +---- + +.. toctree:: + :maxdepth: 1 + + quickstart + settings + configuration + examples + processing + +.. _Credly (by Pearson): https://info.credly.com/ \ No newline at end of file diff --git a/docs/badges/processing.rst b/docs/badges/processing.rst new file mode 100644 index 000000000..497bbfaa2 --- /dev/null +++ b/docs/badges/processing.rst @@ -0,0 +1,102 @@ +Processing +========== + +Incoming events async processing happens in a separate event bus consumer process(es). +See the Event Bus documentation for details. + + +Events subscription +------------------- + +.. note:: + + Only explicitly configured `event types`_ take part in the processing. + +See Badges `default settings`_ for the default set of supported events. +Though, it is expected that any public signal from the `openedx-events`_ library can extend this set with a single requirement: its payload includes a learner PII (UserData object). + + +Learner identification +---------------------- + +.. note:: + + Each incoming event must be associated with a specific learner. + +The system tries to identify a learner by the `UserData` object in the event payload. +If the learner is not found, the event is ignored. + +The system also ensures that such learner exists in the Credentials (creates if needed). + + +Requirements analysis +--------------------- + +Since any requirement is associated with a single event type, all relevant requirements are collected for the incoming signal: + +1. appropriate event type; +2. active badge templates; + +Each requirement's rules are checked against the event payload. +Requirement processing is dropped as soon as the system recognizes not applying rules. + + +Progress update +--------------- + +Current learners' badge progress is stored in the ``Badge Progress`` record. + +.. note:: + + Since badge templates can have more than one requirement, the system should track intermediate progresses as well. + +Once all rules of the processed requirement apply, the system: + +1. ensures there is the badge progress record for the learner; +2. marks the requirement as fulfilled for the learner; + +.. image:: ../_static/images/badges/badges-admin-progress-records.png + :alt: Badge progress records + +If a Badge Progress is recognized as completed (all requirements for the badge template are fulfilled), the system initiates the awarding process. + + +Badge awarding +-------------- + +.. note:: + + Once all requirements for the badge template are fulfilled, the system should award the badge. + +On badge progress completion, the system: + +1. creates an *internal* user credential record for the learner; +2. notifies (public signal) about new badge awarded; +3. tries to issue an *external* Credly badge for the learner; + +.. note:: + + The Badges application implements its extended ``UserCredential`` version (the CredlyBadge) to track external issuing state. Once the Credly badge is successfully issued the **CredlyBadge is updated with its UUID and state**. + +.. _event types: https://docs.openedx.org/projects/openedx-events/en/latest/ +.. _openedx-events: https://github.com/openedx/openedx-events +.. _default settings: settings.html#default-settings + +Badge revocation +---------------- + +Badges can also be revoked. Its a separete set of rules that need to be set up. + +1. Go to Badge Penalties section in admin panel (admin/badges/badge_pentalties). + +.. image:: ../_static/images/badges/badges-admin-penalty-rules.png + :alt: Badge penalties + +2. Select a certain requirement that was previously set up to link penalty + a. To know how to set up badge template requirements, go to the `Configuration`_ section. + +3. Note that all penalties have to be linked to a certain requirement, so that when that requirement is not fulfilled, system would know when to revoke the badge. + +.. _Configuration: configuration.html + +When a learner's badge is revoked by Credly, the Credentials IDA will be notified and will update it's internal records. The status of the badge will change from `awarded` to `revoked` upon successful revocation. \ No newline at end of file diff --git a/docs/badges/quickstart.rst b/docs/badges/quickstart.rst new file mode 100644 index 000000000..dfbac1752 --- /dev/null +++ b/docs/badges/quickstart.rst @@ -0,0 +1,153 @@ +Quick Start +=========== + +.. note:: + + This section includes brief information about the feature – what to start with; where to set up credentials, etc. + +0. Prerequisites – Credly account +--------------------------------- + +To start using this feature a Credly account is necessary. + +1. Register on Credly and create your account. +2. Create Organization in Credly. +3. Create at least 1 badge template and activate it. +4. Credly Organization + +1. Enable feature +----------------- + +Badges feature is optional and it is disabled by default. So, it must be enabled to be accessible. + +.. code-block:: + + # LMS service: + FEATURES["BADGES_ENABLED"] = True + + # Credentials service: + BADGES_ENABLED = True + +2. Configure Credly integration +------------------------------- + +.. note:: + + For detailed information, go to the `Configuration`_ section. + +Go to the Credentials service admin panel and configure the integration with the Credly service: + +1. In the admin panel go to /admin/badges/credly_organization to add Credly Organization. + a. Add UUID (unique identifier) for the Credly organization + b. Add the authorization token of the Credly organization. + +Please note that UUID and authorization token will be given to you during the creation of the Credly Organization on the Credly side + +Check: the system pulls the Organization's data and updates its name. + +.. _Configuration: configuration.html + + +3. Synchronize badge templates +------------------------------ + Note: For detailed information, go to the `Configuration`_ section. + +From the “Credly Organizations” list, select the Organization(s) you want to use and select ``Sync organization badge templates`` action. + +The system pulls the list of badge templates from the Credly Organization. Navigate to the “Credly badge templates” list and check newly created templates. + +.. _Configuration: configuration.html + +4. Setup badge requirements +--------------------------- + +.. note:: + + Requirements describe **what** and **how** must happen on the system to earn a badge. + +The crucial part of the badge template configuration is the requirements specification. At least one requirement must be associated with a badge template. + +Go to the first badge template details page (admin/badges/credly_badge_templates) and add requirements for it: + +1. find the “Badge Requirements” section; +2. add a new item and select an event type (what is expected to happen); + a. optionally, put a description; +3. save and navigate to the Requirement details (Change link); + a. optionally, specify data rules in the “Data Rules” section (how exactly it is expected to happen); +4. add a new item and describe the rule; +5. select a key path - specific data element; +6. select an operator - how to compare the value; +7. enter a value - expected parameter’s value. + +.. note:: + + A configuration for the badge template that must be issued on a specific course completion looks as following: + + - Requirement 1: + - event type: ``org.openedx.learning.course.passing.status.updated.v1`` + - description: ``On the Demo course completion.`` + - Data rule 1: + - key path: ``course.course_key`` + - operator: ``equals`` + - value: ``course-v1:edX+DemoX+Demo_Course`` + - Data rule 2: + - key path: ``is_passing`` + - operator: ``equals`` + - value: ``true`` + +It is possible to put more than one requirement in a badge template. + +5. Activate configured badge templates +-------------------------------------- + + To active a badge template check the ``is active`` checkbox on its edit page. + +Once badge requirements are set up, it should be “enabled” to start “working”. + +Once enabled, the badge template will be active and ready. + +.. warning:: + + Configuration updates for active badge templates are discouraged since they may cause learners’ inconsistent experience. + +6. See users Badge Progress +--------------------------- + +Current badge progress can be seen in the “Badge progress records” section in the Credentials admin panel. + +Since badge templates can have more than one requirement, there can be partially completed badges. + +7. See awarded user credentials +------------------------------- + +Already earned badges are listed in the "Credly badges" section of the admin panel. + +.. note:: + + The Credly Badge is an extended version of a user credential record. + +Once badge progress is complete (all requirements were *fulfilled*), the system: + +1. creates internal user credentials (CredlyBadge); +2. notifies about the badge awarding (public signal); +3. requests Credly service to issue the badge (API request). + +8. See issued Credly badges +--------------------------- + +Earned internal badges (user credentials) spread to the Credly service. + +On a successful Credly badge issuing, the CredlyBadge user credential is updated with its requisites: + +1. external UUID; +2. external state; + +The Credly badge is visible in the Credly service. + + +9. Badge template withdrawal +---------------------------- + +Badge template can be deactivated by putting it in the inactive state (``is active`` checkbox). + +Inactive badge templates are ignored during the processing. diff --git a/docs/badges/settings.rst b/docs/badges/settings.rst new file mode 100644 index 000000000..093dd8ad4 --- /dev/null +++ b/docs/badges/settings.rst @@ -0,0 +1,179 @@ +Settings +======== + +.. note:: + + You can find technical details on how to set up proper configurations for badges to be active in this section. + +Badges feature settings allow configuration: + +1. feature availability; +2. event bus public signals subset for badges; +3. the Credly service integration details (URLs, sandbox usage, etc.); + + +Feature switch +-------------- + +The Badges feature is under a feature switch (disabled by default). + +To enable the feature, update these settings as follows: + +.. code-block:: python + + # Platform services settings: + FEATURES["BADGES_ENABLED"] = True + + # Credentials service settings: + BADGES_ENABLED = True + + +Default settings +---------------- + +The feature has its configuration: + +.. code-block:: python + + # Credentials settings: + BADGES_CONFIG = { + # these events become available in requirements/penalties setup: + "events": [ + "org.openedx.learning.course.passing.status.updated.v1", + "org.openedx.learning.ccx.course.passing.status.updated.v1", + ], + # Credly integration: + "credly": { + "CREDLY_BASE_URL": "https://credly.com/", + "CREDLY_API_BASE_URL": "https://api.credly.com/v1/", + "CREDLY_SANDBOX_BASE_URL": "https://sandbox.credly.com/", + "CREDLY_SANDBOX_API_BASE_URL": "https://sandbox-api.credly.com/v1/", + "USE_SANDBOX": False, + }, + # requirements data rules: + "rules": { + "ignored_keypaths": [ + "user.id", + "user.is_active", + "user.pii.username", + "user.pii.email", + "user.pii.name", + ], + }, + } + +- ``events`` - explicit event bus signals list (only events with PII user data in payload are applicable). +- ``credly`` - Credly integration details. +- ``rules.ignored_keypaths`` - event payload paths to exclude from data rule options (see: Configuration_). + +Credly integration +~~~~~~~~~~~~~~~~~~ + +- USE_SANDBOX - enables Credly sandbox usage (development, testing); +- CREDLY_BASE_URL - Credly service host URL; +- CREDLY_API_BASE_URL - Credly API host URL; +- CREDLY_SANDBOX_BASE_URL - Credly sandbox host URL; +- CREDLY_SANDBOX_API_BASE_URL - Credly sandbox API host URL; + + +Event bus settings +------------------ + + ``learning-badges-lifecycle`` is the event bus topic for all Badges related events. + +The Badges feature has updated event bus producer configurations for the Platform and the Credentials services. + +Source public signals +~~~~~~~~~~~~~~~~~~~~~ + +Platform's event bus producer configuration was extended with 2 public signals: + +1. information about the fact someone’s course grade was updated (allows course completion recognition); +2. information about the fact someone’s CCX course grade was updated (allows CCX course completion recognition). + +.. code-block:: python + + # Platform services settings: + EVENT_BUS_PRODUCER_CONFIG = { + ... + + "org.openedx.learning.course.passing.status.updated.v1": { + "learning-badges-lifecycle": { + "event_key_field": "course_passing_status.course.course_key", + "enabled": _should_send_learning_badge_events, + }, + }, + "org.openedx.learning.ccx.course.passing.status.updated.v1": { + "learning-badges-lifecycle": { + "event_key_field": "course_passing_status.course.course_key", + "enabled": _should_send_learning_badge_events, + }, + }, + } + +Emitted public signals +~~~~~~~~~~~~~~~~~~~~~~ + +The Badges feature introduced 2 own event types: + +1. information about the fact someone has earned a badge; +2. information about the fact someone's badge was revoked; + +.. code-block:: python + + # Credentials service settings: + EVENT_BUS_PRODUCER_CONFIG = { + ... + + "org.openedx.learning.badge.awarded.v1": { + "learning-badges-lifecycle": {"event_key_field": "badge.uuid", "enabled": True }, + }, + "org.openedx.learning.badge.revoked.v1": { + "learning-badges-lifecycle": {"event_key_field": "badge.uuid", "enabled": True }, + }, + } + +Consuming workers +~~~~~~~~~~~~~~~~~ + +.. note:: + + Consumers implementation depends on the used event bus. + +Event bus options: + +- Redis Streams +- Kafka +- ... + +The Credentials and the Platform services **produce** (push) their public signals as messages to the stream. + +To **consume** (pull) those messages a consumer process is required. + +Redis Streams +############# + +When the Redis Streams event bus is used, the ``-learning-badges-lifecycle`` stream is used for messages transport. + +For producing and consuming a single package (broker) is used - event-bus-redis_. + +"Event Bus Redis" is implemented as a Django application and provides a Django management command for consuming messages +(see all details in the package's README). + +.. code-block:: bash + + # Credentials service consumer example: + /edx/app/credentials/credentials/manage.py consume_events -t learning-badges-lifecycle -g credentials_dev --extra={"consumer_name":"credentials_dev.consumer1"} + + # LMS service consumer example: + /edx/app/edxapp/edx-platform/manage.py lms consume_events -t learning-badges-lifecycle -g lms_dev --extra={"consumer_name":"lms_dev.consumer1"} + +.. note:: + + **Credentials event bus consumer** is crucial for the Badges feature, since it is responsible for all incoming events processing. + + **LMS event bus consumer** is only required if LMS wants to receive information about badges processing results (awarding/revocation). + + +.. _Configuration: configuration.html +.. _event-bus-redis: https://github.com/openedx/event-bus-redis \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index f1054682b..b987b1aed 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,4 +28,4 @@ This repository contains the edX Credentials Service, used as the backend to sup lms_user_id program_completion_emails decisions - + badges/index diff --git a/requirements/all.txt b/requirements/all.txt index ec4ed3f14..96dbf6a3a 100644 --- a/requirements/all.txt +++ b/requirements/all.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -16,6 +16,11 @@ astroid==3.2.3 # -r requirements/dev.txt # pylint # pylint-celery +async-timeout==4.0.3 + # via + # -r requirements/dev.txt + # -r requirements/production.txt + # redis attrs==23.2.0 # via # -r requirements/dev.txt @@ -27,17 +32,24 @@ backoff==2.2.1 # -r requirements/dev.txt # -r requirements/production.txt # segment-analytics-python +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # -r requirements/dev.txt + # -r requirements/production.txt + # django + # djangorestframework black==24.4.2 # via -r requirements/dev.txt bleach==6.1.0 # via # -r requirements/dev.txt # -r requirements/production.txt -boto3==1.34.144 +boto3==1.34.145 # via # -r requirements/production.txt # django-ses -botocore==1.34.144 +botocore==1.34.145 # via # -r requirements/production.txt # boto3 @@ -139,6 +151,7 @@ django==4.2.14 # django-debug-toolbar # django-extensions # django-filter + # django-model-utils # django-ses # django-simple-history # django-statici18n @@ -157,6 +170,7 @@ django==4.2.14 # edx-django-utils # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-i18n-tools # edx-toggles # openedx-events @@ -187,6 +201,10 @@ django-filter==24.2 # via # -r requirements/dev.txt # -r requirements/production.txt +django-model-utils==4.5.1 + # via + # -r requirements/dev.txt + # -r requirements/production.txt django-ratelimit==4.1.0 # via # -r requirements/dev.txt @@ -284,6 +302,7 @@ edx-django-utils==5.14.2 # edx-ace # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-rest-api-client # edx-toggles # openedx-events @@ -295,6 +314,10 @@ edx-event-bus-kafka==5.7.0 # via # -r requirements/dev.txt # -r requirements/production.txt +edx-event-bus-redis @ git+https://github.com/openedx/event-bus-redis.git@v0.5.0 + # via + # -r requirements/dev.txt + # -r requirements/production.txt edx-i18n-tools==1.6.0 # via # -r requirements/dev.txt @@ -318,6 +341,11 @@ edx-toggles==5.2.0 # -r requirements/dev.txt # -r requirements/production.txt # edx-event-bus-kafka + # edx-event-bus-redis +exceptiongroup==1.2.2 + # via + # -r requirements/dev.txt + # pytest factory-boy==3.3.0 # via -r requirements/dev.txt faker==26.0.0 @@ -334,7 +362,7 @@ filelock==3.15.4 # -r requirements/dev.txt # tox # virtualenv -fontawesomefree==6.5.1 +fontawesomefree==6.6.0 # via # -r requirements/dev.txt # -r requirements/production.txt @@ -353,6 +381,12 @@ idna==3.7 # -r requirements/dev.txt # -r requirements/production.txt # requests +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # -r requirements/dev.txt + # -r requirements/production.txt + # markdown inflection==0.5.1 # via # -r requirements/dev.txt @@ -440,6 +474,7 @@ openedx-events==9.11.0 # -r requirements/dev.txt # -r requirements/production.txt # edx-event-bus-kafka + # edx-event-bus-redis packaging==24.1 # via # -r requirements/dev.txt @@ -586,8 +621,6 @@ pytz==2024.1 # -r requirements/production.txt # django-ses # drf-yasg -pywatchman==2.0.0 ; "linux" in sys_platform - # via -r requirements/dev.txt pyyaml==6.0.1 # via # -r requirements/dev.txt @@ -601,6 +634,11 @@ qrcode==7.4.2 # via # -r requirements/dev.txt # -r requirements/production.txt +redis==5.0.7 + # via + # -r requirements/dev.txt + # -r requirements/production.txt + # walrus requests==2.32.3 # via # -r requirements/dev.txt @@ -693,6 +731,16 @@ text-unidecode==1.3 # -r requirements/dev.txt # -r requirements/production.txt # python-slugify +tomli==2.0.1 + # via + # -r requirements/dev.txt + # black + # django-stubs + # mypy + # pylint + # pyproject-api + # pytest + # tox tomlkit==0.13.0 # via # -r requirements/dev.txt @@ -707,10 +755,14 @@ typing-extensions==4.12.2 # via # -r requirements/dev.txt # -r requirements/production.txt + # asgiref + # astroid + # black # django-stubs # django-stubs-ext # edx-opaque-keys # mypy + # pylint # qrcode uritemplate==4.1.1 # via @@ -730,6 +782,11 @@ virtualenv==20.26.3 # via # -r requirements/dev.txt # tox +walrus==0.9.4 + # via + # -r requirements/dev.txt + # -r requirements/production.txt + # edx-event-bus-redis webencodings==0.5.1 # via # -r requirements/dev.txt @@ -739,6 +796,11 @@ xss-utils==0.6.0 # via # -r requirements/dev.txt # -r requirements/production.txt +zipp==3.19.2 + # via + # -r requirements/dev.txt + # -r requirements/production.txt + # importlib-metadata zope-event==5.0 # via # -r requirements/production.txt diff --git a/requirements/base.in b/requirements/base.in index fd320c87b..8f14189f8 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -16,6 +16,7 @@ django django-cors-headers django-extensions django-filter +django-model-utils django-ratelimit django-rest-swagger django-simple-history @@ -41,6 +42,7 @@ markdown mysqlclient newrelic openedx-atlas +openedx-events pillow pygments python-memcached @@ -52,6 +54,6 @@ segment-analytics-python social-auth-app-django xss-utils - # TODO Install in configuration +git+https://github.com/openedx/event-bus-redis.git@v0.5.0 git+https://github.com/openedx/credentials-themes.git@0.4.17#egg=edx_credentials_themes==0.4.17 diff --git a/requirements/base.txt b/requirements/base.txt index 836544e4a..18cb577a9 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -8,12 +8,19 @@ asgiref==3.8.1 # via # django # django-cors-headers +async-timeout==4.0.3 + # via redis attrs==23.2.0 # via # edx-ace # openedx-events backoff==2.2.1 # via segment-analytics-python +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # django + # djangorestframework bleach==6.1.0 # via -r requirements/base.in certifi==2024.7.4 @@ -56,6 +63,7 @@ django==4.2.14 # django-crum # django-extensions # django-filter + # django-model-utils # django-simple-history # django-statici18n # django-storages @@ -71,6 +79,7 @@ django==4.2.14 # edx-django-utils # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-i18n-tools # edx-toggles # openedx-events @@ -88,6 +97,8 @@ django-extensions==3.2.3 # via -r requirements/base.in django-filter==24.2 # via -r requirements/base.in +django-model-utils==4.5.1 + # via -r requirements/base.in django-ratelimit==4.1.0 # via -r requirements/base.in django-rest-swagger==2.2.0 @@ -139,6 +150,7 @@ edx-django-utils==5.14.2 # edx-ace # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-rest-api-client # edx-toggles # openedx-events @@ -146,6 +158,8 @@ edx-drf-extensions==10.3.0 # via -r requirements/base.in edx-event-bus-kafka==5.7.0 # via -r requirements/base.in +edx-event-bus-redis @ git+https://github.com/openedx/event-bus-redis.git@v0.5.0 + # via -r requirements/base.in edx-i18n-tools==1.6.0 # via edx-credentials-themes edx-opaque-keys[django]==2.10.0 @@ -160,12 +174,17 @@ edx-toggles==5.2.0 # via # -r requirements/base.in # edx-event-bus-kafka + # edx-event-bus-redis fastavro==1.9.5 # via openedx-events -fontawesomefree==6.5.1 +fontawesomefree==6.6.0 # via -r requirements/base.in idna==3.7 # via requests +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # markdown inflection==0.5.1 # via drf-yasg itypes==1.2.0 @@ -198,7 +217,10 @@ openapi-codec==1.3.2 openedx-atlas==0.6.1 # via -r requirements/base.in openedx-events==9.11.0 - # via edx-event-bus-kafka + # via + # -r requirements/base.in + # edx-event-bus-kafka + # edx-event-bus-redis packaging==24.1 # via drf-yasg path==16.14.0 @@ -253,6 +275,8 @@ pyyaml==6.0.1 # edx-i18n-tools qrcode==7.4.2 # via -r requirements/base.in +redis==5.0.7 + # via walrus requests==2.32.3 # via # -r requirements/base.in @@ -306,6 +330,7 @@ text-unidecode==1.3 # via python-slugify typing-extensions==4.12.2 # via + # asgiref # edx-opaque-keys # qrcode uritemplate==4.1.1 @@ -316,7 +341,11 @@ urllib3==1.26.19 # via # -c requirements/constraints.txt # requests +walrus==0.9.4 + # via edx-event-bus-redis webencodings==0.5.1 # via bleach xss-utils==0.6.0 # via -r requirements/base.in +zipp==3.19.2 + # via importlib-metadata diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 397652e03..77753fcd2 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -1,8 +1,4 @@ -# This is a temporary solution to override the real common_constraints.txt -# In edx-lint, until the pyjwt constraint in edx-lint has been removed. -# See BOM-2721 for more details. -# Below is the copied and edited version of common_constraints - +# This is a temporary solution to override the real common_constraints.txt\n# In edx-lint, until the pyjwt constraint in edx-lint has been removed.\n# See BOM-2721 for more details.\n# Below is the copied and edited version of common_constraints\n # A central location for most common version constraints # (across edx repos) for pip-installation. # @@ -22,6 +18,7 @@ Django<5.0 # elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process. # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html +# See https://github.com/openedx/edx-platform/issues/35126 for more info elasticsearch<7.14.0 # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected @@ -34,3 +31,10 @@ elasticsearch<7.14.0 # So we need to pin it globally, for now. # Ticket for unpinning: https://github.com/openedx/edx-lint/issues/407 importlib-metadata<7 + +# Cause: https://github.com/openedx/event-tracking/pull/290 +# event-tracking 2.4.1 upgrades to pymongo 4.4.0 which is not supported on edx-platform. +# We will pin event-tracking to do not break existing installations +# This can be unpinned once https://github.com/openedx/edx-platform/issues/34586 +# has been resolved and edx-platform is running with pymongo>=4.4.0 +event-tracking<2.4.1 diff --git a/requirements/dev.txt b/requirements/dev.txt index f97ee2205..29646a6bb 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -15,6 +15,10 @@ astroid==3.2.3 # -r requirements/test.txt # pylint # pylint-celery +async-timeout==4.0.3 + # via + # -r requirements/test.txt + # redis attrs==23.2.0 # via # -r requirements/test.txt @@ -24,6 +28,12 @@ backoff==2.2.1 # via # -r requirements/test.txt # segment-analytics-python +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # -r requirements/test.txt + # django + # djangorestframework black==24.4.2 # via -r requirements/test.txt bleach==6.1.0 @@ -113,6 +123,7 @@ django==4.2.14 # django-debug-toolbar # django-extensions # django-filter + # django-model-utils # django-simple-history # django-statici18n # django-storages @@ -130,6 +141,7 @@ django==4.2.14 # edx-django-utils # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-i18n-tools # edx-toggles # openedx-events @@ -152,6 +164,8 @@ django-extensions==3.2.3 # via -r requirements/test.txt django-filter==24.2 # via -r requirements/test.txt +django-model-utils==4.5.1 + # via -r requirements/test.txt django-ratelimit==4.1.0 # via -r requirements/test.txt django-rest-swagger==2.2.0 @@ -213,6 +227,7 @@ edx-django-utils==5.14.2 # edx-ace # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-rest-api-client # edx-toggles # openedx-events @@ -220,6 +235,8 @@ edx-drf-extensions==10.3.0 # via -r requirements/test.txt edx-event-bus-kafka==5.7.0 # via -r requirements/test.txt +edx-event-bus-redis @ git+https://github.com/openedx/event-bus-redis.git@v0.5.0 + # via -r requirements/test.txt edx-i18n-tools==1.6.0 # via # -r requirements/dev.in @@ -239,6 +256,11 @@ edx-toggles==5.2.0 # via # -r requirements/test.txt # edx-event-bus-kafka + # edx-event-bus-redis +exceptiongroup==1.2.2 + # via + # -r requirements/test.txt + # pytest factory-boy==3.3.0 # via -r requirements/test.txt faker==26.0.0 @@ -254,7 +276,7 @@ filelock==3.15.4 # -r requirements/test.txt # tox # virtualenv -fontawesomefree==6.5.1 +fontawesomefree==6.6.0 # via -r requirements/test.txt httpretty==1.1.4 # via -r requirements/test.txt @@ -262,6 +284,11 @@ idna==3.7 # via # -r requirements/test.txt # requests +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # -r requirements/test.txt + # markdown inflection==0.5.1 # via # -r requirements/test.txt @@ -327,6 +354,7 @@ openedx-events==9.11.0 # via # -r requirements/test.txt # edx-event-bus-kafka + # edx-event-bus-redis packaging==24.1 # via # -r requirements/test.txt @@ -448,8 +476,6 @@ pytz==2024.1 # via # -r requirements/test.txt # drf-yasg -pywatchman==2.0.0 ; "linux" in sys_platform - # via -r requirements/dev.in pyyaml==6.0.1 # via # -r requirements/test.txt @@ -460,6 +486,10 @@ pyyaml==6.0.1 # responses qrcode==7.4.2 # via -r requirements/test.txt +redis==5.0.7 + # via + # -r requirements/test.txt + # walrus requests==2.32.3 # via # -r requirements/test.txt @@ -534,6 +564,16 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify +tomli==2.0.1 + # via + # -r requirements/test.txt + # black + # django-stubs + # mypy + # pylint + # pyproject-api + # pytest + # tox tomlkit==0.13.0 # via # -r requirements/test.txt @@ -545,10 +585,14 @@ types-pyyaml==6.0.12.20240311 typing-extensions==4.12.2 # via # -r requirements/test.txt + # asgiref + # astroid + # black # django-stubs # django-stubs-ext # edx-opaque-keys # mypy + # pylint # qrcode uritemplate==4.1.1 # via @@ -565,9 +609,17 @@ virtualenv==20.26.3 # via # -r requirements/test.txt # tox +walrus==0.9.4 + # via + # -r requirements/test.txt + # edx-event-bus-redis webencodings==0.5.1 # via # -r requirements/test.txt # bleach xss-utils==0.6.0 # via -r requirements/test.txt +zipp==3.19.2 + # via + # -r requirements/test.txt + # importlib-metadata diff --git a/requirements/docs.txt b/requirements/docs.txt index 64cdc98e9..70ad6e3f0 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,12 +1,12 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # -accessible-pygments==0.0.5 +accessible-pygments==0.0.4 # via pydata-sphinx-theme -alabaster==0.7.16 +alabaster==0.7.13 # via sphinx babel==2.15.0 # via @@ -18,7 +18,7 @@ certifi==2024.7.4 # via requests charset-normalizer==3.3.2 # via requests -docutils==0.21.2 +docutils==0.19 # via # pydata-sphinx-theme # sphinx @@ -26,6 +26,10 @@ idna==3.7 # via requests imagesize==1.4.1 # via sphinx +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # sphinx jinja2==3.1.4 # via sphinx jsx-lexer==2.0.1 @@ -36,7 +40,7 @@ packaging==24.1 # via # pydata-sphinx-theme # sphinx -pydata-sphinx-theme==0.15.4 +pydata-sphinx-theme==0.14.4 # via sphinx-book-theme pygments==2.18.0 # via @@ -44,30 +48,32 @@ pygments==2.18.0 # jsx-lexer # pydata-sphinx-theme # sphinx +pytz==2024.1 + # via babel requests==2.32.3 # via sphinx snowballstemmer==2.2.0 # via sphinx soupsieve==2.5 # via beautifulsoup4 -sphinx==7.4.4 +sphinx==6.2.1 # via # -r requirements/docs.in # pydata-sphinx-theme # sphinx-book-theme -sphinx-book-theme==1.1.3 +sphinx-book-theme==1.0.1 # via -r requirements/docs.in -sphinxcontrib-applehelp==1.0.8 +sphinxcontrib-applehelp==1.0.4 # via sphinx -sphinxcontrib-devhelp==1.0.6 +sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==2.0.5 +sphinxcontrib-htmlhelp==2.0.1 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.7 +sphinxcontrib-qthelp==1.0.3 # via sphinx -sphinxcontrib-serializinghtml==1.1.10 +sphinxcontrib-serializinghtml==1.1.5 # via sphinx typing-extensions==4.12.2 # via pydata-sphinx-theme @@ -75,3 +81,5 @@ urllib3==1.26.19 # via # -c requirements/constraints.txt # requests +zipp==3.19.2 + # via importlib-metadata diff --git a/requirements/pip.txt b/requirements/pip.txt index df29e61c6..3aba9984a 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -10,5 +10,5 @@ wheel==0.43.0 # The following packages are considered to be unsafe in a requirements file: pip==24.1.2 # via -r requirements/pip.in -setuptools==70.3.0 +setuptools==71.0.3 # via -r requirements/pip.in diff --git a/requirements/pip_tools.txt b/requirements/pip_tools.txt index 0b0b25e96..cd5c274bd 100644 --- a/requirements/pip_tools.txt +++ b/requirements/pip_tools.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -8,6 +8,10 @@ build==1.2.1 # via pip-tools click==8.1.7 # via pip-tools +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # build packaging==24.1 # via build pip-tools==7.4.1 @@ -16,8 +20,14 @@ pyproject-hooks==1.1.0 # via # build # pip-tools +tomli==2.0.1 + # via + # build + # pip-tools wheel==0.43.0 # via pip-tools +zipp==3.19.2 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/production.txt b/requirements/production.txt index a727d5276..44b4d78ec 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -9,6 +9,10 @@ asgiref==3.8.1 # -r requirements/base.txt # django # django-cors-headers +async-timeout==4.0.3 + # via + # -r requirements/base.txt + # redis attrs==23.2.0 # via # -r requirements/base.txt @@ -18,11 +22,17 @@ backoff==2.2.1 # via # -r requirements/base.txt # segment-analytics-python +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # django + # djangorestframework bleach==6.1.0 # via -r requirements/base.txt -boto3==1.34.144 +boto3==1.34.145 # via django-ses -botocore==1.34.144 +botocore==1.34.145 # via # boto3 # s3transfer @@ -78,6 +88,7 @@ django==4.2.14 # django-crum # django-extensions # django-filter + # django-model-utils # django-ses # django-simple-history # django-statici18n @@ -94,6 +105,7 @@ django==4.2.14 # edx-django-utils # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-i18n-tools # edx-toggles # openedx-events @@ -114,6 +126,8 @@ django-extensions==3.2.3 # via -r requirements/base.txt django-filter==24.2 # via -r requirements/base.txt +django-model-utils==4.5.1 + # via -r requirements/base.txt django-ratelimit==4.1.0 # via -r requirements/base.txt django-rest-swagger==2.2.0 @@ -173,6 +187,7 @@ edx-django-utils==5.14.2 # edx-ace # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-rest-api-client # edx-toggles # openedx-events @@ -180,6 +195,8 @@ edx-drf-extensions==10.3.0 # via -r requirements/base.txt edx-event-bus-kafka==5.7.0 # via -r requirements/base.txt +edx-event-bus-redis @ git+https://github.com/openedx/event-bus-redis.git@v0.5.0 + # via -r requirements/base.txt edx-i18n-tools==1.6.0 # via # -r requirements/base.txt @@ -196,11 +213,12 @@ edx-toggles==5.2.0 # via # -r requirements/base.txt # edx-event-bus-kafka + # edx-event-bus-redis fastavro==1.9.5 # via # -r requirements/base.txt # openedx-events -fontawesomefree==6.5.1 +fontawesomefree==6.6.0 # via -r requirements/base.txt gevent==24.2.1 # via -r requirements/production.in @@ -212,6 +230,11 @@ idna==3.7 # via # -r requirements/base.txt # requests +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # -r requirements/base.txt + # markdown inflection==0.5.1 # via # -r requirements/base.txt @@ -265,6 +288,7 @@ openedx-events==9.11.0 # via # -r requirements/base.txt # edx-event-bus-kafka + # edx-event-bus-redis packaging==24.1 # via # -r requirements/base.txt @@ -348,6 +372,10 @@ pyyaml==6.0.1 # edx-i18n-tools qrcode==7.4.2 # via -r requirements/base.txt +redis==5.0.7 + # via + # -r requirements/base.txt + # walrus requests==2.32.3 # via # -r requirements/base.txt @@ -420,6 +448,7 @@ text-unidecode==1.3 typing-extensions==4.12.2 # via # -r requirements/base.txt + # asgiref # edx-opaque-keys # qrcode uritemplate==4.1.1 @@ -433,12 +462,20 @@ urllib3==1.26.19 # -r requirements/base.txt # botocore # requests +walrus==0.9.4 + # via + # -r requirements/base.txt + # edx-event-bus-redis webencodings==0.5.1 # via # -r requirements/base.txt # bleach xss-utils==0.6.0 # via -r requirements/base.txt +zipp==3.19.2 + # via + # -r requirements/base.txt + # importlib-metadata zope-event==5.0 # via gevent zope-interface==6.4.post2 diff --git a/requirements/test.txt b/requirements/test.txt index dc87ff6b9..d6c8abe05 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -13,6 +13,10 @@ astroid==3.2.3 # via # pylint # pylint-celery +async-timeout==4.0.3 + # via + # -r requirements/base.txt + # redis attrs==23.2.0 # via # -r requirements/base.txt @@ -22,6 +26,12 @@ backoff==2.2.1 # via # -r requirements/base.txt # segment-analytics-python +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # django + # djangorestframework black==24.4.2 # via -r requirements/test.in bleach==6.1.0 @@ -98,6 +108,7 @@ distlib==0.3.8 # django-crum # django-extensions # django-filter + # django-model-utils # django-simple-history # django-statici18n # django-storages @@ -113,6 +124,7 @@ distlib==0.3.8 # edx-django-utils # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-i18n-tools # edx-toggles # openedx-events @@ -133,6 +145,8 @@ django-extensions==3.2.3 # via -r requirements/base.txt django-filter==24.2 # via -r requirements/base.txt +django-model-utils==4.5.1 + # via -r requirements/base.txt django-ratelimit==4.1.0 # via -r requirements/base.txt django-rest-swagger==2.2.0 @@ -190,6 +204,7 @@ edx-django-utils==5.14.2 # edx-ace # edx-drf-extensions # edx-event-bus-kafka + # edx-event-bus-redis # edx-rest-api-client # edx-toggles # openedx-events @@ -197,6 +212,8 @@ edx-drf-extensions==10.3.0 # via -r requirements/base.txt edx-event-bus-kafka==5.7.0 # via -r requirements/base.txt +edx-event-bus-redis @ git+https://github.com/openedx/event-bus-redis.git@v0.5.0 + # via -r requirements/base.txt edx-i18n-tools==1.6.0 # via # -r requirements/base.txt @@ -215,6 +232,9 @@ edx-toggles==5.2.0 # via # -r requirements/base.txt # edx-event-bus-kafka + # edx-event-bus-redis +exceptiongroup==1.2.2 + # via pytest factory-boy==3.3.0 # via -r requirements/test.in faker==26.0.0 @@ -227,7 +247,7 @@ filelock==3.15.4 # via # tox # virtualenv -fontawesomefree==6.5.1 +fontawesomefree==6.6.0 # via -r requirements/base.txt httpretty==1.1.4 # via -r requirements/test.in @@ -235,6 +255,11 @@ idna==3.7 # via # -r requirements/base.txt # requests +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # -r requirements/base.txt + # markdown inflection==0.5.1 # via # -r requirements/base.txt @@ -291,6 +316,7 @@ openedx-events==9.11.0 # via # -r requirements/base.txt # edx-event-bus-kafka + # edx-event-bus-redis packaging==24.1 # via # -r requirements/base.txt @@ -410,6 +436,10 @@ pyyaml==6.0.1 # responses qrcode==7.4.2 # via -r requirements/base.txt +redis==5.0.7 + # via + # -r requirements/base.txt + # walrus requests==2.32.3 # via # -r requirements/base.txt @@ -483,6 +513,13 @@ text-unidecode==1.3 # via # -r requirements/base.txt # python-slugify +tomli==2.0.1 + # via + # black + # pylint + # pyproject-api + # pytest + # tox tomlkit==0.13.0 # via pylint tox==4.16.0 @@ -490,7 +527,11 @@ tox==4.16.0 typing-extensions==4.12.2 # via # -r requirements/base.txt + # asgiref + # astroid + # black # edx-opaque-keys + # pylint # qrcode uritemplate==4.1.1 # via @@ -505,9 +546,17 @@ urllib3==1.26.19 # responses virtualenv==20.26.3 # via tox +walrus==0.9.4 + # via + # -r requirements/base.txt + # edx-event-bus-redis webencodings==0.5.1 # via # -r requirements/base.txt # bleach xss-utils==0.6.0 # via -r requirements/base.txt +zipp==3.19.2 + # via + # -r requirements/base.txt + # importlib-metadata diff --git a/requirements/translations.txt b/requirements/translations.txt index 74a6ac09c..5209d8db7 100644 --- a/requirements/translations.txt +++ b/requirements/translations.txt @@ -1,11 +1,15 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # asgiref==3.8.1 # via django +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # django django==4.2.14 # via # -c requirements/common_constraints.txt @@ -24,3 +28,5 @@ pyyaml==6.0.1 # via edx-i18n-tools sqlparse==0.5.1 # via django +typing-extensions==4.12.2 + # via asgiref