diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index c8f4520f9..34619a42e 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -29,6 +29,8 @@ badges.Fulfillment: ".. no_pii:": "This model has no PII" badges.PenaltyDataRule: ".. no_pii:": "This model has no PII" +badges.AccredibleAPIConfig: + ".. no_pii:": "This model has no PII" credentials.HistoricalProgramCompletionEmailConfiguration: ".. no_pii:": "This model has no PII" contenttypes.ContentType: diff --git a/credentials/apps/badges/accredible/api_client.py b/credentials/apps/badges/accredible/api_client.py new file mode 100644 index 000000000..92df3e0b7 --- /dev/null +++ b/credentials/apps/badges/accredible/api_client.py @@ -0,0 +1,127 @@ +import logging + +from attrs import asdict +from django.conf import settings +from django.contrib.sites.models import Site + +from credentials.apps.badges.accredible.data import AccredibleBadgeData, AccredibleExpireBadgeData +from credentials.apps.badges.accredible.exceptions import AccredibleError +from credentials.apps.badges.accredible.utils import get_accredible_api_base_url +from credentials.apps.badges.base_api_client import BaseBadgeProviderClient +from credentials.apps.badges.models import AccredibleAPIConfig, AccredibleGroup + + +logger = logging.getLogger(__name__) + + +class AccredibleAPIClient(BaseBadgeProviderClient): + """ + A client for interacting with the Accredible API. + + This class provides methods for performing various operations on the Accredible API. + """ + + PROVIDER_NAME = "Accredible" + + def __init__(self, api_config_id: int): + """ + Initializes a AccredibleAPIClient object. + + Args: + api_config (AccredibleAPIConfig): Configuration object for the Accredible API. + """ + + self.api_config_id = api_config_id + self.api_config = self.get_api_config() + + def get_api_config(self) -> AccredibleAPIConfig: + """ + Returns the API configuration object for the Accredible API. + """ + try: + return AccredibleAPIConfig.objects.get(id=self.api_config_id) + except AccredibleAPIConfig.DoesNotExist: + raise AccredibleError(f"AccredibleAPIConfig with the id {self.api_config_id} does not exist!") + + def _get_base_api_url(self) -> str: + return get_accredible_api_base_url(settings) + + def _get_headers(self) -> dict: + """ + Returns the headers for making API requests to Accredible. + """ + return { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_config.api_key}", + } + + def fetch_all_groups(self) -> dict: + """ + Fetch all groups. + """ + return self.perform_request("get", "issuer/all_groups") + + def fetch_design_image(self, design_id: int) -> str: + """ + Fetches the design and return the URL of image. + """ + design_raw = self.perform_request("get", f"designs/{design_id}") + return design_raw.get("design", {}).get("rasterized_content_url") + + def issue_badge(self, issue_badge_data: AccredibleBadgeData) -> dict: + """ + Issues a badge using the Accredible REST API. + + Args: + issue_badge_data (IssueBadgeData): Data required to issue the badge. + """ + return self.perform_request("post", "credentials", asdict(issue_badge_data)) + + def revoke_badge(self, badge_id, data: AccredibleExpireBadgeData) -> dict: + """ + Revoke a badge with the given badge ID. + + Args: + badge_id (str): ID of the badge to revoke. + data (dict): Additional data for the revocation. + """ + return self.perform_request("patch", f"credentials/{badge_id}", asdict(data)) + + def sync_groups(self, site_id: int) -> int: + """ + Pull all groups for a given Accredible API config. + + 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 + + groups_data = self.fetch_all_groups() + raw_groups = groups_data.get("groups", []) + + all_group_ids = [group.get("id") for group in raw_groups] + AccredibleGroup.objects.exclude(id__in=all_group_ids).delete() + + for raw_group in raw_groups: + AccredibleGroup.objects.update_or_create( + id=raw_group.get("id"), + api_config=self.api_config, + defaults={ + "site": site, + "name": raw_group.get("course_name"), + "description": raw_group.get("course_description"), + "icon": self.fetch_design_image(raw_group.get("primary_design_id")), + "created": raw_group.get("created_at"), + "state": AccredibleGroup.STATES.active, + }, + ) + + return len(raw_groups) diff --git a/credentials/apps/badges/accredible/data.py b/credentials/apps/badges/accredible/data.py new file mode 100644 index 000000000..16787ff40 --- /dev/null +++ b/credentials/apps/badges/accredible/data.py @@ -0,0 +1,64 @@ +from datetime import datetime + +import attr + + +@attr.s(auto_attribs=True, frozen=True) +class AccredibleRecipient: + """ + Represents the recipient data in the credential. + + Attributes: + name (str): The recipient's name. + email (str): The recipient's email address. + """ + + name: str + email: str + + +@attr.s(auto_attribs=True, frozen=True) +class AccredibleCredential: + """ + Represents the credential data. + + Attributes: + recipient (RecipientData): Information about the recipient. + group_id (int): ID of the credential group. + name (str): Title of the credential. + issued_on (datetime): Date when the credential was issued. + complete (bool): Whether the credential process is complete. + """ + + recipient: AccredibleRecipient + group_id: int + name: str + issued_on: datetime + complete: bool + + +@attr.s(auto_attribs=True, frozen=True) +class AccredibleExpiredCredential: + """ + Represents the data required to expire a credential. + """ + + expired_on: datetime + + +@attr.s(auto_attribs=True, frozen=True) +class AccredibleBadgeData: + """ + Represents the data required to issue a badge. + """ + + credential: AccredibleCredential + + +@attr.s(auto_attribs=True, frozen=True) +class AccredibleExpireBadgeData: + """ + Represents the data required to expire a badge. + """ + + credential: AccredibleExpiredCredential diff --git a/credentials/apps/badges/accredible/exceptions.py b/credentials/apps/badges/accredible/exceptions.py new file mode 100644 index 000000000..a224fad5d --- /dev/null +++ b/credentials/apps/badges/accredible/exceptions.py @@ -0,0 +1,11 @@ +""" +Specific for Accredible exceptions. +""" + +from credentials.apps.badges.exceptions import BadgesError + + +class AccredibleError(BadgesError): + """ + Accredible backend generic error. + """ diff --git a/credentials/apps/badges/accredible/utils.py b/credentials/apps/badges/accredible/utils.py new file mode 100644 index 000000000..40b07ccb6 --- /dev/null +++ b/credentials/apps/badges/accredible/utils.py @@ -0,0 +1,45 @@ +""" +Accredible utility functions. +""" + + +def get_accredible_api_base_url(settings) -> str: + """ + Determines the base URL for the Accredible service based on application settings. + + Parameters: + - settings: A configuration object containing the application's settings. + + Returns: + - str: The base URL for the Accredible 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. + """ + accredible_config = settings.BADGES_CONFIG["accredible"] + + if accredible_config.get("USE_SANDBOX"): + return accredible_config["ACCREDIBLE_SANDBOX_API_BASE_URL"] + + return accredible_config["ACCREDIBLE_API_BASE_URL"] + + +def get_accredible_base_url(settings) -> str: + """ + Determines the base URL for the Accredible service based on application settings. + + Parameters: + - settings: A configuration object containing the application's settings. + + Returns: + - str: The base URL for the Accredible 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. + """ + accredible_config = settings.BADGES_CONFIG["accredible"] + + if accredible_config.get("USE_SANDBOX"): + return accredible_config["ACCREDIBLE_SANDBOX_BASE_URL"] + + return accredible_config["ACCREDIBLE_BASE_URL"] diff --git a/credentials/apps/badges/admin.py b/credentials/apps/badges/admin.py index bdf43756b..3ee41a2fc 100644 --- a/credentials/apps/badges/admin.py +++ b/credentials/apps/badges/admin.py @@ -21,7 +21,11 @@ PenaltyDataRuleForm, PenaltyDataRuleFormSet, ) +from credentials.apps.badges.exceptions import BadgeProviderError from credentials.apps.badges.models import ( + AccredibleAPIConfig, + AccredibleBadge, + AccredibleGroup, BadgePenalty, BadgeProgress, BadgeRequirement, @@ -35,6 +39,12 @@ from credentials.apps.badges.toggles import is_badges_enabled +ADMIN_CHANGE_VIEW_REVERSE_NAMES = { + CredlyBadgeTemplate.ORIGIN: "admin:badges_credlybadgetemplate_change", + AccredibleGroup.ORIGIN: "admin:badges_accrediblegroup_change", +} + + class BadgeRequirementInline(admin.TabularInline): """ Badge template requirement inline setup. @@ -385,14 +395,19 @@ def template_link(self, instance): """ Interactive link to parent (badge template). """ - url = reverse("admin:badges_credlybadgetemplate_change", args=[instance.template.pk]) + reverse_name = ADMIN_CHANGE_VIEW_REVERSE_NAMES.get(instance.template.origin, "admin:index") + reverse_args = [] if reverse_name == "admin:index" else [instance.template.pk] + + url = reverse(reverse_name, args=reverse_args) 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])) + reverse_name = ADMIN_CHANGE_VIEW_REVERSE_NAMES.get(obj.template.origin, "admin:index") + reverse_args = [] if reverse_name == "admin:index" else [obj.template.pk] + return HttpResponseRedirect(reverse(reverse_name, args=reverse_args)) return super().response_change(request, obj) @@ -442,7 +457,9 @@ def template_link(self, instance): """ Interactive link to parent (badge template). """ - url = reverse("admin:badges_credlybadgetemplate_change", args=[instance.template.pk]) + reverse_name = ADMIN_CHANGE_VIEW_REVERSE_NAMES.get(instance.template.origin, "admin:index") + reverse_args = [] if reverse_name == "admin:index" else [instance.template.pk] + url = reverse(reverse_name, args=reverse_args) return format_html('{}', url, instance.template) template_link.short_description = _("badge template") @@ -457,7 +474,9 @@ def formfield_for_manytomany(self, 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])) + reverse_name = ADMIN_CHANGE_VIEW_REVERSE_NAMES.get(obj.template.origin, "admin:index") + reverse_args = [] if reverse_name == "admin:index" else [obj.template.pk] + return HttpResponseRedirect(reverse(reverse_name, args=reverse_args)) return super().response_change(request, obj) @@ -541,6 +560,180 @@ def has_add_permission(self, request): return False +class AccredibleAPIConfigAdmin(admin.ModelAdmin): + """ + Accredible API configuration admin setup. + """ + + list_display = ( + "id", + "name", + ) + actions = ("sync_groups",) + + @admin.action(description="Sync groups") + def sync_groups(self, request, queryset): + """ + Sync groups for selected api configs. + """ + site = get_current_site(request) + for api_config in queryset: + try: + call_command( + "sync_accredible_groups", + api_config_id=api_config.id, + site_id=site.id, + ) + except BadgeProviderError as exc: + messages.set_level(request, messages.ERROR) + messages.error(request, _("Failed to sync groups for API config: {}. {}").format(api_config.name, exc)) + + messages.success(request, _("Accredible groups were successfully updated.")) + + +class AccredibleBadgeAdmin(admin.ModelAdmin): + """ + Accredible badge admin setup. + """ + + list_display = ( + "uuid", + "username", + "credential", + "status", + "state", + "external_id", + ) + list_filter = ( + "status", + "state", + ) + search_fields = ( + "username", + "external_id", + ) + readonly_fields = ( + "credential_id", + "credential_content_type", + "username", + "download_url", + "state", + "uuid", + "external_id", + ) + + def has_add_permission(self, request): + return False + + +class AccredibleGroupAdmin(admin.ModelAdmin): + """ + Accredible group admin setup. + """ + + list_display = ( + "id", + "api_config", + "name", + "state", + "is_active", + "image", + ) + list_filter = ( + "api_config", + "is_active", + "state", + ) + search_fields = ( + "name", + "id", + ) + readonly_fields = [ + "state", + "origin", + "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": ( + "name", + "description", + "image", + "origin", + ) + }, + ), + ( + "Accredible", + { + "fields": ( + "api_config", + "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 + + # register admin configurations with respect to the feature flag if is_badges_enabled(): admin.site.register(CredlyOrganization, CredlyOrganizationAdmin) @@ -549,3 +742,6 @@ def has_add_permission(self, request): admin.site.register(BadgeRequirement, BadgeRequirementAdmin) admin.site.register(BadgePenalty, BadgePenaltyAdmin) admin.site.register(BadgeProgress, BadgeProgressAdmin) + admin.site.register(AccredibleAPIConfig, AccredibleAPIConfigAdmin) + admin.site.register(AccredibleBadge, AccredibleBadgeAdmin) + admin.site.register(AccredibleGroup, AccredibleGroupAdmin) diff --git a/credentials/apps/badges/base_api_client.py b/credentials/apps/badges/base_api_client.py new file mode 100644 index 000000000..082bdaf07 --- /dev/null +++ b/credentials/apps/badges/base_api_client.py @@ -0,0 +1,105 @@ +import logging +from abc import ABC, abstractmethod +from urllib.parse import urljoin + +import requests +from requests.exceptions import HTTPError + +from .exceptions import BadgeProviderError + + +logger = logging.getLogger(__name__) + + +class BaseBadgeProviderClient(ABC): + """ + Base class for interacting with a generic badge provider API. + + This class provides common functionality such as performing requests + and error handling. Methods specific to badge providers must be implemented + by subclasses. + """ + + PROVIDER_NAME = None + REQUESTS_TIMEOUT = 10 + + def __init__(self): + self.base_api_url = None + + 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"{self.PROVIDER_NAME} API: {method.upper()} {url}") + response = requests.request( + method.upper(), url, headers=self._get_headers(), json=data, timeout=self.REQUESTS_TIMEOUT + ) + 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 {self.PROVIDER_NAME} request: {response.status_code} - {response.text}" + ) + raise BadgeProviderError(f"{response.text} Status({response.status_code})") + + @property + def base_api_url(self): + return self._get_base_api_url() + + @abstractmethod + def _get_headers(self): + """ + Returns the headers for making API requests. + """ + + @abstractmethod + def _get_base_api_url(self): + """ + Returns the base URL for the badge provider API. + """ + + @abstractmethod + def issue_badge(self, issue_badge_data): + """ + Issues a badge using the badge provider API. + + Args: + issue_badge_data (dict): Data required to issue the badge. + """ + + @abstractmethod + def revoke_badge(self, badge_id, data=None): + """ + Revoke a badge with the given badge ID. + + Must be implemented by subclasses. + + Args: + badge_id (str): ID of the badge to revoke. + data (dict): Additional data for the revocation. + """ diff --git a/credentials/apps/badges/credly/api_client.py b/credentials/apps/badges/credly/api_client.py index 793f4c85e..3b587045d 100644 --- a/credentials/apps/badges/credly/api_client.py +++ b/credentials/apps/badges/credly/api_client.py @@ -3,13 +3,13 @@ from functools import lru_cache from urllib.parse import urljoin -import requests +import requests # pylint: disable=unused-import 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.base_api_client import BaseBadgeProviderClient +from credentials.apps.badges.credly.exceptions import CredlyError from credentials.apps.badges.credly.utils import get_credly_api_base_url from credentials.apps.badges.models import CredlyBadgeTemplate, CredlyOrganization @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -class CredlyAPIClient: +class CredlyAPIClient(BaseBadgeProviderClient): """ A client for interacting with the Credly API. @@ -26,7 +26,9 @@ class CredlyAPIClient: and revoking badges. """ - def __init__(self, organization_id, api_key=None): + PROVIDER_NAME = "Credly" + + def __init__(self, organization_id, api_key=None): # pylint: disable=super-init-not-called """ Initializes a CredlyRestAPI object. @@ -41,7 +43,8 @@ def __init__(self, organization_id, api_key=None): self.api_key = api_key self.organization_id = organization_id - self.base_api_url = urljoin(get_credly_api_base_url(settings), f"organizations/{self.organization_id}/") + def _get_base_api_url(self): + return urljoin(get_credly_api_base_url(settings), f"organizations/{self.organization_id}/") def _get_organization(self, organization_id): """ @@ -53,43 +56,6 @@ def _get_organization(self, organization_id): 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. @@ -140,12 +106,13 @@ def issue_badge(self, issue_badge_data): """ return self.perform_request("post", "badges/", asdict(issue_badge_data)) - def revoke_badge(self, badge_id, data): + def revoke_badge(self, badge_id, data=None): """ Revoke a badge with the given badge ID. Args: badge_id (str): ID of the badge to revoke. + data (dict): Additional data for the revocation. """ return self.perform_request("put", f"badges/{badge_id}/revoke/", data=data) diff --git a/credentials/apps/badges/exceptions.py b/credentials/apps/badges/exceptions.py index e2719006f..5d908d671 100644 --- a/credentials/apps/badges/exceptions.py +++ b/credentials/apps/badges/exceptions.py @@ -13,3 +13,9 @@ class BadgesProcessingError(BadgesError): """ Exception raised for errors that occur during badge processing. """ + + +class BadgeProviderError(BadgesError): + """ + Exception raised for errors that occur during badge API client processing. + """ diff --git a/credentials/apps/badges/issuers.py b/credentials/apps/badges/issuers.py index 9d03e154c..b56510361 100644 --- a/credentials/apps/badges/issuers.py +++ b/credentials/apps/badges/issuers.py @@ -2,20 +2,43 @@ This module provides classes for issuing badge credentials to users. """ +from datetime import datetime + from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.utils.translation import gettext as _ +from credentials.apps.badges.accredible.api_client import AccredibleAPIClient +from credentials.apps.badges.accredible.data import ( + AccredibleBadgeData, + AccredibleCredential, + AccredibleExpireBadgeData, + AccredibleExpiredCredential, + AccredibleRecipient, +) 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.exceptions import BadgeProviderError +from credentials.apps.badges.models import ( + AccredibleBadge, + AccredibleGroup, + 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 +REVOCATION_STATES = { + CredlyBadge: CredlyBadge.STATES.revoked, + AccredibleBadge: AccredibleBadge.STATES.expired, +} + + class BadgeTemplateIssuer(AbstractCredentialIssuer): """ Issues BadgeTemplate credentials to users. @@ -59,7 +82,7 @@ def issue_credential( UserCredential """ - user_credential, __ = self.issued_user_credential_type.objects.update_or_create( + user_credential, __ = self.issued_user_credential_type.objects.get_or_create( username=username, credential_content_type=ContentType.objects.get_for_model(credential), credential_id=credential.id, @@ -67,6 +90,9 @@ def issue_credential( "status": status, }, ) + if not user_credential.state == REVOCATION_STATES.get(self.issued_user_credential_type): + user_credential.status = status + user_credential.save() self.set_credential_attributes(user_credential, attributes) self.set_credential_date_override(user_credential, date_override) @@ -133,7 +159,7 @@ def issue_credly_badge(self, *, user_credential): try: credly_api = CredlyAPIClient(badge_template.organization.uuid) response = credly_api.issue_badge(credly_badge_data) - except CredlyAPIError: + except BadgeProviderError: user_credential.state = "error" user_credential.save() raise @@ -154,7 +180,7 @@ def revoke_credly_badge(self, credential_id, user_credential): } try: response = credly_api.revoke_badge(user_credential.external_uuid, revoke_data) - except CredlyAPIError: + except BadgeProviderError: user_credential.state = "error" user_credential.save() raise @@ -196,3 +222,101 @@ def revoke(self, credential_id, username): if user_credential.propagated: self.revoke_credly_badge(credential_id, user_credential) return user_credential + + +class AccredibleBadgeTemplateIssuer(BadgeTemplateIssuer): + """ + Issues AccredibleGroup credentials to users. + """ + + issued_credential_type = AccredibleGroup + issued_user_credential_type = AccredibleBadge + + def issue_accredible_badge(self, *, user_credential): + """ + Requests Accredible service for external badge issuing based on internal user credential (AccredibleBadge). + """ + + user = get_user_by_username(user_credential.username) + group = user_credential.credential + + accredible_badge_data = AccredibleBadgeData( + credential=AccredibleCredential( + recipient=AccredibleRecipient( + name=user.get_full_name() or user.username, + email=user.email, + ), + group_id=group.id, + name=group.name, + issued_on=user_credential.created.strftime("%Y-%m-%d %H:%M:%S %z"), + complete=True, + ) + ) + + try: + accredible_api = AccredibleAPIClient(group.api_config.id) + response = accredible_api.issue_badge(accredible_badge_data) + except BadgeProviderError: + user_credential.state = "error" + user_credential.save() + raise + + user_credential.external_id = response.get("credential").get("id") + user_credential.state = AccredibleBadge.STATES.accepted + user_credential.save() + + def revoke_accredible_badge(self, credential_id, user_credential): + """ + Requests Accredible service for external badge expiring based on internal user credential (AccredibleBadge). + """ + + credential = self.get_credential(credential_id) + accredible_api_client = AccredibleAPIClient(credential.api_config.id) + revoke_badge_data = AccredibleExpireBadgeData( + credential=AccredibleExpiredCredential(expired_on=datetime.now().strftime("%Y-%m-%d %H:%M:%S %z")) + ) + + try: + accredible_api_client.revoke_badge(user_credential.external_id, revoke_badge_data) + except BadgeProviderError: + user_credential.state = "error" + user_credential.save() + raise + + user_credential.state = AccredibleBadge.STATES.expired + user_credential.save() + + def award(self, *, username, credential_id): + """ + Awards a Accredible badge. + + - Creates user credential record for the group, for a given user; + - Notifies about the awarded badge (public signal); + - Issues external Accredible badge (Accredible API); + + Returns: (AccredibleBadge) user credential + """ + + accredible_badge = super().award(username=username, credential_id=credential_id) + + # do not issue new badges if the badge was issued already + if not accredible_badge.propagated: + self.issue_accredible_badge(user_credential=accredible_badge) + + return accredible_badge + + def revoke(self, credential_id, username): + """ + Revokes a Accredible badge. + + - Changes user credential status to REVOKED, for a given user; + - Notifies about the revoked badge (public signal); + - Expire external Accredible badge (Accredible API); + + Returns: (AccredibleBadge) user credential + """ + + user_credential = super().revoke(credential_id, username) + if user_credential.propagated: + self.revoke_accredible_badge(credential_id, user_credential) + return user_credential diff --git a/credentials/apps/badges/management/commands/sync_accredible_groups.py b/credentials/apps/badges/management/commands/sync_accredible_groups.py new file mode 100644 index 000000000..11c3b694e --- /dev/null +++ b/credentials/apps/badges/management/commands/sync_accredible_groups.py @@ -0,0 +1,54 @@ +from django.core.management.base import BaseCommand + +from credentials.apps.badges.accredible.api_client import AccredibleAPIClient +from credentials.apps.badges.models import AccredibleAPIConfig + + +class Command(BaseCommand): + """ + Sync groups for a specific accredible api config or all configs. + + Usage: + site_id=1 + api_config_id=1 + + ./manage.py sync_accredible_groups --site_id $site_id + ./manage.py sync_accredible_groups --site_id $site_id --api_config_id $api_config_id + """ + + help = "Sync accredible groups for a specific api config or all api configs" + + def add_arguments(self, parser): + parser.add_argument("--site_id", type=int, help="Site ID.") + parser.add_argument("--api_config_id", type=str, help="ID of the API config.") + + def handle(self, *args, **options): + """ + Handle the command. + """ + DEFAULT_SITE_ID = 1 + api_configs_to_sync = [] + + site_id = options.get("site_id") + api_config_id = options.get("api_config_id") + + if site_id is None: + self.stdout.write(f"Side ID wasn't provided: assuming site_id = {DEFAULT_SITE_ID}") + site_id = DEFAULT_SITE_ID + + if api_config_id: + api_configs_to_sync.append(api_config_id) + self.stdout.write(f"Syncing groups for the single config: {api_config_id}") + else: + api_configs_to_sync = AccredibleAPIConfig.get_all_api_config_ids() + self.stdout.write( + "API Config ID wasn't provided: syncing groups for all configs - " f"{api_configs_to_sync}", + ) + + for api_config in AccredibleAPIConfig.objects.filter(id__in=api_configs_to_sync): + accredible_api_client = AccredibleAPIClient(api_config.id) + processed_items = accredible_api_client.sync_groups(site_id) + + self.stdout.write(f"API Config {api_config_id}: got {processed_items} groups.") + + self.stdout.write("...completed!") diff --git a/credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_and_more.py b/credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_and_more.py new file mode 100644 index 000000000..848e757fb --- /dev/null +++ b/credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.17 on 2024-12-20 10:13 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('credentials', '0030_revoke_certificates_management_command'), + ('badges', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AccredibleAPIConfig', + 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')), + ('name', models.CharField(blank=True, help_text='Accredible API configuration name.', max_length=255, null=True)), + ('api_key', models.CharField(help_text='Accredible API key.', max_length=255)), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AccredibleBadge', + 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'), ('accepted', 'accepted'), ('expired', 'expired')], default='created', help_text='Accredible badge issuing state', max_length=100, no_check_for_status=True)), + ('external_id', models.IntegerField(blank=True, help_text='Accredible service badge identifier', null=True, unique=True)), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + bases=('credentials.usercredential',), + ), + migrations.AlterField( + model_name='badgetemplate', + name='icon', + field=models.ImageField(blank=True, max_length=255, null=True, upload_to='badge_templates/icons'), + ), + migrations.AlterField( + model_name='badgetemplate', + name='state', + field=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, null=True), + ), + migrations.CreateModel( + name='AccredibleGroup', + 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')), + ('api_config', models.ForeignKey(help_text='Accredible API configuration.', on_delete=django.db.models.deletion.CASCADE, to='badges.accredibleapiconfig')), + ], + options={ + 'abstract': False, + }, + bases=('badges.badgetemplate',), + ), + ] diff --git a/credentials/apps/badges/migrations/0003_alter_accredibleapiconfig_name.py b/credentials/apps/badges/migrations/0003_alter_accredibleapiconfig_name.py new file mode 100644 index 000000000..fa87b0218 --- /dev/null +++ b/credentials/apps/badges/migrations/0003_alter_accredibleapiconfig_name.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-23 15:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("badges", "0002_accredibleapiconfig_accrediblebadge_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="accredibleapiconfig", + name="name", + field=models.CharField(help_text="Accredible API configuration name.", max_length=255, null=True), + ), + ] diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py index 84ad3a3e9..a16fced95 100644 --- a/credentials/apps/badges/models.py +++ b/credentials/apps/badges/models.py @@ -15,6 +15,7 @@ from model_utils.fields import StatusField from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData +from credentials.apps.badges.accredible.utils import get_accredible_base_url from credentials.apps.badges.credly.utils import get_credly_base_url from credentials.apps.badges.signals.signals import ( notify_progress_complete, @@ -88,11 +89,12 @@ class BadgeTemplate(AbstractCredential): 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) + icon = models.ImageField(upload_to="badge_templates/icons", null=True, blank=True, max_length=255) 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)."), + null=True, ) def __str__(self): @@ -536,7 +538,7 @@ def groups(self): return { group: BadgeRequirement.is_group_fulfilled(group=group, template=self.template, username=self.username) - for group in self.template.groups + for group in getattr(self.template, "groups", []) } @property @@ -551,15 +553,14 @@ def progress(self): """ Notify about the progress. """ - - notify_progress_complete(self, self.username, self.template.id) + notify_progress_complete(self, self.username, self.template.id, self.template.origin) def regress(self): """ Notify about the regression. """ - notify_progress_incomplete(self, self.username, self.template.id) + notify_progress_incomplete(self, self.username, self.template.id, self.template.origin) def reset(self): """ @@ -623,6 +624,7 @@ class CredlyBadge(UserCredential): STATES.pending, STATES.accepted, STATES.rejected, + STATES.revoked, } state = StatusField( @@ -675,3 +677,115 @@ def propagated(self): """ return self.external_uuid and (self.state in self.ISSUING_STATES) + + +class AccredibleAPIConfig(TimeStampedModel): + """ + Accredible API configuration. + """ + + name = models.CharField(max_length=255, help_text=_("Accredible API configuration name."), null=True, blank=False) + api_key = models.CharField(max_length=255, help_text=_("Accredible API key.")) + + @classmethod + def get_all_api_config_ids(cls): + """ + Get all api config IDs. + """ + return list(cls.objects.values_list("id", flat=True)) + + +class AccredibleGroup(BadgeTemplate): + """ + Accredible badge group credential type. + + Accredible groups should not be created manually, instead they are pulled from the Accredible service. + """ + + ORIGIN = "accredible" + uuid = None + + api_config = models.ForeignKey( + AccredibleAPIConfig, + on_delete=models.CASCADE, + help_text=_("Accredible API configuration."), + ) + + @property + def management_url(self): + """ + Build external Credly dashboard URL. + """ + accredible_host_base_url = get_accredible_base_url(settings) + return urljoin(accredible_host_base_url, f"issuer/dashboard/group/{self.id}/information-and-appearance") + + +class AccredibleBadge(UserCredential): + """ + Earned Accredible badge (Badge template credential) for user. + + - tracks distributed (external Accredible service) state for Accredible badge. + """ + + STATES = Choices( + "created", + "no_response", + "error", + "accepted", + "expired", + ) + ISSUING_STATES = { + STATES.accepted, + STATES.expired, + } + + state = StatusField( + choices_name="STATES", + help_text=_("Accredible badge issuing state"), + default=STATES.created, + ) + + external_id = models.IntegerField( + blank=True, + null=True, + unique=True, + help_text=_("Accredible service badge identifier"), + ) + + def as_badge_data(self) -> BadgeData: + """ + Represents itself as a BadgeData instance. + """ + + user = get_user_by_username(self.username) + group = self.credential + + badge_data = BadgeData( + uuid=str(self.uuid), + user=UserData( + pii=UserPersonalData( + username=self.username, + email=user.email, + name=user.get_full_name(), + ), + id=user.lms_user_id, + is_active=user.is_active, + ), + template=BadgeTemplateData( + uuid=str(group.uuid), + origin=group.origin, + name=group.name, + description=group.description, + image_url=str(group.icon), + ), + ) + + return badge_data + + @property + def propagated(self): + """ + Checks if this user credential already has issued (external) Accredible badge. + """ + + return self.external_id and (self.state in self.ISSUING_STATES) diff --git a/credentials/apps/badges/signals/handlers.py b/credentials/apps/badges/signals/handlers.py index 2044e0849..6af0665d9 100644 --- a/credentials/apps/badges/signals/handlers.py +++ b/credentials/apps/badges/signals/handlers.py @@ -9,8 +9,8 @@ from django.dispatch import receiver from openedx_events.tooling import OpenEdxPublicSignal, load_all_signals -from credentials.apps.badges.issuers import CredlyBadgeTemplateIssuer -from credentials.apps.badges.models import BadgeProgress +from credentials.apps.badges.issuers import AccredibleBadgeTemplateIssuer, CredlyBadgeTemplateIssuer +from credentials.apps.badges.models import AccredibleGroup, BadgeProgress, CredlyBadgeTemplate from credentials.apps.badges.processing.generic import process_event from credentials.apps.badges.signals import ( BADGE_PROGRESS_COMPLETE, @@ -63,7 +63,7 @@ def handle_requirement_regressed(sender, username, **kwargs): @receiver(BADGE_PROGRESS_COMPLETE) -def handle_badge_completion(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument +def handle_badge_completion(sender, username, badge_template_id, origin, **kwargs): # pylint: disable=unused-argument """ Fires once ALL requirements for a badge template were marked as "done". @@ -73,11 +73,14 @@ def handle_badge_completion(sender, username, badge_template_id, **kwargs): # p logger.debug("BADGES: progress is complete for %s on the %s", username, badge_template_id) - CredlyBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id) + if origin == CredlyBadgeTemplate.ORIGIN: + CredlyBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id) + elif origin == AccredibleGroup.ORIGIN: + AccredibleBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id) @receiver(BADGE_PROGRESS_INCOMPLETE) -def handle_badge_regression(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument +def handle_badge_regression(sender, username, badge_template_id, origin, **kwargs): # pylint: disable=unused-argument """ On user's Badge regression (incompletion). @@ -85,4 +88,7 @@ def handle_badge_regression(sender, username, badge_template_id, **kwargs): # p - badge template ID """ - CredlyBadgeTemplateIssuer().revoke(badge_template_id, username) + if origin == CredlyBadgeTemplate.ORIGIN: + CredlyBadgeTemplateIssuer().revoke(badge_template_id, username) + elif origin == AccredibleGroup.ORIGIN: + AccredibleBadgeTemplateIssuer().revoke(badge_template_id, username) diff --git a/credentials/apps/badges/signals/signals.py b/credentials/apps/badges/signals/signals.py index db224ff54..a02dec413 100644 --- a/credentials/apps/badges/signals/signals.py +++ b/credentials/apps/badges/signals/signals.py @@ -48,7 +48,7 @@ def notify_requirement_regressed(*, sender, username, badge_template_id): ) -def notify_progress_complete(sender, username, badge_template_id): +def notify_progress_complete(sender, username, badge_template_id, origin): """ Notifies about user's completion on the badge template. """ @@ -57,10 +57,11 @@ def notify_progress_complete(sender, username, badge_template_id): sender=sender, username=username, badge_template_id=badge_template_id, + origin=origin, ) -def notify_progress_incomplete(sender, username, badge_template_id): +def notify_progress_incomplete(sender, username, badge_template_id, origin): """ Notifies about user's regression on the badge template. """ @@ -68,6 +69,7 @@ def notify_progress_incomplete(sender, username, badge_template_id): sender=sender, username=username, badge_template_id=badge_template_id, + origin=origin, ) diff --git a/credentials/apps/badges/tests/accredible/__init__.py b/credentials/apps/badges/tests/accredible/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/tests/accredible/test_api_client.py b/credentials/apps/badges/tests/accredible/test_api_client.py new file mode 100644 index 000000000..4779b7f85 --- /dev/null +++ b/credentials/apps/badges/tests/accredible/test_api_client.py @@ -0,0 +1,96 @@ +from unittest import mock + +from attrs import asdict +from django.test import TestCase + +from credentials.apps.badges.accredible.api_client import AccredibleAPIClient +from credentials.apps.badges.accredible.data import ( + AccredibleBadgeData, + AccredibleCredential, + AccredibleExpireBadgeData, + AccredibleExpiredCredential, + AccredibleRecipient, +) +from credentials.apps.badges.models import AccredibleAPIConfig, AccredibleGroup + + +class AccredibleAPIClientTestCase(TestCase): + def setUp(self): + self.api_config = AccredibleAPIConfig.objects.create( + api_key="test-api-key", + name="test_config", + ) + self.api_client = AccredibleAPIClient(self.api_config.id) + self.badge_data = AccredibleBadgeData( + credential=AccredibleCredential( + recipient=AccredibleRecipient(name="Test name", email="test_name@test.com"), + group_id=123, + name="Test Badge", + issued_on="2021-01-01 00:00:00 +0000", + complete=True, + ) + ) + self.expire_badge_data = AccredibleExpireBadgeData( + credential=AccredibleExpiredCredential(expired_on="2021-01-01 00:00:00 +0000") + ) + + def test_fetch_all_groups(self): + with mock.patch.object(AccredibleAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"groups": ["group1", "group2"]} + result = self.api_client.fetch_all_groups() + mock_perform_request.assert_called_once_with("get", "issuer/all_groups") + self.assertEqual(result, {"groups": ["group1", "group2"]}) + + def test_fetch_design_image(self): + design_id = 123 + with mock.patch.object(AccredibleAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"design": {"rasterized_content_url": "url"}} + result = self.api_client.fetch_design_image(design_id) + mock_perform_request.assert_called_once_with("get", f"designs/{design_id}") + self.assertEqual(result, "url") + + def test_issue_badge(self): + with mock.patch.object(AccredibleAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"badge": "issued"} + result = self.api_client.issue_badge(self.badge_data) + mock_perform_request.assert_called_once_with("post", "credentials", asdict(self.badge_data)) + self.assertEqual(result, {"badge": "issued"}) + + def test_revoke_badge(self): + badge_id = 123 + with mock.patch.object(AccredibleAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"badge": "revoked"} + result = self.api_client.revoke_badge(badge_id, self.expire_badge_data) + mock_perform_request.assert_called_once_with( + "patch", f"credentials/{badge_id}", asdict(self.expire_badge_data) + ) + self.assertEqual(result, {"badge": "revoked"}) + + def test_sync_groups(self): + AccredibleGroup.objects.create( + id=777, + api_config=self.api_config, + name="old_name", + description="old_desc", + icon="old_icon", + site_id=1, + ) + with mock.patch.object(AccredibleAPIClient, "fetch_all_groups") as mock_fetch_all_groups, mock.patch.object( + AccredibleAPIClient, "fetch_design_image" + ) as mock_fetch_design_image: + mock_fetch_all_groups.return_value = { + "groups": [{"id": 1, "course_name": "name", "course_description": "desc", "primary_design_id": 123}] + } + mock_fetch_design_image.return_value = "url" + + self.assertEqual(AccredibleGroup.objects.filter(id=777).exists(), True) + result = self.api_client.sync_groups(1) + mock_fetch_all_groups.assert_called_once() + mock_fetch_design_image.assert_called_once_with(123) + self.assertEqual(result, 1) + self.assertEqual(AccredibleGroup.objects.count(), 1) + self.assertEqual(AccredibleGroup.objects.first().name, "name") + self.assertEqual(AccredibleGroup.objects.first().description, "desc") + self.assertEqual(AccredibleGroup.objects.first().icon, "url") + self.assertEqual(AccredibleGroup.objects.first().api_config, self.api_config) + self.assertEqual(AccredibleGroup.objects.filter(id=777).exists(), False) diff --git a/credentials/apps/badges/tests/accredible/test_utils.py b/credentials/apps/badges/tests/accredible/test_utils.py new file mode 100644 index 000000000..1a64bfd9a --- /dev/null +++ b/credentials/apps/badges/tests/accredible/test_utils.py @@ -0,0 +1,27 @@ +from django.conf import settings +from django.test import TestCase + +from credentials.apps.badges.accredible.utils import get_accredible_base_url + + +class TestGetAccredibleBaseUrl(TestCase): + def test_get_accredible_base_url_sandbox(self): + settings.BADGES_CONFIG["accredible"] = { + "ACCREDIBLE_BASE_URL": "https://accredible.com", + "ACCREDIBLE_SANDBOX_BASE_URL": "https://sandbox.accredible.com", + "ACCREDIBLE_SANDBOX_API_BASE_URL": "https://sandbox.api.accredible.com/v1/", + "USE_SANDBOX": True, + } + + result = get_accredible_base_url(settings) + self.assertEqual(result, "https://sandbox.accredible.com") + + def test_get_accredible_base_url_production(self): + settings.BADGES_CONFIG["accredible"] = { + "ACCREDIBLE_BASE_URL": "https://accredible.com", + "ACCREDIBLE_SANDBOX_BASE_URL": "https://sandbox.accredible.com", + "ACCREDIBLE_SANDBOX_API_BASE_URL": "https://sandbox.api.accredible.com/v1/", + "USE_SANDBOX": False, + } + result = get_accredible_base_url(settings) + self.assertEqual(result, "https://accredible.com") diff --git a/credentials/apps/badges/tests/test_admin_forms.py b/credentials/apps/badges/tests/test_admin_forms.py index 6a5914e93..1c41d4013 100644 --- a/credentials/apps/badges/tests/test_admin_forms.py +++ b/credentials/apps/badges/tests/test_admin_forms.py @@ -11,7 +11,7 @@ DataRuleExtensionsMixin, ParentMixin, ) -from credentials.apps.badges.credly.exceptions import CredlyAPIError +from credentials.apps.badges.exceptions import BadgeProviderError from credentials.apps.badges.models import BadgeRequirement, BadgeTemplate @@ -132,7 +132,7 @@ def test_clean_with_invalid_organization(self): ) as mock_get_orgs: mock_get_orgs.return_value = {"test_uuid": "test_org"} - with self.assertRaises(forms.ValidationError) as cm: + with self.assertRaises(BadgeProviderError) as cm: form.clean() self.assertIn("You specified an invalid authorization token.", str(cm.exception)) @@ -170,13 +170,13 @@ def test_ensure_organization_exists(self): def test_ensure_organization_exists_with_error(self): form = CredlyOrganizationAdminForm() api_client = MagicMock() - api_client.fetch_organization.side_effect = CredlyAPIError("API Error") + api_client.fetch_organization.side_effect = BadgeProviderError("API Error") - with self.assertRaises(forms.ValidationError) as cm: + with self.assertRaises(BadgeProviderError) as cm: form.ensure_organization_exists(api_client) api_client.fetch_organization.assert_called_once() - self.assertEqual(str(cm.exception), "['API Error']") + self.assertEqual(str(cm.exception), "API Error") class TestParentMixin(ParentMixin): diff --git a/credentials/apps/badges/tests/test_issuers.py b/credentials/apps/badges/tests/test_issuers.py index 787f2ba27..5f9e3a427 100644 --- a/credentials/apps/badges/tests/test_issuers.py +++ b/credentials/apps/badges/tests/test_issuers.py @@ -6,10 +6,18 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase +from credentials.apps.badges.accredible.api_client import AccredibleAPIClient 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.badges.exceptions import BadgeProviderError +from credentials.apps.badges.issuers import AccredibleBadgeTemplateIssuer, CredlyBadgeTemplateIssuer +from credentials.apps.badges.models import ( + AccredibleAPIConfig, + AccredibleBadge, + AccredibleGroup, + CredlyBadge, + CredlyBadgeTemplate, + CredlyOrganization, +) from credentials.apps.credentials.constants import UserCredentialStatus @@ -126,10 +134,10 @@ def test_issue_credly_badge_with_error(self): # 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 + mock_issue_badge.side_effect = BadgeProviderError # Call the issue_credly_badge method and expect CredlyAPIError to be raised - with self.assertRaises(CredlyAPIError): + with self.assertRaises(BadgeProviderError): self.issuer().issue_credly_badge(user_credential=user_credential) # Check if the user credential state is updated to "error" @@ -154,7 +162,7 @@ def test_revoke_credly_badge_success(self, mock_revoke_badge): user_credential.refresh_from_db() self.assertEqual(user_credential.state, "revoked") - @patch.object(CredlyAPIClient, "revoke_badge", side_effect=CredlyAPIError("Revocation failed")) + @patch.object(CredlyAPIClient, "revoke_badge", side_effect=BadgeProviderError("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", @@ -165,8 +173,151 @@ def test_revoke_credly_badge_failure(self, mock_revoke_badge): # pylint: disabl external_uuid=self.fake.uuid4(), ) - with self.assertRaises(CredlyAPIError): + with self.assertRaises(BadgeProviderError): self.issuer().revoke_credly_badge(self.badge_template.id, user_credential) user_credential.refresh_from_db() self.assertEqual(user_credential.state, "error") + + +class AccredibleBadgeTemplateIssuerTestCase(TestCase): + issued_credential_type = AccredibleGroup + issued_user_credential_type = AccredibleBadge + issuer = AccredibleBadgeTemplateIssuer + + def setUp(self): + self.fake = faker.Faker() + self.accredible_api_config = AccredibleAPIConfig.objects.create( + api_key=self.fake.uuid4(), name=self.fake.word() + ) + self.group = 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", + api_config=self.accredible_api_config, + ) + User.objects.create_user(username="test_user", email="test_email@example.com", password="test_password") + + def _perform_request(self, method, endpoint, data=None): # pylint: disable=unused-argument + return {"credential": {"id": 123}} + + def test_create_user_credential_awarded(self): + with mock.patch("credentials.apps.badges.issuers.notify_badge_awarded") as mock_notify_badge_awarded: + with mock.patch.object(self.issuer, "issue_accredible_badge") as mock_issue_accredible_badge: + self.issuer().award(credential_id=self.group.id, username="test_user") + + mock_notify_badge_awarded.assert_called_once() + mock_issue_accredible_badge.assert_called_once() + + self.assertTrue( + self.issued_user_credential_type.objects.filter( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.group), + credential_id=self.group.id, + ).exists() + ) + + def test_create_user_credential_revoked(self): + self.issued_user_credential_type.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.group), + credential_id=self.group.id, + state=AccredibleBadge.STATES.accepted, + uuid=self.fake.uuid4(), + external_id=123, + ) + + with mock.patch("credentials.apps.badges.issuers.notify_badge_revoked") as mock_notify_badge_revoked: + with mock.patch.object(self.issuer, "revoke_accredible_badge") as mock_revoke_accredible_badge: + self.issuer().revoke(self.group.id, "test_user") + + mock_revoke_accredible_badge.assert_called_once() + mock_notify_badge_revoked.assert_called_once() + + self.assertTrue( + self.issued_user_credential_type.objects.filter( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.group), + credential_id=self.group.id, + status=UserCredentialStatus.REVOKED, + ).exists() + ) + + @patch.object(AccredibleAPIClient, "perform_request", _perform_request) + def test_issue_accredible_badge(self): + user_credential = self.issued_user_credential_type.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.group), + credential_id=self.group.id, + state=AccredibleBadge.STATES.accepted, + uuid=self.fake.uuid4(), + external_id=123, + ) + + self.issuer().issue_accredible_badge(user_credential=user_credential) + + self.assertIsNotNone(user_credential.external_id) + self.assertEqual(user_credential.state, AccredibleBadge.STATES.accepted) + + user_credential.refresh_from_db() + self.assertIsNotNone(user_credential.external_id) + self.assertEqual(user_credential.state, AccredibleBadge.STATES.accepted) + + def test_issue_accredible_badge_with_error(self): + user_credential = self.issued_user_credential_type.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.group), + credential_id=self.group.id, + state=AccredibleBadge.STATES.accepted, + uuid=self.fake.uuid4(), + external_id=123, + ) + + with mock.patch( + "credentials.apps.badges.accredible.api_client.AccredibleAPIClient" + ) as mock_accredible_api_client: + mock_issue_badge = mock_accredible_api_client.return_value.issue_badge + mock_issue_badge.side_effect = BadgeProviderError + + with self.assertRaises(BadgeProviderError): + self.issuer().issue_accredible_badge(user_credential=user_credential) + + user_credential.refresh_from_db() + self.assertEqual(user_credential.state, "error") + + @patch.object(AccredibleAPIClient, "revoke_badge") + def test_revoke_accredible_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.group), + credential_id=self.group.id, + state=AccredibleBadge.STATES.accepted, + uuid=self.fake.uuid4(), + external_id=123, + ) + + mock_revoke_badge.return_value = {"credential": {"id": 123}} + + self.issuer().revoke_accredible_badge(self.group.id, user_credential) + + user_credential.refresh_from_db() + self.assertEqual(user_credential.state, "expired") + + @patch.object(AccredibleAPIClient, "revoke_badge", side_effect=BadgeProviderError("Revocation failed")) + def test_revoke_accredible_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.group), + credential_id=self.group.id, + state=AccredibleBadge.STATES.accepted, + uuid=self.fake.uuid4(), + external_id=123, + ) + + with self.assertRaises(BadgeProviderError): + self.issuer().revoke_accredible_badge(self.group.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 index 2209bdbd3..963819498 100644 --- a/credentials/apps/badges/tests/test_management_commands.py +++ b/credentials/apps/badges/tests/test_management_commands.py @@ -4,7 +4,7 @@ from django.core.management import call_command from django.test import TestCase -from credentials.apps.badges.models import CredlyOrganization +from credentials.apps.badges.models import AccredibleAPIConfig, CredlyOrganization class TestSyncOrganizationBadgeTemplatesCommand(TestCase): @@ -26,3 +26,24 @@ 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) + + +class TestSyncAccredibleGroupsCommand(TestCase): + def setUp(self): + self.faker = faker.Faker() + self.api_config = AccredibleAPIConfig.objects.create(api_key=self.faker.uuid4(), name=self.faker.word()) + AccredibleAPIConfig.objects.bulk_create([AccredibleAPIConfig(api_key=self.faker.uuid4()) for _ in range(5)]) + + @mock.patch("credentials.apps.badges.management.commands.sync_accredible_groups.AccredibleAPIClient") + def test_handle_no_arguments(self, mock_accredible_api_client): + call_command("sync_accredible_groups") + self.assertEqual(mock_accredible_api_client.call_count, AccredibleAPIConfig.objects.all().count()) + self.assertEqual( + mock_accredible_api_client.return_value.sync_groups.call_count, AccredibleAPIConfig.objects.all().count() + ) + + @mock.patch("credentials.apps.badges.management.commands.sync_accredible_groups.AccredibleAPIClient") + def test_handle_with_api_config_id(self, mock_accredible_api_client): + call_command("sync_accredible_groups", "--api_config_id", self.api_config.id) + mock_accredible_api_client.assert_called_once_with(1) + mock_accredible_api_client.return_value.sync_groups.assert_called_once_with(1) diff --git a/credentials/apps/badges/tests/test_models.py b/credentials/apps/badges/tests/test_models.py index cd123b921..879192f01 100644 --- a/credentials/apps/badges/tests/test_models.py +++ b/credentials/apps/badges/tests/test_models.py @@ -9,6 +9,9 @@ from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData from credentials.apps.badges.models import ( + AccredibleAPIConfig, + AccredibleBadge, + AccredibleGroup, BadgePenalty, BadgeProgress, BadgeRequirement, @@ -734,3 +737,84 @@ def test_is_active(self): self.requirement.template.is_active = False self.assertFalse(self.rule.is_active) + + +class AccredibleGroupTestCase(TestCase): + def setUp(self): + self.api_config = AccredibleAPIConfig.objects.create(api_key="test-api-key", name="test_config") + self.site = Site.objects.create(domain="test_domain", name="test_name") + self.group = AccredibleGroup.objects.create( + api_config=self.api_config, + id=55, + name="test_group", + site=self.site, + ) + + def test_management_url(self): + credly_host_base_url = "https://example.com/" + with patch("credentials.apps.badges.models.get_accredible_base_url") as mock_get_accredible_base_url: + mock_get_accredible_base_url.return_value = credly_host_base_url + expected_url = f"{credly_host_base_url}issuer/dashboard/group/{self.group.id}/information-and-appearance" + self.assertEqual(self.group.management_url, expected_url) + mock_get_accredible_base_url.assert_called_with(settings) + + +class AccredibleAPIConfigTestCase(TestCase): + def setUp(self): + self.api_config = AccredibleAPIConfig.objects.create( + api_key="test-api-key", + name="test_config", + ) + + def test_get_all_api_config_ids(self): + organization_ids = list(AccredibleAPIConfig.get_all_api_config_ids()) + self.assertEqual(organization_ids, [self.api_config.id]) + + +class AccredibleBadgeAsBadgeDataTestCase(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 = AccredibleBadge.objects.create( + username="test_user", + credential_content_type=ContentType.objects.get_for_model(self.credential), + credential_id=self.credential.id, + state=AccredibleBadge.STATES.created, + 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) diff --git a/credentials/apps/badges/tests/test_signals.py b/credentials/apps/badges/tests/test_signals.py index 0a79663b4..ed3d2b3d8 100644 --- a/credentials/apps/badges/tests/test_signals.py +++ b/credentials/apps/badges/tests/test_signals.py @@ -28,6 +28,7 @@ def test_progression_signal_emission_and_receiver_execution(self): sender=self, username="test_user", badge_template_id=self.badge_template.id, + origin=self.badge_template.origin, ) # UserCredential object @@ -51,6 +52,7 @@ def test_regression_signal_emission_and_receiver_execution(self): sender=self, username="test_user", badge_template_id=self.badge_template.id, + origin=self.badge_template.origin, ) # UserCredential object diff --git a/credentials/apps/credentials/migrations/0031_alter_usercredential_credential_content_type.py b/credentials/apps/credentials/migrations/0031_alter_usercredential_credential_content_type.py new file mode 100644 index 000000000..1e039ecbd --- /dev/null +++ b/credentials/apps/credentials/migrations/0031_alter_usercredential_credential_content_type.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.17 on 2024-12-19 11:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('credentials', '0030_revoke_certificates_management_command'), + ] + + operations = [ + migrations.AlterField( + model_name='usercredential', + name='credential_content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ('coursecertificate', 'programcertificate', 'credlybadgetemplate', 'accrediblegroup')}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + ), + ] diff --git a/credentials/apps/credentials/models.py b/credentials/apps/credentials/models.py index 85e1dac9e..313932d30 100644 --- a/credentials/apps/credentials/models.py +++ b/credentials/apps/credentials/models.py @@ -169,7 +169,9 @@ class UserCredential(TimeStampedModel): credential_content_type = models.ForeignKey( ContentType, - limit_choices_to={"model__in": ("coursecertificate", "programcertificate", "credlybadgetemplate")}, + limit_choices_to={ + "model__in": ("coursecertificate", "programcertificate", "credlybadgetemplate", "accrediblegroup") + }, on_delete=models.CASCADE, ) credential_id = models.PositiveIntegerField() diff --git a/credentials/apps/credentials/tests/test_api.py b/credentials/apps/credentials/tests/test_api.py index ddb8de1c0..34a6efedd 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" - # 22 is the content type for "Course Certificate" - assert credential.credential_content_type_id == 22 + # 25 is the content type for "Course Certificate" + assert credential.credential_content_type_id == 25 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" - # 22 is the content type for "Course Certificate" - assert credential.credential_content_type_id == 22 + # 25 is the content type for "Course Certificate" + assert credential.credential_content_type_id == 25 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" - # 22 is the content type for "Course Certificate" - assert credential.credential_content_type_id == 22 + # 25 is the content type for "Course Certificate" + assert credential.credential_content_type_id == 25 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 13fa51d52..230449428 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 b87bed099..a074ad03c 100644 --- a/credentials/conf/locale/eo/LC_MESSAGES/django.po +++ b/credentials/conf/locale/eo/LC_MESSAGES/django.po @@ -1,7 +1,7 @@ # edX translation file. -# Copyright (C) 2024 EdX +# Copyright (C) 2025 EdX # This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. -# EdX Team , 2024. +# EdX Team , 2025. # msgid "" msgstr "" @@ -77,6 +77,18 @@ msgstr "" msgid "badge template" msgstr "ßädgé témpläté Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#" +#: apps/badges/admin.py +msgid "Failed to sync groups for API config: {}. {}" +msgstr "" +"Fäïléd tö sýnç gröüps för ÀPÌ çönfïg: {}. {} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя #" + +#: apps/badges/admin.py +msgid "Accredible groups were successfully updated." +msgstr "" +"Àççrédïßlé gröüps wéré süççéssfüllý üpdätéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя #" + #: apps/badges/admin_forms.py msgid "You can't provide an API key for a configured organization." msgstr "" @@ -264,6 +276,28 @@ msgstr "Çrédlý ßädgé ïssüïng stäté Ⱡ'σяєм ιρѕυм ∂σłσ msgid "Credly service badge identifier" msgstr "Çrédlý sérvïçé ßädgé ïdéntïfïér Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" +#: apps/badges/models.py +msgid "Accredible API configuration name." +msgstr "" +"Àççrédïßlé ÀPÌ çönfïgürätïön nämé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєт#" + +#: apps/badges/models.py +msgid "Accredible API key." +msgstr "Àççrédïßlé ÀPÌ kéý. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#" + +#: apps/badges/models.py +msgid "Accredible API configuration." +msgstr "Àççrédïßlé ÀPÌ çönfïgürätïön. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#" + +#: apps/badges/models.py +msgid "Accredible badge issuing state" +msgstr "Àççrédïßlé ßädgé ïssüïng stäté Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" + +#: apps/badges/models.py +msgid "Accredible service badge identifier" +msgstr "" +"Àççrédïßlé 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/eo/LC_MESSAGES/djangojs.po b/credentials/conf/locale/eo/LC_MESSAGES/djangojs.po index 69b8ed15c..a076364d7 100644 --- a/credentials/conf/locale/eo/LC_MESSAGES/djangojs.po +++ b/credentials/conf/locale/eo/LC_MESSAGES/djangojs.po @@ -1,7 +1,7 @@ # edX translation file. -# Copyright (C) 2024 EdX +# Copyright (C) 2025 EdX # This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. -# EdX Team , 2024. +# EdX Team , 2025. # msgid "" msgstr "" diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/django.mo b/credentials/conf/locale/rtl/LC_MESSAGES/django.mo index 24b820aae..281448727 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 5a64fdeda..ddc422f24 100644 --- a/credentials/conf/locale/rtl/LC_MESSAGES/django.po +++ b/credentials/conf/locale/rtl/LC_MESSAGES/django.po @@ -1,7 +1,7 @@ # edX translation file. -# Copyright (C) 2024 EdX +# Copyright (C) 2025 EdX # This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. -# EdX Team , 2024. +# EdX Team , 2025. # msgid "" msgstr "" @@ -67,6 +67,14 @@ msgstr "Ⱥɔʇᴉʌǝ bɐdƃǝ ʇǝɯdlɐʇǝ ɯnsʇ ɥɐʌǝ ɐʇ lǝɐsʇ øn msgid "badge template" msgstr "bɐdƃǝ ʇǝɯdlɐʇǝ" +#: apps/badges/admin.py +msgid "Failed to sync groups for API config: {}. {}" +msgstr "Fɐᴉlǝd ʇø sʎnɔ ƃɹønds ɟøɹ ȺⱣƗ ɔønɟᴉƃ: {}. {}" + +#: apps/badges/admin.py +msgid "Accredible groups were successfully updated." +msgstr "Ⱥɔɔɹǝdᴉblǝ ƃɹønds ʍǝɹǝ snɔɔǝssɟnllʎ nddɐʇǝd." + #: 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." @@ -219,6 +227,26 @@ msgstr "Ȼɹǝdlʎ bɐdƃǝ ᴉssnᴉnƃ sʇɐʇǝ" msgid "Credly service badge identifier" msgstr "Ȼɹǝdlʎ sǝɹʌᴉɔǝ bɐdƃǝ ᴉdǝnʇᴉɟᴉǝɹ" +#: apps/badges/models.py +msgid "Accredible API configuration name." +msgstr "Ⱥɔɔɹǝdᴉblǝ ȺⱣƗ ɔønɟᴉƃnɹɐʇᴉøn nɐɯǝ." + +#: apps/badges/models.py +msgid "Accredible API key." +msgstr "Ⱥɔɔɹǝdᴉblǝ ȺⱣƗ ʞǝʎ." + +#: apps/badges/models.py +msgid "Accredible API configuration." +msgstr "Ⱥɔɔɹǝdᴉblǝ ȺⱣƗ ɔønɟᴉƃnɹɐʇᴉøn." + +#: apps/badges/models.py +msgid "Accredible badge issuing state" +msgstr "Ⱥɔɔɹǝdᴉblǝ bɐdƃǝ ᴉssnᴉnƃ sʇɐʇǝ" + +#: apps/badges/models.py +msgid "Accredible service badge identifier" +msgstr "Ⱥɔɔɹǝdᴉblǝ sǝɹʌᴉɔǝ bɐdƃǝ ᴉdǝnʇᴉɟᴉǝɹ" + #: apps/core/admin.py msgid "Activate selected entries" msgstr "Ⱥɔʇᴉʌɐʇǝ sǝlǝɔʇǝd ǝnʇɹᴉǝs" diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.po b/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.po index 011e6c391..5b58270f4 100644 --- a/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.po +++ b/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.po @@ -1,7 +1,7 @@ # edX translation file. -# Copyright (C) 2024 EdX +# Copyright (C) 2025 EdX # This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. -# EdX Team , 2024. +# EdX Team , 2025. # msgid "" msgstr "" diff --git a/credentials/settings/base.py b/credentials/settings/base.py index 6fdbbde9e..ec20a3bba 100644 --- a/credentials/settings/base.py +++ b/credentials/settings/base.py @@ -567,6 +567,13 @@ "CREDLY_SANDBOX_API_BASE_URL": "https://sandbox-api.credly.com/v1/", "USE_SANDBOX": False, }, + "accredible": { + "ACCREDIBLE_BASE_URL": "https://dashboard.accredible.com/", + "ACCREDIBLE_API_BASE_URL": "https://api.accredible.com/v1/", + "ACCREDIBLE_SANDBOX_BASE_URL": "https://sandbox.dashboard.accredible.com/", + "ACCREDIBLE_SANDBOX_API_BASE_URL": "https://sandbox.api.accredible.com/v1/", + "USE_SANDBOX": False, + }, "rules": { "ignored_keypaths": [ "user.id", diff --git a/docs/_static/images/badges/badges-admin-groups-sync.png b/docs/_static/images/badges/badges-admin-groups-sync.png new file mode 100644 index 000000000..e6642517e Binary files /dev/null and b/docs/_static/images/badges/badges-admin-groups-sync.png differ diff --git a/docs/_static/images/badges/badges-admin.png b/docs/_static/images/badges/badges-admin.png index e05d8e9c5..f37a2531c 100644 Binary files a/docs/_static/images/badges/badges-admin.png and b/docs/_static/images/badges/badges-admin.png differ diff --git a/docs/badges/configuration/accredible.rst b/docs/badges/configuration/accredible.rst new file mode 100644 index 000000000..99b7502a7 --- /dev/null +++ b/docs/badges/configuration/accredible.rst @@ -0,0 +1,173 @@ +Accredible Configuration +======================== + +.. note:: + + This section provides information on how and where to set up accredible badge groups and configuration. + +The Badges feature is configured in the Credentials admin panel. + +.. image:: ../../_static/images/badges/badges-admin.png + :alt: Badges administration + +Accredible API Configurations +----------------------------- + +Multiple Accredible API Configurations can be configured. + +**All communication between Open edX Credentials and Accredible service happens on behalf of a Accredible API config.** + +Go to the Accredible API Configs section in the admin panel and create a new item: + +1. to set the name for config; +2. to set the api key, used to sync the Accredible account. + +In case of errors, check the credentials used for the API Config + +Groups +--------------- + +*Accredible groups* (badge templates for short) are created in the Accredible dashboard and then, they are retrieved by the Credentials via API. + +Synchronization +~~~~~~~~~~~~~~~ + +To synchronize Accredible groups for the API Configuration one should: + +- navigate "Accredible API Configs" list page; +- select the API Config; +- use ``Sync groups`` action; + +.. image:: ../../_static/images/badges/badges-admin-groups-sync.png + :alt: Accredible groups synchronization + +On success, the system will update the list of Accredible groups: + +- Accredible group records are created inactive (disabled); + +For a group 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 group configuration. +At least one badge requirement must be associated with a group. + +Badge Requirements are listed inline on a group 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 group can be activated: + +- navigate to the group detail page; +- check ``Is active`` checkbox; + + Activated groups starts "working" immediately. + +Accredible group record includes: + +1. Core credential attributes; +2. Badge template credential attributes; +3. Accredible service attributes (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/configuration.rst b/docs/badges/configuration/credly.rst similarity index 90% rename from docs/badges/configuration.rst rename to docs/badges/configuration/credly.rst index 04f30e1cc..f4bd7d7b0 100644 --- a/docs/badges/configuration.rst +++ b/docs/badges/configuration/credly.rst @@ -1,5 +1,5 @@ -Configuration -============= +Credly Configuration +==================== .. note:: @@ -7,7 +7,7 @@ Configuration The Badges feature is configured in the Credentials admin panel. -.. image:: ../_static/images/badges/badges-admin.png +.. image:: ../../_static/images/badges/badges-admin.png :alt: Badges administration Credly Organizations @@ -53,7 +53,7 @@ To synchronize Credly badge templates for the Organization one should: - select the Organization; - use ``Sync organization badge templates`` action; -.. image:: ../_static/images/badges/badges-admin-credly-templates-sync.png +.. 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: @@ -61,7 +61,7 @@ On success, the system will update the list of Credly badge templates for the Or - 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 +.. 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. @@ -76,7 +76,7 @@ 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 +.. 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. @@ -114,7 +114,7 @@ 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 +.. image:: ../../_static/images/badges/badges-admin-rules-group.png :alt: Badge requirement rules group See `configuration examples`_. @@ -131,14 +131,14 @@ 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 +.. 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 +.. image:: ../../_static/images/badges/badges-admin-data-rules.png :alt: Badge requirement data rules 1. **Key path** - payload path to the target attribute @@ -168,7 +168,7 @@ A penalty setup is similar to a badge requirement, but has different effect: it 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 +.. image:: ../../_static/images/badges/badges-admin-penalty-rules.png :alt: Badge penalty rules edit Activation @@ -181,7 +181,7 @@ Configured badge template can be activated: Activated badge template starts "working" immediately. -.. image:: ../_static/images/badges/badges-admin-template-details.png +.. image:: ../../_static/images/badges/badges-admin-template-details.png :alt: Badge template data structure Credly badge template record includes: diff --git a/docs/badges/configuration/index.rst b/docs/badges/configuration/index.rst new file mode 100644 index 000000000..02c2588cb --- /dev/null +++ b/docs/badges/configuration/index.rst @@ -0,0 +1,10 @@ +Configuration +============= + +.. toctree:: + :maxdepth: 1 + + credly + accredible + +.. _Credly (by Pearson): https://info.credly.com/ \ No newline at end of file diff --git a/docs/badges/index.rst b/docs/badges/index.rst index eea58b2f0..46bc09dd9 100644 --- a/docs/badges/index.rst +++ b/docs/badges/index.rst @@ -26,11 +26,11 @@ Glossary ---- .. toctree:: - :maxdepth: 1 + :maxdepth: 2 quickstart settings - configuration + configuration/index examples processing diff --git a/docs/badges/processing.rst b/docs/badges/processing.rst index 497bbfaa2..e08e25ca0 100644 --- a/docs/badges/processing.rst +++ b/docs/badges/processing.rst @@ -99,4 +99,6 @@ Badges can also be revoked. Its a separete set of rules that need to be set up. .. _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 +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. + +The badge cannot be reissued once it has been revoked. diff --git a/docs/badges/quickstart.rst b/docs/badges/quickstart.rst index dfbac1752..4abf856e0 100644 --- a/docs/badges/quickstart.rst +++ b/docs/badges/quickstart.rst @@ -2,18 +2,27 @@ 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 ---------------------------------- +Currently Open edX supports two badge services: Credly and Accredible. + +0. Prerequisites – service account +---------------------------------- + +To start using this feature a Credly or Accredible account is necessary. -To start using this feature a Credly account is necessary. +For Credly: 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 + + +For Accredible: + +1. Register on Accredible and create your account. +2. Create at least 1 group. 1. Enable feature ----------------- @@ -28,14 +37,17 @@ Badges feature is optional and it is disabled by default. So, it must be enabled # Credentials service: BADGES_ENABLED = True -2. Configure Credly integration +2. Configure 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: +Go to the Credentials service admin panel and configure the integration with the service: + +Credly +------ 1. In the admin panel go to /admin/badges/credly_organization to add Credly Organization. a. Add UUID (unique identifier) for the Credly organization @@ -45,6 +57,14 @@ Please note that UUID and authorization token will be given to you during the cr Check: the system pulls the Organization's data and updates its name. +Accredible +----------- + +1. Retrieve API Key from Accredible account settings. Go to the Accredible account settings -> Manage API Keys and create a new API Key. +2. In the admin panel go to ``/admin/badges/accredibleapiconfig`` to add Accredible Group. + a. Add API Key + b. Add name for configuration + .. _Configuration: configuration.html @@ -52,10 +72,19 @@ Check: the system pulls the Organization's data and updates its name. ------------------------------ Note: For detailed information, go to the `Configuration`_ section. +Credly +------ + 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. +Accredible +---------- +From the Accredible API Configurations list, select the Configuration(s) you want to use and select ``Sync groups`` action. + +The system pulls the list of groups from the Accredible account. Navigate to the “Accredible groups” list and check newly created groups. + .. _Configuration: configuration.html 4. Setup badge requirements @@ -67,7 +96,7 @@ The system pulls the list of badge templates from the Credly Organization. Navig 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: +Go to the first badge template details page (admin/badges/credly_badge_templates or admin/badges/accrediblegroup) 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); @@ -120,29 +149,30 @@ Since badge templates can have more than one requirement, there can be partially 7. See awarded user credentials ------------------------------- -Already earned badges are listed in the "Credly badges" section of the admin panel. +Already earned badges are listed in the "Credly badges" or "Accredible badges" section of the admin panel. .. note:: - The Credly Badge is an extended version of a user credential record. + This 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); +1. creates internal user credentials (CredlyBadge or AccredibleBadge); 2. notifies about the badge awarding (public signal); -3. requests Credly service to issue the badge (API request). +3. requests Credly or Accredible service to issue the badge (API request). -8. See issued Credly badges +8. See issued badges --------------------------- -Earned internal badges (user credentials) spread to the Credly service. +Earned internal badges (user credentials) spread to the badge service. -On a successful Credly badge issuing, the CredlyBadge user credential is updated with its requisites: +On a successful badge issuing, the CredlyBadge or AccredibleBadge user credential is updated with its requisites: 1. external UUID; 2. external state; The Credly badge is visible in the Credly service. +The Accredible badge is visible in the Accredible service. 9. Badge template withdrawal diff --git a/docs/badges/settings.rst b/docs/badges/settings.rst index 093dd8ad4..c8a6d2d7f 100644 --- a/docs/badges/settings.rst +++ b/docs/badges/settings.rst @@ -11,6 +11,10 @@ Badges feature settings allow configuration: 2. event bus public signals subset for badges; 3. the Credly service integration details (URLs, sandbox usage, etc.); +You can use tutor plugin to setup all needed configurations: + +https://github.com/raccoongang/tutor-contrib-badges + Feature switch -------------- @@ -50,6 +54,15 @@ The feature has its configuration: "CREDLY_SANDBOX_API_BASE_URL": "https://sandbox-api.credly.com/v1/", "USE_SANDBOX": False, }, + # Accredible integration: + "accredible": { + "ACCREDIBLE_BASE_URL": "https://dashboard.accredible.com/", + "ACCREDIBLE_API_BASE_URL": "https://api.accredible.com/v1/", + "ACCREDIBLE_SANDBOX_BASE_URL": "https://sandbox.dashboard.accredible.com/", + "ACCREDIBLE_SANDBOX_API_BASE_URL": "https://sandbox.api.accredible.com/v1/", + "USE_SANDBOX": False, + }, + # requirements data rules: "rules": { "ignored_keypaths": [ @@ -75,6 +88,12 @@ Credly integration - CREDLY_SANDBOX_BASE_URL - Credly sandbox host URL; - CREDLY_SANDBOX_API_BASE_URL - Credly sandbox API host URL; +Accredible integration +~~~~~~~~~~~~~~~~~~~~~~ +- USE_SANDBOX - enables Accredible sandbox usage (development, testing); +- ACCREDIBLE_BASE_URL - Accredible service host URL; +- ACCREDIBLE_API_BASE_URL - Accredible API host URL; +- ACCREDIBLE_SANDBOX_BASE_URL - Accredible sandbox host URL; Event bus settings ------------------