From 7ff30560e89229e21754d304c5d36ac48e2d2deb Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Thu, 2 Jan 2025 12:19:07 +0200 Subject: [PATCH] feat: course credentials as verifiable credentials --- .../composition/schemas.py | 31 +++++- .../composition/tests/test_schemas.py | 83 ++++++++++++++++ .../apps/verifiable_credentials/constants.py | 6 ++ .../verifiable_credentials/issuance/models.py | 93 +++++++++++------- .../rest_api/v1/tests/test_views.py | 35 +++++-- .../rest_api/v1/urls.py | 2 +- .../rest_api/v1/views.py | 28 ++++-- .../tests/test_utils.py | 60 ++++++++--- .../apps/verifiable_credentials/utils.py | 44 ++++++--- .../conf/locale/eo/LC_MESSAGES/django.mo | Bin 39502 -> 41155 bytes .../conf/locale/eo/LC_MESSAGES/django.po | 31 +++++- .../conf/locale/eo/LC_MESSAGES/djangojs.po | 4 +- .../conf/locale/rtl/LC_MESSAGES/django.mo | Bin 26509 -> 27333 bytes .../conf/locale/rtl/LC_MESSAGES/django.po | 24 ++++- .../conf/locale/rtl/LC_MESSAGES/djangojs.po | 4 +- 15 files changed, 360 insertions(+), 85 deletions(-) create mode 100644 credentials/apps/verifiable_credentials/composition/tests/test_schemas.py create mode 100644 credentials/apps/verifiable_credentials/constants.py diff --git a/credentials/apps/verifiable_credentials/composition/schemas.py b/credentials/apps/verifiable_credentials/composition/schemas.py index ade36631c..837e5bcdf 100644 --- a/credentials/apps/verifiable_credentials/composition/schemas.py +++ b/credentials/apps/verifiable_credentials/composition/schemas.py @@ -4,6 +4,8 @@ from rest_framework import serializers +from ..constants import CredentialsType + class EducationalOccupationalProgramSchema(serializers.Serializer): # pylint: disable=abstract-method """ @@ -20,6 +22,21 @@ class Meta: read_only_fields = "__all__" +class EducationalOccupationalCourseSchema(serializers.Serializer): # pylint: disable=abstract-method + """ + Defines Open edX Course. + """ + + TYPE = "Course" + + id = serializers.CharField(default=TYPE, help_text="https://schema.org/Course") + name = serializers.CharField(source="course.title") + courseCode = serializers.CharField(source="user_credential.credential.course_id") + + class Meta: + read_only_fields = "__all__" + + class EducationalOccupationalCredentialSchema(serializers.Serializer): # pylint: disable=abstract-method """ Defines Open edX user credential. @@ -30,7 +47,19 @@ class EducationalOccupationalCredentialSchema(serializers.Serializer): # pylint id = serializers.CharField(default=TYPE, help_text="https://schema.org/EducationalOccupationalCredential") name = serializers.CharField(source="user_credential.credential.title") description = serializers.CharField(source="user_credential.uuid") - program = EducationalOccupationalProgramSchema(source="*") + + def to_representation(self, instance): + """ + Dynamically add fields based on the type. + """ + representation = super().to_representation(instance) + + if instance.user_credential.credential_content_type.model == CredentialsType.PROGRAM: + representation["program"] = EducationalOccupationalProgramSchema(instance).data + elif instance.user_credential.credential_content_type.model == CredentialsType.COURSE: + representation["course"] = EducationalOccupationalCourseSchema(instance).data + + return representation class Meta: read_only_fields = "__all__" diff --git a/credentials/apps/verifiable_credentials/composition/tests/test_schemas.py b/credentials/apps/verifiable_credentials/composition/tests/test_schemas.py new file mode 100644 index 000000000..6bf828b12 --- /dev/null +++ b/credentials/apps/verifiable_credentials/composition/tests/test_schemas.py @@ -0,0 +1,83 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from credentials.apps.catalog.tests.factories import ( + CourseFactory, + CourseRunFactory, + OrganizationFactory, + ProgramFactory, +) +from credentials.apps.core.tests.factories import UserFactory +from credentials.apps.core.tests.mixins import SiteMixin +from credentials.apps.credentials.tests.factories import ( + CourseCertificateFactory, + ProgramCertificateFactory, + UserCredentialFactory, +) +from credentials.apps.verifiable_credentials.composition.schemas import EducationalOccupationalCredentialSchema +from credentials.apps.verifiable_credentials.issuance.tests.factories import IssuanceLineFactory + + +class EducationalOccupationalCredentialSchemaTests(SiteMixin, TestCase): + def setUp(self): + super().setUp() + self.user = UserFactory() + self.orgs = [OrganizationFactory.create(name=name, site=self.site) for name in ["TestOrg1", "TestOrg2"]] + self.course = CourseFactory.create(site=self.site) + self.course_runs = CourseRunFactory.create_batch(2, course=self.course) + self.program = ProgramFactory( + title="TestProgram1", + course_runs=self.course_runs, + authoring_organizations=self.orgs, + site=self.site, + ) + self.course_certs = [ + CourseCertificateFactory.create( + course_id=course_run.key, + course_run=course_run, + site=self.site, + ) + for course_run in self.course_runs + ] + self.program_cert = ProgramCertificateFactory.create( + program=self.program, program_uuid=self.program.uuid, site=self.site + ) + self.course_credential_content_type = ContentType.objects.get( + app_label="credentials", model="coursecertificate" + ) + self.program_credential_content_type = ContentType.objects.get( + app_label="credentials", model="programcertificate" + ) + self.course_user_credential = UserCredentialFactory.create( + username=self.user.username, + credential_content_type=self.course_credential_content_type, + credential=self.course_certs[0], + ) + self.program_user_credential = UserCredentialFactory.create( + username=self.user.username, + credential_content_type=self.program_credential_content_type, + credential=self.program_cert, + ) + self.program_issuance_line = IssuanceLineFactory(user_credential=self.program_user_credential, subject_id="did:key:test") + self.course_issuance_line = IssuanceLineFactory(user_credential=self.course_user_credential, subject_id="did:key:test") + + + def test_to_representation_program(self): + data = EducationalOccupationalCredentialSchema(self.program_issuance_line).data + + assert data["id"] == "EducationalOccupationalCredential" + assert data["name"] == self.program_cert.title + assert data["description"] == str(self.program_user_credential.uuid) + assert data["program"]["id"] == "EducationalOccupationalProgram" + assert data["program"]["name"] == self.program.title + assert data["program"]["description"] == str(self.program.uuid) + + def test_to_representation_course(self): + data = EducationalOccupationalCredentialSchema(self.course_issuance_line).data + + assert data["id"] == "EducationalOccupationalCredential" + assert data["name"] == self.course_certs[0].title + assert data["description"] == str(self.course_user_credential.uuid) + assert data["course"]["id"] == "Course" + assert data["course"]["name"] == self.course.title + assert data["course"]["courseCode"] == self.course_certs[0].course_id diff --git a/credentials/apps/verifiable_credentials/constants.py b/credentials/apps/verifiable_credentials/constants.py new file mode 100644 index 000000000..f58987890 --- /dev/null +++ b/credentials/apps/verifiable_credentials/constants.py @@ -0,0 +1,6 @@ +class CredentialsType: + """ + Enum to define the type of credentials. + """ + PROGRAM = "programcertificate" + COURSE = "coursecertificate" diff --git a/credentials/apps/verifiable_credentials/issuance/models.py b/credentials/apps/verifiable_credentials/issuance/models.py index aaea75bb2..2082a3b7b 100644 --- a/credentials/apps/verifiable_credentials/issuance/models.py +++ b/credentials/apps/verifiable_credentials/issuance/models.py @@ -12,12 +12,14 @@ from django.utils.translation import gettext_lazy as _ from django_extensions.db.models import TimeStampedModel +from credentials.apps.catalog.models import Course from credentials.apps.credentials.models import UserCredential from credentials.apps.verifiable_credentials.utils import capitalize_first from ..composition.utils import get_data_model, get_data_models from ..settings import vc_settings from ..storages.utils import get_storage +from ..constants import CredentialsType User = get_user_model() @@ -106,8 +108,8 @@ def credential_verbose_type(self): Map internal credential types to verbose labels (source models do not provide those). """ contenttype_to_verbose_name = { - "programcertificate": _("program certificate"), - "coursecertificate": _("course certificate"), + CredentialsType.PROGRAM: _("program certificate"), + CredentialsType.COURSE: _("course certificate"), } return contenttype_to_verbose_name.get(self.credential_content_type) @@ -120,10 +122,10 @@ def credential_name(self): return credential_title contenttype_to_name = { - "programcertificate": _("program certificate for passing a program {program_title}").format( + CredentialsType.PROGRAM: _("program certificate for passing a program {program_title}").format( program_title=getattr(self.program, "title", "") ), - "coursecertificate": self.credential_verbose_type, + CredentialsType.COURSE: self.credential_verbose_type, } return capitalize_first(contenttype_to_name.get(self.credential_content_type)) @@ -132,48 +134,58 @@ def credential_description(self): """ Verifiable credential achievement description resolution. """ - effort_portion = ( - _(", with total {hours_of_effort} Hours of effort required to complete it").format( - hours_of_effort=self.program.total_hours_of_effort + if self.credential_content_type == CredentialsType.PROGRAM: + effort_portion = ( + _(", with total {hours_of_effort} Hours of effort required to complete it").format( + hours_of_effort=self.program.total_hours_of_effort + ) + if self.program.total_hours_of_effort + else "" ) - if self.program.total_hours_of_effort - else "" - ) - program_certificate_description = _( - "{credential_type} is granted on program {program_title} completion offered by {organizations}, in collaboration with {platform_name}. The {program_title} program includes {course_count} course(s){effort_info}." # pylint: disable=line-too-long - ).format( - credential_type=self.credential_verbose_type, - program_title=self.program.title, - organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))), - platform_name=self.platform_name, - course_count=self.program.course_runs.count(), - effort_info=effort_portion, - ) - type_to_description = { - "programcertificate": program_certificate_description, - "coursecertificate": "", - } - return capitalize_first(type_to_description.get(self.credential_content_type)) + description = _( + "{credential_type} is granted on program {program_title} completion offered by {organizations}, in collaboration with {platform_name}. The {program_title} program includes {course_count} course(s){effort_info}." # pylint: disable=line-too-long + ).format( + credential_type=self.credential_verbose_type, + program_title=self.program.title, + organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))), + platform_name=self.platform_name, + course_count=self.program.course_runs.count(), + effort_info=effort_portion, + ) + elif self.credential_content_type == CredentialsType.COURSE: + description = _("{credential_type} is granted on course {course_title} completion offered by {organization}, in collaboration with {platform_name}").format( + credential_type=self.credential_verbose_type, + course_title=getattr(self.course, "title", ""), + platform_name=self.platform_name, + organization=self.user_credential.credential.course_key.org, + ) + return capitalize_first(description) @property def credential_narrative(self): """ Verifiable credential achievement criteria narrative. """ - program_certificate_narrative = _( - "{recipient_fullname} successfully completed all courses and received passing grades for a Professional Certificate in {program_title} a program offered by {organizations}, in collaboration with {platform_name}." # pylint: disable=line-too-long - ).format( - recipient_fullname=self.subject_fullname or _("recipient"), - program_title=self.program.title, - organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))), - platform_name=self.platform_name, - ) - type_to_narrative = { - "programcertificate": program_certificate_narrative, - "coursecertificate": "", - } - return capitalize_first(type_to_narrative.get(self.credential_content_type)) + if self.credential_content_type == CredentialsType.PROGRAM: + narrative = _( + "{recipient_fullname} successfully completed all courses and received passing grades for a Professional Certificate in {program_title} a program offered by {organizations}, in collaboration with {platform_name}." # pylint: disable=line-too-long + ).format( + recipient_fullname=self.subject_fullname or _("recipient"), + program_title=self.program.title, + organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))), + platform_name=self.platform_name, + ) + elif self.credential_content_type == CredentialsType.COURSE: + narrative = _( + "{recipient_fullname} successfully completed a course and received a passing grade for a Course Certificate in {course_title} a course offered by {organization}, in collaboration with {platform_name}. " # pylint: disable=line-too-long + ).format( + recipient_fullname=self.subject_fullname or _("recipient"), + course_title=getattr(self.course, "title", ""), + organization=self.user_credential.credential.course_key.org, + platform_name=self.platform_name, + ) + return capitalize_first(narrative) @property def credential_content_type(self): @@ -183,6 +195,11 @@ def credential_content_type(self): def program(self): return getattr(self.user_credential.credential, "program", None) + @property + def course(self): + course_id = getattr(self.user_credential.credential, "course_id", None) + return Course.objects.filter(course_runs__key=course_id).first() + @property def platform_name(self): if not (site_configuration := getattr(self.user_credential.credential.site, "siteconfiguration", "")): diff --git a/credentials/apps/verifiable_credentials/rest_api/v1/tests/test_views.py b/credentials/apps/verifiable_credentials/rest_api/v1/tests/test_views.py index 27cff56ba..51cd387df 100644 --- a/credentials/apps/verifiable_credentials/rest_api/v1/tests/test_views.py +++ b/credentials/apps/verifiable_credentials/rest_api/v1/tests/test_views.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls import reverse +from ddt import ddt, data, unpack from rest_framework import status from credentials.apps.catalog.tests.factories import ( @@ -22,12 +23,13 @@ from credentials.apps.verifiable_credentials.issuance import IssuanceException from credentials.apps.verifiable_credentials.issuance.tests.factories import IssuanceLineFactory from credentials.apps.verifiable_credentials.storages.learner_credential_wallet import LCWallet -from credentials.apps.verifiable_credentials.utils import get_user_program_credentials_data +from credentials.apps.verifiable_credentials.utils import get_user_credentials_data JSON_CONTENT_TYPE = "application/json" +@ddt class ProgramCredentialsViewTests(SiteMixin, TestCase): def setUp(self): super().setUp() @@ -73,22 +75,43 @@ def setUp(self): def test_deny_unauthenticated_user(self): self.client.logout() - response = self.client.get("/verifiable_credentials/api/v1/program_credentials/") + response = self.client.get("/verifiable_credentials/api/v1/credentials/") self.assertEqual(response.status_code, 401) def test_allow_authenticated_user(self): """Verify the endpoint requires an authenticated user.""" self.client.logout() self.client.login(username=self.user.username, password=USER_PASSWORD) - response = self.client.get("/verifiable_credentials/api/v1/program_credentials/") + response = self.client.get("/verifiable_credentials/api/v1/credentials/") self.assertEqual(response.status_code, 200) - def test_get(self): + def test_get_without_query_params(self): self.client.login(username=self.user.username, password=USER_PASSWORD) - response = self.client.get("/verifiable_credentials/api/v1/program_credentials/") + response = self.client.get("/verifiable_credentials/api/v1/credentials/") self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["program_credentials"], get_user_program_credentials_data(self.user.username)) + self.assertEqual(response.data["program_credentials"], get_user_credentials_data(self.user.username, "programcertificate")) + self.assertEqual(response.data["course_credentials"], get_user_credentials_data(self.user.username, "coursecertificate")) + + @data( + ("programcertificate", {"program_credentials": "programcertificate"}, ["course_credentials"]), + ("coursecertificate", {"course_credentials": "coursecertificate"}, ["program_credentials"]), + ("programcertificate,coursecertificate", + {"program_credentials": "programcertificate", "course_credentials": "coursecertificate"}, []) + ) + @unpack + def test_get_with_query_params(self, types, expected_data, not_in_keys): + self.client.login(username=self.user.username, password=USER_PASSWORD) + response = self.client.get(f"/verifiable_credentials/api/v1/credentials/?types={types}") + self.assertEqual(response.status_code, 200) + + for key, expected_value in expected_data.items(): + self.assertEqual( + response.data[key], + get_user_credentials_data(self.user.username, expected_value) + ) + for key in not_in_keys: + self.assertNotIn(key, response.data) class InitIssuanceViewTestCase(SiteMixin, TestCase): url_path = reverse("verifiable_credentials:api:v1:credentials-init") diff --git a/credentials/apps/verifiable_credentials/rest_api/v1/urls.py b/credentials/apps/verifiable_credentials/rest_api/v1/urls.py index d531359bf..103577134 100644 --- a/credentials/apps/verifiable_credentials/rest_api/v1/urls.py +++ b/credentials/apps/verifiable_credentials/rest_api/v1/urls.py @@ -9,7 +9,7 @@ router = routers.DefaultRouter() -router.register(r"program_credentials", views.ProgramCredentialsViewSet, basename="program_credentials") +router.register(r"credentials", views.CredentialsViewSet, basename="credentials") urlpatterns = [ path(r"credentials/init/", views.InitIssuanceView.as_view(), name="credentials-init"), diff --git a/credentials/apps/verifiable_credentials/rest_api/v1/views.py b/credentials/apps/verifiable_credentials/rest_api/v1/views.py index cc0442c49..32dbb2b84 100644 --- a/credentials/apps/verifiable_credentials/rest_api/v1/views.py +++ b/credentials/apps/verifiable_credentials/rest_api/v1/views.py @@ -25,7 +25,7 @@ from credentials.apps.verifiable_credentials.storages.utils import get_available_storages, get_storage from credentials.apps.verifiable_credentials.utils import ( generate_base64_qr_code, - get_user_program_credentials_data, + get_user_credentials_data, is_valid_uuid, ) @@ -35,7 +35,7 @@ User = get_user_model() -class ProgramCredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): +class CredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): authentication_classes = ( JwtAuthentication, SessionAuthentication, @@ -43,17 +43,33 @@ class ProgramCredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): permission_classes = (IsAuthenticated,) + CREDENTIAL_TYPES_MAP = { + "programcertificate": "program_credentials", + "coursecertificate": "course_credentials", + } + def list(self, request, *args, **kwargs): """ - List data for all the user's issued program credentials. - GET: /verifiable_credentials/api/v1/program_credentials/ + List data for all the user's issued credentials. + GET: /verifiable_credentials/api/v1/credentials?types=coursecertificate,programcertificate Arguments: request: A request to control data returned in endpoint response Returns: response(dict): Information about the user's program credentials """ - program_credentials = get_user_program_credentials_data(request.user.username) - return Response({"program_credentials": program_credentials}) + types = self.request.query_params.get('types') + response = {} + + if types: + types = types.split(',') + else: + types = self.CREDENTIAL_TYPES_MAP.keys() + + for type in types: + if type in self.CREDENTIAL_TYPES_MAP: + response[self.CREDENTIAL_TYPES_MAP[type]] = get_user_credentials_data(request.user.username, type) + + return Response(response) class InitIssuanceView(APIView): diff --git a/credentials/apps/verifiable_credentials/tests/test_utils.py b/credentials/apps/verifiable_credentials/tests/test_utils.py index 8eb42fe43..4cb7f46ff 100644 --- a/credentials/apps/verifiable_credentials/tests/test_utils.py +++ b/credentials/apps/verifiable_credentials/tests/test_utils.py @@ -20,7 +20,7 @@ from credentials.apps.verifiable_credentials.utils import ( capitalize_first, generate_base64_qr_code, - get_user_program_credentials_data, + get_user_credentials_data, ) @@ -73,25 +73,25 @@ def setUp(self): def test_get_user_program_credentials_data_not_completed(self): self.program_user_credential.delete() - result = get_user_program_credentials_data(self.user.username) + result = get_user_credentials_data(self.user.username, "programcertificate") assert result == [] def test_get_user_program_credentials_data_zero_programs(self): self.program_cert.delete() self.program.delete() self.program_user_credential.delete() - result = get_user_program_credentials_data(self.user.username) + result = get_user_credentials_data(self.user.username, "programcertificate") assert result == [] def test_get_user_program_credentials_data_one_program(self): - result = get_user_program_credentials_data(self.user.username) + result = get_user_credentials_data(self.user.username, "programcertificate") assert result[0]["uuid"] == str(self.program_user_credential.uuid).replace("-", "") assert result[0]["status"] == self.program_user_credential.status assert result[0]["username"] == self.program_user_credential.username assert result[0]["download_url"] == self.program_user_credential.download_url assert result[0]["credential_id"] == self.program_user_credential.credential_id - assert result[0]["program_uuid"] == str(self.program_user_credential.credential.program_uuid).replace("-", "") - assert result[0]["program_title"] == self.program_user_credential.credential.program.title + assert result[0]["credential_uuid"] == str(self.program_user_credential.credential.program_uuid).replace("-", "") + assert result[0]["credential_title"] == self.program_user_credential.credential.program.title def test_get_user_program_credentials_data_multiple_programs(self): self.program2 = ProgramFactory( @@ -108,23 +108,61 @@ def test_get_user_program_credentials_data_multiple_programs(self): credential_content_type=self.program_credential_content_type, credential=self.program_cert2, ) - result = get_user_program_credentials_data(self.user.username) + result = get_user_credentials_data(self.user.username, "programcertificate") assert result[0]["uuid"] == str(self.program_user_credential.uuid).replace("-", "") assert result[0]["status"] == self.program_user_credential.status assert result[0]["username"] == self.program_user_credential.username assert result[0]["download_url"] == self.program_user_credential.download_url assert result[0]["credential_id"] == self.program_user_credential.credential_id - assert result[0]["program_uuid"] == str(self.program_user_credential.credential.program_uuid).replace("-", "") - assert result[0]["program_title"] == self.program_user_credential.credential.program.title + assert result[0]["credential_uuid"] == str(self.program_user_credential.credential.program_uuid).replace("-", "") + assert result[0]["credential_title"] == self.program_user_credential.credential.program.title assert result[1]["uuid"] == str(self.program_user_credential2.uuid).replace("-", "") assert result[1]["status"] == self.program_user_credential2.status assert result[1]["username"] == self.program_user_credential2.username assert result[1]["download_url"] == self.program_user_credential2.download_url assert result[1]["credential_id"] == self.program_user_credential2.credential_id - assert result[1]["program_uuid"] == str(self.program_user_credential2.credential.program_uuid).replace("-", "") - assert result[1]["program_title"] == self.program_user_credential2.credential.program.title + assert result[1]["credential_uuid"] == str(self.program_user_credential2.credential.program_uuid).replace("-", "") + assert result[1]["credential_title"] == self.program_user_credential2.credential.program.title + def test_get_user_course_credentials_data_zero_courses(self): + self.course_user_credentials[0].delete() + self.course_user_credentials[1].delete() + result = get_user_credentials_data(self.user.username, "coursecertificate") + assert result == [] + + def test_get_user_course_credentials_data_one_course(self): + self.course_user_credentials[1].delete() + result = get_user_credentials_data(self.user.username, "coursecertificate") + assert result[0]["uuid"] == str(self.course_user_credentials[0].uuid).replace("-", "") + assert result[0]["status"] == self.course_user_credentials[0].status + assert result[0]["username"] == self.course_user_credentials[0].username + assert result[0]["download_url"] == self.course_user_credentials[0].download_url + assert result[0]["credential_id"] == self.course_user_credentials[0].credential_id + assert result[0]["credential_uuid"] == self.course_user_credentials[0].credential.course_id + assert result[0]["credential_title"] == self.course_user_credentials[0].credential.title + + def test_get_user_course_credentials_data_multiple_courses(self): + result = get_user_credentials_data(self.user.username, "coursecertificate") + assert result[0]["uuid"] == str(self.course_user_credentials[0].uuid).replace("-", "") + assert result[0]["status"] == self.course_user_credentials[0].status + assert result[0]["username"] == self.course_user_credentials[0].username + assert result[0]["download_url"] == self.course_user_credentials[0].download_url + assert result[0]["credential_id"] == self.course_user_credentials[0].credential_id + assert result[0]["credential_uuid"] == self.course_user_credentials[0].credential.course_id + assert result[0]["credential_title"] == self.course_user_credentials[0].credential.title + + assert result[1]["uuid"] == str(self.course_user_credentials[1].uuid).replace("-", "") + assert result[1]["status"] == self.course_user_credentials[1].status + assert result[1]["username"] == self.course_user_credentials[1].username + assert result[1]["download_url"] == self.course_user_credentials[1].download_url + assert result[1]["credential_id"] == self.course_user_credentials[1].credential_id + assert result[1]["credential_uuid"] == self.course_user_credentials[1].credential.course_id + assert result[1]["credential_title"] == self.course_user_credentials[1].credential.title + + def test_non_existing_content_type(self): + result = get_user_credentials_data(self.user.username, "non_existing_content_type") + assert result == [] class TestGenerateBase64QRCode(TestCase): def test_correct_output_format(self): diff --git a/credentials/apps/verifiable_credentials/utils.py b/credentials/apps/verifiable_credentials/utils.py index 11428aef3..55add7f66 100644 --- a/credentials/apps/verifiable_credentials/utils.py +++ b/credentials/apps/verifiable_credentials/utils.py @@ -9,37 +9,53 @@ from credentials.apps.credentials.data import UserCredentialStatus -def get_user_program_credentials_data(username): +def get_user_credentials_data(username, model): """ Translates a list of UserCredentials (for programs) into context data. Arguments: request_username(str): Username for whom we are getting UserCredential objects for + model(str): The model for content type (programcertificate | coursecertificate) Returns: list(dict): A list of dictionaries, each dictionary containing information for a credential that the user awarded """ - program_cert_content_type = ContentType.objects.get(app_label="credentials", model="programcertificate") - program_credentials = get_user_credentials_by_content_type( - username, [program_cert_content_type], UserCredentialStatus.AWARDED.value + try: + credential_cert_content_type = ContentType.objects.get(app_label="credentials", model=model) + except ContentType.DoesNotExist: + return [] + + credentials = get_user_credentials_by_content_type( + username, [credential_cert_content_type], UserCredentialStatus.AWARDED.value ) - return [ - { + + data = [] + for credential in credentials: + if model == "programcertificate": + credential_uuid = credential.credential.program_uuid.hex + credential_title = credential.credential.program.title + credential_org = ", ".join( + credential.credential.program.authoring_organizations.values_list("name", flat=True) + ) + elif model == "coursecertificate": + credential_uuid = credential.credential.course_id + credential_title = credential.credential.title + credential_org = credential.credential.course_key.org + + data.append({ "uuid": credential.uuid.hex, "status": credential.status, "username": credential.username, "download_url": credential.download_url, "credential_id": credential.credential_id, - "program_uuid": credential.credential.program_uuid.hex, - "program_title": credential.credential.program.title, - "program_org": ", ".join( - credential.credential.program.authoring_organizations.values_list("name", flat=True) - ), + "credential_uuid": credential_uuid , + "credential_title": credential_title, + "credential_org": credential_org, "modified_date": credential.modified.date().isoformat(), - } - for credential in program_credentials - ] + }) + + return data def generate_base64_qr_code(text): diff --git a/credentials/conf/locale/eo/LC_MESSAGES/django.mo b/credentials/conf/locale/eo/LC_MESSAGES/django.mo index 13fa51d521eddfca71fef424411f21a24c17fcc7..f379a26878713bff9cadbe788cf04989f457004f 100644 GIT binary patch delta 3671 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 X!wwuQ{PT, 2024. +# EdX Team , 2025. # msgid "" msgstr "" @@ -761,6 +761,16 @@ msgstr "" "{program_title} prögräm ïnçlüdés {course_count} çöürsé(s){effort_info}. " "Ⱡ'σяєм#" +#: apps/verifiable_credentials/issuance/models.py +#, python-brace-format +msgid "" +"{credential_type} is granted on course {course_title} completion offered by " +"{organization}, in collaboration with {platform_name}" +msgstr "" +"{credential_type} ïs gräntéd ön çöürsé {course_title} çömplétïön öfféréd ßý " +"{organization}, ïn çölläßörätïön wïth {platform_name} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт" +" αмєт, ¢σηѕє¢тє#" + #: apps/verifiable_credentials/issuance/models.py #, python-brace-format msgid "" @@ -782,6 +792,23 @@ msgstr "" msgid "recipient" msgstr "réçïpïént Ⱡ'σяєм ιρѕυм ∂σł#" +#: apps/verifiable_credentials/issuance/models.py +#, python-brace-format +msgid "" +"{recipient_fullname} successfully completed a course and received a passing " +"grade for a Course Certificate in {course_title} a course offered by " +"{organization}, in collaboration with {platform_name}. " +msgstr "" +"{recipient_fullname} süççéssfüllý çömplétéd ä çöürsé änd réçéïvéd ä pässïng " +"grädé för ä Çöürsé Çértïfïçäté ïn {course_title} ä çöürsé öfféréd ßý " +"{organization}, ïn çölläßörätïön wïth {platform_name}. Ⱡ'σяєм ιρѕυм ∂σłσя " +"ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя ιη¢ι∂ι∂υηт υт " +"łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм νєηιαм, qυιѕ ησѕтяυ∂ " +"єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα ¢σммσ∂σ ¢σηѕєqυαт. ∂υιѕ " +"αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт єѕѕє ¢ιłłυм ∂σłσяє єυ " +"ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт ¢υρι∂αтαт ηση ρяσι∂єηт, ѕυηт " +"ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт αηι#" + #: apps/verifiable_credentials/issuance/models.py msgid "" "Issuer secret key. See: https://w3c-ccg.github.io/did-method-" 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 24b820aaeaf5f5805e87a0d5ddbb16dbdfffb83d..83d12d82975d187e034c9319ed1cdbfec932abf9 100644 GIT binary patch delta 3625 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, 2024. +# EdX Team , 2025. # msgid "" msgstr "" @@ -648,6 +648,15 @@ msgstr "" "bʎ {organizations}, ᴉn ɔøllɐbøɹɐʇᴉøn ʍᴉʇɥ {platform_name}. Ŧɥǝ " "{program_title} dɹøƃɹɐɯ ᴉnɔlndǝs {course_count} ɔønɹsǝ(s){effort_info}." +#: apps/verifiable_credentials/issuance/models.py +#, python-brace-format +msgid "" +"{credential_type} is granted on course {course_title} completion offered by " +"{organization}, in collaboration with {platform_name}" +msgstr "" +"{credential_type} ᴉs ƃɹɐnʇǝd øn ɔønɹsǝ {course_title} ɔøɯdlǝʇᴉøn øɟɟǝɹǝd bʎ " +"{organization}, ᴉn ɔøllɐbøɹɐʇᴉøn ʍᴉʇɥ {platform_name}" + #: apps/verifiable_credentials/issuance/models.py #, python-brace-format msgid "" @@ -663,6 +672,17 @@ msgstr "" msgid "recipient" msgstr "ɹǝɔᴉdᴉǝnʇ" +#: apps/verifiable_credentials/issuance/models.py +#, python-brace-format +msgid "" +"{recipient_fullname} successfully completed a course and received a passing " +"grade for a Course Certificate in {course_title} a course offered by " +"{organization}, in collaboration with {platform_name}. " +msgstr "" +"{recipient_fullname} snɔɔǝssɟnllʎ ɔøɯdlǝʇǝd ɐ ɔønɹsǝ ɐnd ɹǝɔǝᴉʌǝd ɐ dɐssᴉnƃ " +"ƃɹɐdǝ ɟøɹ ɐ Ȼønɹsǝ Ȼǝɹʇᴉɟᴉɔɐʇǝ ᴉn {course_title} ɐ ɔønɹsǝ øɟɟǝɹǝd bʎ " +"{organization}, ᴉn ɔøllɐbøɹɐʇᴉøn ʍᴉʇɥ {platform_name}. " + #: apps/verifiable_credentials/issuance/models.py msgid "" "Issuer secret key. See: https://w3c-ccg.github.io/did-method-" 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 ""