diff --git a/credentials/apps/badges/accredible/api_client.py b/credentials/apps/badges/accredible/api_client.py index 83f3bf067..b7e4a827e 100644 --- a/credentials/apps/badges/accredible/api_client.py +++ b/credentials/apps/badges/accredible/api_client.py @@ -7,6 +7,7 @@ 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 +from credentials.apps.badges.accredible.exceptions import AccredibleError logger = logging.getLogger(__name__) @@ -18,24 +19,38 @@ class AccredibleAPIClient(BaseBadgeProviderClient): This class provides methods for performing various operations on the Accredible API. """ + PROVIDER_NAME = "Accredible" - def __init__(self, api_config: AccredibleAPIConfig): + def __init__(self, api_config_id: int): """ Initializes a AccredibleAPIClient object. Args: api_config (AccredibleAPIConfig): Configuration object for the Accredible API. """ - self.api_config = api_config + + 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: + api_config = AccredibleAPIConfig.objects.get(id=self.api_config_id) + return api_config + 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 Credly. + Returns the headers for making API requests to Accredible. """ + return { "Accept": "application/json", "Content-Type": "application/json", @@ -46,12 +61,14 @@ 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") @@ -62,6 +79,7 @@ def issue_badge(self, issue_badge_data: AccredibleBadgeData) -> dict: 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: @@ -72,6 +90,7 @@ def revoke_badge(self, badge_id, data: AccredibleExpireBadgeData) -> dict: 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: @@ -84,6 +103,7 @@ def sync_groups(self, site_id: int) -> int: Returns: int | None: processed items. """ + try: site = Site.objects.get(id=site_id) except Site.DoesNotExist: @@ -93,6 +113,9 @@ def sync_groups(self, site_id: int) -> int: 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"), diff --git a/credentials/apps/badges/accredible/data.py b/credentials/apps/badges/accredible/data.py index a8dd67207..97af85fa0 100644 --- a/credentials/apps/badges/accredible/data.py +++ b/credentials/apps/badges/accredible/data.py @@ -11,6 +11,7 @@ class AccredibleRecipient: name (str): The recipient's name. email (str): The recipient's email address. """ + name: str email: str @@ -40,6 +41,7 @@ class AccredibleExpiredCredential: """ Represents the data required to expire a credential. """ + expired_on: datetime @attr.s(auto_attribs=True, frozen=True) @@ -47,6 +49,7 @@ class AccredibleBadgeData: """ Represents the data required to issue a badge. """ + credential: AccredibleCredential @attr.s(auto_attribs=True, frozen=True) @@ -54,4 +57,5 @@ 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 index 9ea093ca1..2da5d2e27 100644 --- a/credentials/apps/badges/accredible/utils.py +++ b/credentials/apps/badges/accredible/utils.py @@ -1,3 +1,7 @@ +""" +Accredible utility functions. +""" + def get_accredible_api_base_url(settings) -> str: """ Determines the base URL for the Accredible service based on application settings. diff --git a/credentials/apps/badges/management/commands/sync_accredible_groups.py b/credentials/apps/badges/management/commands/sync_accredible_groups.py index f6fe6e276..b5eaa3ac0 100644 --- a/credentials/apps/badges/management/commands/sync_accredible_groups.py +++ b/credentials/apps/badges/management/commands/sync_accredible_groups.py @@ -1,16 +1,21 @@ -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): + """ + Sync groups for a specific accredible api config or all configs. + Usage: + site_id=1 + api_config_id=1 -class Command(BaseCommand): - help = "Sync badge templates for a specific organization or all organizations" + ./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.") @@ -18,14 +23,7 @@ def add_arguments(self, parser): 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 + Handle the command. """ DEFAULT_SITE_ID = 1 api_configs_to_sync = [] @@ -34,24 +32,23 @@ def handle(self, *args, **options): 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}") + 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) - logger.info(f"Syncing groups for the single config: {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() - logger.info( + self.stdout.write( "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) + 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) - logger.info(f"API Config {api_config_id}: got {processed_items} groups.") + self.stdout.write(f"API Config {api_config_id}: got {processed_items} groups.") - logger.info("...completed!") + self.stdout.write("...completed!") diff --git a/credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_accrediblegroup.py b/credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_accrediblegroup.py deleted file mode 100644 index ee032e949..000000000 --- a/credentials/apps/badges/migrations/0002_accredibleapiconfig_accrediblebadge_accrediblegroup.py +++ /dev/null @@ -1,113 +0,0 @@ -# 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/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_badgetemplate_icon_alter_badgetemplate_state.py b/credentials/apps/badges/migrations/0003_alter_badgetemplate_icon_alter_badgetemplate_state.py deleted file mode 100644 index 7ec10726b..000000000 --- a/credentials/apps/badges/migrations/0003_alter_badgetemplate_icon_alter_badgetemplate_state.py +++ /dev/null @@ -1,31 +0,0 @@ -# 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/tests/accredible/test_api_client.py b/credentials/apps/badges/tests/accredible/test_api_client.py index 3b3b8749d..6e8a27832 100644 --- a/credentials/apps/badges/tests/accredible/test_api_client.py +++ b/credentials/apps/badges/tests/accredible/test_api_client.py @@ -18,7 +18,7 @@ def setUp(self): api_key="test-api-key", name="test_config", ) - self.api_client = AccredibleAPIClient(self.api_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"), @@ -67,6 +67,14 @@ def test_revoke_badge(self): 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 = { @@ -74,6 +82,7 @@ def test_sync_groups(self): } 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) @@ -83,3 +92,4 @@ def test_sync_groups(self): 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/test_management_commands.py b/credentials/apps/badges/tests/test_management_commands.py index 2209bdbd3..5c838a1c9 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 CredlyOrganization, AccredibleAPIConfig 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, 6) + self.assertEqual(mock_accredible_api_client.return_value.sync_groups.call_count, 6) + + @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", 1) + mock_accredible_api_client.assert_called_once_with(1) + mock_accredible_api_client.return_value.sync_groups.assert_called_once_with(1)