From c0f5ceab89a410f1dc866b07e5055ec0dc453dda Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Wed, 18 Dec 2024 14:40:54 +0200 Subject: [PATCH] feat: [AXM-1227, AXM-1235, AXM-1242] accredible models, client and syncing groups --- .../apps/badges/accredible/api_client.py | 108 +++++++++++ credentials/apps/badges/accredible/data.py | 57 ++++++ credentials/apps/badges/accredible/utils.py | 42 +++++ credentials/apps/badges/admin.py | 175 ++++++++++++++++++ .../commands/sync_accredible_groups.py | 57 ++++++ ...iconfig_accrediblebadge_accrediblegroup.py | 113 +++++++++++ ...template_icon_alter_badgetemplate_state.py | 31 ++++ credentials/apps/badges/models.py | 79 +++++++- credentials/settings/base.py | 7 + 9 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 credentials/apps/badges/accredible/api_client.py create mode 100644 credentials/apps/badges/accredible/data.py create mode 100644 credentials/apps/badges/accredible/utils.py create mode 100644 credentials/apps/badges/management/commands/sync_accredible_groups.py create mode 100644 credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_accrediblegroup.py create mode 100644 credentials/apps/badges/migrations/0003_alter_badgetemplate_icon_alter_badgetemplate_state.py diff --git a/credentials/apps/badges/accredible/api_client.py b/credentials/apps/badges/accredible/api_client.py new file mode 100644 index 000000000..83f3bf067 --- /dev/null +++ b/credentials/apps/badges/accredible/api_client.py @@ -0,0 +1,108 @@ +import logging +from django.conf import settings +from django.contrib.sites.models import Site +from attrs import asdict + +from credentials.apps.badges.models import AccredibleAPIConfig, AccredibleGroup +from credentials.apps.badges.base_api_client import BaseBadgeProviderClient +from credentials.apps.badges.accredible.data import AccredibleBadgeData, AccredibleExpireBadgeData +from credentials.apps.badges.accredible.utils import get_accredible_api_base_url + + +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: AccredibleAPIConfig): + """ + Initializes a AccredibleAPIClient object. + + Args: + api_config (AccredibleAPIConfig): Configuration object for the Accredible API. + """ + self.api_config = api_config + + 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 Credly. + """ + 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", []) + + 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")), + }, + ) + + 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..a8dd67207 --- /dev/null +++ b/credentials/apps/badges/accredible/data.py @@ -0,0 +1,57 @@ +import attr +from datetime import datetime + + +@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. + description (str): Description of the credential. + issued_on (datetime): Date when the credential was issued. + complete (bool): Whether the credential process is complete. + """ + + recipient: AccredibleRecipient + group_id: int + name: str + description: str + issued_on: datetime + complete: bool + +@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/utils.py b/credentials/apps/badges/accredible/utils.py new file mode 100644 index 000000000..9ea093ca1 --- /dev/null +++ b/credentials/apps/badges/accredible/utils.py @@ -0,0 +1,42 @@ +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. + """ + + credly_config = settings.BADGES_CONFIG["accredible"] + + if credly_config.get("USE_SANDBOX"): + return credly_config["ACCREDIBLE_SANDBOX_BASE_URL"] + + return credly_config["ACCREDIBLE_BASE_URL"] diff --git a/credentials/apps/badges/admin.py b/credentials/apps/badges/admin.py index bdf43756b..0852354ee 100644 --- a/credentials/apps/badges/admin.py +++ b/credentials/apps/badges/admin.py @@ -31,6 +31,9 @@ DataRule, Fulfillment, PenaltyDataRule, + AccredibleAPIConfig, + AccredibleBadge, + AccredibleGroup, ) from credentials.apps.badges.toggles import is_badges_enabled @@ -541,6 +544,175 @@ 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: + call_command( + "sync_accredible_groups", + api_config_id=api_config.id, + site_id=site.id, + ) + + 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 +721,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/management/commands/sync_accredible_groups.py b/credentials/apps/badges/management/commands/sync_accredible_groups.py new file mode 100644 index 000000000..f6fe6e276 --- /dev/null +++ b/credentials/apps/badges/management/commands/sync_accredible_groups.py @@ -0,0 +1,57 @@ +import logging + +from django.core.management.base import BaseCommand + +from credentials.apps.badges.accredible.api_client import AccredibleAPIClient +from credentials.apps.badges.models import AccredibleAPIConfig + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Sync badge templates for a specific organization or all organizations" + + def add_arguments(self, parser): + parser.add_argument("--site_id", type=int, help="Site ID.") + parser.add_argument("--api_config_id", type=str, help="ID of the API config.") + + def handle(self, *args, **options): + """ + Sync groups for a specific accredible api config or all configs. + + Usage: + site_id=1 + api_config_id=1 + + ./manage.py sync_organization_badge_templates --site_id $site_id + ./manage.py sync_organization_badge_templates --site_id $site_id --api_config_id $api_config_id + """ + 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: + logger.warning(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) + logger.info(f"Syncing groups for the single config: {api_config_id}") + else: + api_configs_to_sync = AccredibleAPIConfig.get_all_api_config_ids() + logger.info( + "API Config ID wasn't provided: syncing groups for all configs - " + f"{api_configs_to_sync}", + ) + + for api_config_id in api_configs_to_sync: + api_config = AccredibleAPIConfig.objects.get(id=api_config_id) + accredible_api_client = AccredibleAPIClient(api_config) + processed_items = accredible_api_client.sync_groups(site_id) + + logger.info(f"API Config {api_config_id}: got {processed_items} groups.") + + logger.info("...completed!") diff --git a/credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_accrediblegroup.py b/credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_accrediblegroup.py new file mode 100644 index 000000000..ee032e949 --- /dev/null +++ b/credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_accrediblegroup.py @@ -0,0 +1,113 @@ +# Generated by Django 4.2.16 on 2024-12-18 11:15 + +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.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_badgetemplate_icon_alter_badgetemplate_state.py b/credentials/apps/badges/migrations/0003_alter_badgetemplate_icon_alter_badgetemplate_state.py new file mode 100644 index 000000000..7ec10726b --- /dev/null +++ b/credentials/apps/badges/migrations/0003_alter_badgetemplate_icon_alter_badgetemplate_state.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.16 on 2024-12-18 13:09 + +from django.db import migrations, models +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("badges", "0002_accredibleapiconfig_accrediblebadge_accrediblegroup"), + ] + + operations = [ + 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, + ), + ), + ] diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py index 84ad3a3e9..174eb2309 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): @@ -675,3 +677,78 @@ 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=True) + 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"), + ) 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",