From 66504f0031e019ccdf5316c38d61d115885ecde6 Mon Sep 17 00:00:00 2001 From: Sagirov Evgeniy <34642612+UvgenGen@users.noreply.github.com> Date: Thu, 21 Mar 2024 18:09:30 +0200 Subject: [PATCH 1/4] fix: [ACI-585] Credentials event bus consumer issue (#73) Co-authored-by: Sagirov Eugeniy --- credentials/settings/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/credentials/settings/base.py b/credentials/settings/base.py index d8f5e6a39b..9a2431887b 100644 --- a/credentials/settings/base.py +++ b/credentials/settings/base.py @@ -553,24 +553,24 @@ # .. setting_description: Dictionary of event_types mapped to dictionaries of topic to topic-related configuration. EVENT_BUS_PRODUCER_CONFIG = { # .. setting_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.badge.awarded.v1'] - # ['learning-badge-lifecycle']['enabled'] + # ['learning-badges-lifecycle']['enabled'] # .. toggle_implementation: SettingToggle # .. toggle_default: True # .. toggle_description: Enables sending org.openedx.learning.badge.awarded.v1 events over the event bus. # .. toggle_warning: The default may be changed in a later release. # .. toggle_use_cases: opt_in "org.openedx.learning.badge.awarded.v1": { - "learning-badge-lifecycle": {"event_key_field": "badge.uuid", "enabled": True}, + "learning-badges-lifecycle": {"event_key_field": "badge.uuid", "enabled": True}, }, # .. setting_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.badge.revoked.v1'] - # ['learning-badge-lifecycle']['enabled'] + # ['learning-badges-lifecycle']['enabled'] # .. toggle_implementation: SettingToggle # .. toggle_default: True # .. toggle_description: Enables sending org.openedx.learning.badge.revoked.v1 events over the event bus. # .. toggle_warning: The default may be changed in a later release. # .. toggle_use_cases: opt_in "org.openedx.learning.badge.revoked.v1": { - "learning-badge-lifecycle": {"event_key_field": "badge.uuid", "enabled": True}, + "learning-badges-lifecycle": {"event_key_field": "badge.uuid", "enabled": True}, }, } From f435946648178f66b5e10b6c58c416d765f782e6 Mon Sep 17 00:00:00 2001 From: Sagirov Evgeniy <34642612+UvgenGen@users.noreply.github.com> Date: Thu, 21 Mar 2024 23:31:30 +0200 Subject: [PATCH 2/4] fix: [ACI-596] 500 error occurs if a user clicks on the Sync organization badge templates (#74) Co-authored-by: Sagirov Eugeniy --- .../distribution/credly/credly_badges/admin.py | 2 +- .../sync_organization_badge_templates.py | 16 ++++++++++++---- .../distribution/credly/credly_badges/utils.py | 4 ++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/credentials/apps/badges/distribution/credly/credly_badges/admin.py b/credentials/apps/badges/distribution/credly/credly_badges/admin.py index 3e3102efe5..dd8d03becf 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/admin.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/admin.py @@ -33,7 +33,7 @@ def sync_organization_badge_templates(self, request, queryset): Sync badge templates for selected organizations. """ for organization in queryset: - sync_badge_templates_for_organization(organization.uuid) + sync_badge_templates_for_organization(organization.uuid, request.site) class CredlyBadgeTemplateAdmin(admin.ModelAdmin): diff --git a/credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py b/credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py index 1bc6d29813..d8d1358992 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py @@ -2,6 +2,7 @@ from credly_badges.models import CredlyOrganization from credly_badges.utils import sync_badge_templates_for_organization +from django.contrib.sites.models import Site from django.core.management.base import BaseCommand @@ -12,6 +13,7 @@ class Command(BaseCommand): help = "Sync badge templates for a specific organization or all organizations" def add_arguments(self, parser): + parser.add_argument("--site_id", type=int, help="Site ID.") parser.add_argument("--organization_id", type=str, help="UUID of the organization.") def handle(self, *args, **options): @@ -19,20 +21,26 @@ def handle(self, *args, **options): Sync badge templates for a specific organization or all organizations. Usage: - ./manage.py sync_organization_badge_templates - ./manage.py sync_organization_badge_templates --organization_id c117c179-81b1-4f7e-a3a1-e6ae30568c13 + ./manage.py sync_organization_badge_templates --site_id 1 + ./manage.py sync_organization_badge_templates --site_id 1 --organization_id c117c179-81b1-4f7e-a3a1-e6ae30568c13 """ + organization_id = options.get("organization_id") + site_id = options.get("site_id") + try: + site = Site.objects.get(id=site_id) + except Site.DoesNotExist: + logger.info(f"Site with id {site_id} does not exists") if organization_id: logger.info(f"Syncing badge templates for single organization: {organization_id}") - sync_badge_templates_for_organization(organization_id) + sync_badge_templates_for_organization(organization_id, site) else: all_organization_ids = CredlyOrganization.get_all_organization_ids() logger.info( f"Organization id was not provided. Syncing badge templates for all organizations: {all_organization_ids}" ) for organization_id in all_organization_ids: - sync_badge_templates_for_organization(organization_id) + sync_badge_templates_for_organization(organization_id, site) logger.info("Done.") diff --git a/credentials/apps/badges/distribution/credly/credly_badges/utils.py b/credentials/apps/badges/distribution/credly/credly_badges/utils.py index eb4d17906a..4f1dcf01a7 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/utils.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/utils.py @@ -5,7 +5,7 @@ from .models import CredlyBadgeTemplate, CredlyOrganization -def sync_badge_templates_for_organization(organization_id): +def sync_badge_templates_for_organization(organization_id, site): """ Pull active badge templates for a given Credly Organization. @@ -27,7 +27,7 @@ def sync_badge_templates_for_organization(organization_id): CredlyBadgeTemplate.objects.update_or_create( uuid=badge_template_data.get("id"), defaults={ - "site": get_current_request().site, + "site": site, "organization": organization, "name": badge_template_data.get("name"), "state": badge_template_data.get("state"), From 9fd60e1d4401a1ae234f89c9479325fdcf63b9b1 Mon Sep 17 00:00:00 2001 From: andrii-hantkovskyi <131773947+andrii-hantkovskyi@users.noreply.github.com> Date: Tue, 26 Mar 2024 13:43:22 +0200 Subject: [PATCH 3/4] feat: [ACI-779] implement data path-key lookup function (#78) Co-authored-by: Andrii --- credentials/tests/test_utils.py | 44 +++++++++++++++++++++++++++++++++ credentials/utils.py | 14 +++++++++++ 2 files changed, 58 insertions(+) create mode 100644 credentials/tests/test_utils.py create mode 100644 credentials/utils.py diff --git a/credentials/tests/test_utils.py b/credentials/tests/test_utils.py new file mode 100644 index 0000000000..82136ef425 --- /dev/null +++ b/credentials/tests/test_utils.py @@ -0,0 +1,44 @@ +import unittest + +from credentials.utils import keypath + +class TestKeypath(unittest.TestCase): + def test_keypath_exists(self): + payload = { + "course": { + "key": "105-3332", + } + } + result = keypath(payload, "course.key") + self.assertEqual(result, "105-3332") + + def test_keypath_not_exists(self): + payload = { + "course": { + "id": "105-3332", + } + } + result = keypath(payload, "course.key") + self.assertIsNone(result) + + def test_keypath_deep(self): + payload = { + "course": { + "data": { + "identification": { + "id": 25 + } + } + } + } + result = keypath(payload, "course.data.identification.id") + self.assertEqual(result, 25) + + def test_keypath_invalid_path(self): + payload = { + "course": { + "key": "105-3332", + } + } + result = keypath(payload, "course.id") + self.assertIsNone(result) \ No newline at end of file diff --git a/credentials/utils.py b/credentials/utils.py new file mode 100644 index 0000000000..b43cf633e7 --- /dev/null +++ b/credentials/utils.py @@ -0,0 +1,14 @@ +def keypath(payload, keys_path): + keys = keys_path.split('.') + current = payload + + def traverse(current, keys): + if not keys: + return current + key = keys[0] + if isinstance(current, dict) and key in current: + return traverse(current[key], keys[1:]) + else: + return None + + return traverse(current, keys) \ No newline at end of file From 9ee83f82d8669d4187b662ca47887a41648e2c8b Mon Sep 17 00:00:00 2001 From: andrii-hantkovskyi <131773947+andrii-hantkovskyi@users.noreply.github.com> Date: Tue, 26 Mar 2024 13:54:54 +0200 Subject: [PATCH 4/4] feat: [ACI-587, ACI-586] implement openedx origin badge progress completion processing (#72) * feat: [ACI-587, ACI-586] implement openedx origin badge progress completion processing * style: remove too many blanklines at the EOF --------- Co-authored-by: Andrii --- credentials/apps/badges/services/__init__.py | 0 .../apps/badges/services/badge_templates.py | 9 +++ .../apps/badges/services/user_credentials.py | 19 ++++++ credentials/apps/badges/signals/handlers.py | 22 ++++++- credentials/apps/badges/signals/signals.py | 9 +++ .../apps/badges/tests/test_services.py | 59 +++++++++++++++++++ credentials/apps/badges/tests/test_signals.py | 48 +++++++++++++++ 7 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 credentials/apps/badges/services/__init__.py create mode 100644 credentials/apps/badges/services/badge_templates.py create mode 100644 credentials/apps/badges/services/user_credentials.py create mode 100644 credentials/apps/badges/tests/test_services.py create mode 100644 credentials/apps/badges/tests/test_signals.py diff --git a/credentials/apps/badges/services/__init__.py b/credentials/apps/badges/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/credentials/apps/badges/services/badge_templates.py b/credentials/apps/badges/services/badge_templates.py new file mode 100644 index 0000000000..01be6085eb --- /dev/null +++ b/credentials/apps/badges/services/badge_templates.py @@ -0,0 +1,9 @@ +from django.core.exceptions import ObjectDoesNotExist + +from ..models import BadgeTemplate + +def get_badge_template_by_id(badge_template_id): + try: + return BadgeTemplate.objects.get(id=badge_template_id) + except ObjectDoesNotExist: + return None diff --git a/credentials/apps/badges/services/user_credentials.py b/credentials/apps/badges/services/user_credentials.py new file mode 100644 index 0000000000..a5c6e0c415 --- /dev/null +++ b/credentials/apps/badges/services/user_credentials.py @@ -0,0 +1,19 @@ +from django.contrib.contenttypes.models import ContentType + +from ..models import BadgeTemplate, UserCredential + + +def create_user_credential(username, badge_template): + if not isinstance(username, str): + raise ValueError("`username` must be a string") + + if not isinstance(badge_template, BadgeTemplate): + raise TypeError("`badge_template` must be an instance of BadgeTemplate") + + + UserCredential.objects.create( + credential_content_type=ContentType.objects.get_for_model( + badge_template), + credential_id=badge_template.id, + username=username, + ) diff --git a/credentials/apps/badges/signals/handlers.py b/credentials/apps/badges/signals/handlers.py index d809fd7aea..1a137c05d1 100644 --- a/credentials/apps/badges/signals/handlers.py +++ b/credentials/apps/badges/signals/handlers.py @@ -5,8 +5,14 @@ """ import logging +from django.dispatch import receiver + from openedx_events.tooling import OpenEdxPublicSignal, load_all_signals +from .signals import BADGE_PROGRESS_COMPLETE + +from ..services.badge_templates import get_badge_template_by_id +from ..services.user_credentials import create_user_credential from ..utils import get_badging_event_types from ..processing import process @@ -33,4 +39,18 @@ def event_handler(sender, signal, **kwargs): logger.debug(f"Received signal {signal}") # NOTE (performance): all consumed messages from event bus trigger this. - process(signal, sender=sender, **kwargs) \ No newline at end of file + process(signal, sender=sender, **kwargs) + + +@receiver(BADGE_PROGRESS_COMPLETE) +def listen_for_completed_badge(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument + badge_template = get_badge_template_by_id(badge_template_id) + + if badge_template is None: + return + + if badge_template.origin == 'openedx': + create_user_credential(username, badge_template) + return + + # TODO: add processing for 'credly' origin diff --git a/credentials/apps/badges/signals/signals.py b/credentials/apps/badges/signals/signals.py index 637bdba396..b0a09ddfce 100644 --- a/credentials/apps/badges/signals/signals.py +++ b/credentials/apps/badges/signals/signals.py @@ -4,3 +4,12 @@ - BADGE_REQUIREMENTS_COMPLETE - all badge template requirements are finished; - BADGE_REQUIREMENTS_NOT_COMPLETE - a reason for earned badge revocation; """ + +from django.dispatch import Signal + +# Signal that indicates that user finisher all badge template requirements. +# providing_args=[ +# 'username', # String usernam of User +# 'badge_template_id', # Integer ID of finished badge template +# ] +BADGE_PROGRESS_COMPLETE = Signal() diff --git a/credentials/apps/badges/tests/test_services.py b/credentials/apps/badges/tests/test_services.py new file mode 100644 index 0000000000..869cbefcbe --- /dev/null +++ b/credentials/apps/badges/tests/test_services.py @@ -0,0 +1,59 @@ +from django.test import TestCase +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.contenttypes.models import ContentType + +from ..models import BadgeTemplate, UserCredential +from ..services.badge_templates import get_badge_template_by_id +from ..services.user_credentials import create_user_credential + + +class UserCredentialServiceTestCase(TestCase): + def setUp(self): + # Create a test badge template + self.badge_template = BadgeTemplate.objects.create( + origin='openedx', site_id=1) + + def test_create_user_credential(self): + # Call create_user_credential with valid arguments + create_user_credential('test_user', self.badge_template) + + # Check if user credential is created + self.assertTrue( + UserCredential.objects.filter( + username='test_user', + credential_content_type=ContentType.objects.get_for_model( + self.badge_template), + credential_id=self.badge_template.id, + ).exists() + ) + + def test_create_user_credential_invalid_username(self): + # Call create_user_credential with non-existent username + with self.assertRaises(ValueError): + # Passing int as username + create_user_credential(123, self.badge_template) + + def test_create_user_credential_invalid_template(self): + # Call create_user_credential with non-existent badge template + with self.assertRaises(TypeError): + # Passing None as badge template + create_user_credential('test_user', None) + + +class BadgeTemplateServiceTestCase(TestCase): + def setUp(self): + self.badge_template = BadgeTemplate.objects.create(origin='openedx', site_id=1) + + def test_get_badge_template_by_id(self): + # Call get_badge_template_by_id with existing badge template ID + badge_template = get_badge_template_by_id(self.badge_template.id) + + # Check if the returned badge template is correct + self.assertEqual(badge_template, self.badge_template) + + def test_get_badge_template_by_id_nonexistent(self): + # Call get_badge_template_by_id with non-existent ID + badge_template = get_badge_template_by_id(999) # Non-existent ID + + # Check that None is returned + self.assertIsNone(badge_template) diff --git a/credentials/apps/badges/tests/test_signals.py b/credentials/apps/badges/tests/test_signals.py new file mode 100644 index 0000000000..7555d36182 --- /dev/null +++ b/credentials/apps/badges/tests/test_signals.py @@ -0,0 +1,48 @@ +from django.test import TestCase +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.contenttypes.models import ContentType + +from ..models import BadgeTemplate, UserCredential +from ..signals.signals import BADGE_PROGRESS_COMPLETE + + +class BadgeSignalReceiverTestCase(TestCase): + def setUp(self): + # Create a test badge template + self.badge_template = BadgeTemplate.objects.create( + name='test', site_id=1) + + def test_signal_emission_and_receiver_execution(self): + # Emit the signal + BADGE_PROGRESS_COMPLETE.send( + sender=self, + username='test_user', + badge_template_id=self.badge_template.id, + ) + + # UserCredential object + user_credential = UserCredential.objects.filter( + username='test_user', + credential_content_type=ContentType.objects.get_for_model( + self.badge_template), + credential_id=self.badge_template.id, + ) + + # Check if user credential is created + self.assertTrue(user_credential.exists()) + + # Check if user credential status is 'awarded' + self.assertTrue(user_credential[0].status == 'awarded') + + def test_behavior_for_nonexistent_badge_templates(self): + # Emit the signal with a non-existent badge template ID + BADGE_PROGRESS_COMPLETE.send( + sender=self, + username='test_user', + badge_template_id=999, # Non-existent ID + ) + + # Check that no user credential is created + self.assertFalse( + UserCredential.objects.filter(username='test_user').exists() + )