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_open_badges.py b/credentials/apps/verifiable_credentials/composition/tests/test_open_badges.py index a3bf8017f..517c0e561 100644 --- a/credentials/apps/verifiable_credentials/composition/tests/test_open_badges.py +++ b/credentials/apps/verifiable_credentials/composition/tests/test_open_badges.py @@ -21,144 +21,181 @@ class TestOpenBadgesDataModel: """ @pytest.mark.django_db - def test_default_name(self, issuance_line): + def test_default_name(self, program_issuance_line): """ Predefined for Program certificate value is used as `name` property. """ expected_default_name = "Program certificate for passing a program TestProgram1" - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["name"] == expected_default_name @pytest.mark.django_db - def test_overridden_name(self, monkeypatch, issuance_line): + def test_overridden_name(self, monkeypatch, program_issuance_line): """ Program certificate title overrides `name` property. """ expected_overridden_name = "Explicit Credential Title" - monkeypatch.setattr(issuance_line.user_credential.credential, "title", expected_overridden_name) + monkeypatch.setattr(program_issuance_line.user_credential.credential, "title", expected_overridden_name) - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["name"] == expected_overridden_name @pytest.mark.django_db - def test_credential_subject_id(self, issuance_line): + def test_credential_subject_id(self, program_issuance_line): """ Credential Subject `id` property. """ - expected_id = issuance_line.subject_id + expected_id = program_issuance_line.subject_id - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["credentialSubject"]["id"] == expected_id @pytest.mark.django_db - def test_credential_subject_type(self, issuance_line): + def test_credential_subject_type(self, program_issuance_line): """ Credential Subject `type` property. """ expected_type = CredentialSubjectSchema.TYPE - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["credentialSubject"]["type"] == expected_type @pytest.mark.django_db - def test_credential_subject_name(self, monkeypatch, issuance_line, user): + def test_credential_subject_name(self, monkeypatch, program_issuance_line, user): """ Credential Subject `name` property. """ expected_name = user.full_name - monkeypatch.setattr(issuance_line.user_credential, "username", user.username) + monkeypatch.setattr(program_issuance_line.user_credential, "username", user.username) - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["credentialSubject"]["name"] == expected_name @pytest.mark.django_db - def test_credential_subject_achievement_id(self, issuance_line): + def test_credential_subject_achievement_id(self, program_issuance_line): """ Credential Subject Achievement `id` property. """ - expected_id = str(issuance_line.user_credential.uuid) + expected_id = str(program_issuance_line.user_credential.uuid) - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["credentialSubject"]["achievement"]["id"] == expected_id @pytest.mark.django_db - def test_credential_subject_achievement_type(self, issuance_line): + def test_credential_subject_achievement_type(self, program_issuance_line): """ Credential Subject Achievement `type` property. """ expected_type = AchievementSchema.TYPE - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["credentialSubject"]["achievement"]["type"] == expected_type @pytest.mark.django_db - def test_credential_subject_achievement_default_name(self, issuance_line): + def test_credential_subject_achievement_default_name(self, program_issuance_line): """ Credential Subject Achievement default `name` property. """ expected_default_name = "Program certificate for passing a program TestProgram1" - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["credentialSubject"]["achievement"]["name"] == expected_default_name @pytest.mark.django_db - def test_credential_subject_achievement_overridden_name(self, monkeypatch, issuance_line): + def test_credential_subject_achievement_overridden_name(self, monkeypatch, program_issuance_line): """ Credential Subject Achievement overridden `name` property. """ expected_overridden_name = "Explicit Credential Title" - monkeypatch.setattr(issuance_line.user_credential.credential, "title", expected_overridden_name) + monkeypatch.setattr(program_issuance_line.user_credential.credential, "title", expected_overridden_name) - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["credentialSubject"]["achievement"]["name"] == expected_overridden_name @pytest.mark.django_db def test_credential_subject_achievement_description( - self, monkeypatch, issuance_line, user_credential, site_configuration + self, monkeypatch, program_issuance_line, program_user_credential, site_configuration ): # pylint: disable=unused-argument """ Credential Subject Achievement `description` property. """ expected_description = "Program certificate is granted on program TestProgram1 completion offered by TestOrg1, TestOrg2, in collaboration with TestPlatformName1. The TestProgram1 program includes 2 course(s)." # pylint: disable=line-too-long - monkeypatch.setattr(issuance_line.user_credential.credential.program, "total_hours_of_effort", None) + monkeypatch.setattr(program_issuance_line.user_credential.credential.program, "total_hours_of_effort", None) - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["credentialSubject"]["achievement"]["description"] == expected_description @pytest.mark.django_db def test_credential_subject_achievement_description_with_effort( - self, issuance_line, user_credential, site_configuration + self, program_issuance_line, program_user_credential, site_configuration ): # pylint: disable=unused-argument """ Credential Subject Achievement `description` property (Program Certificate with Effort specified). """ expected_description = "Program certificate is granted on program TestProgram1 completion offered by TestOrg1, TestOrg2, in collaboration with TestPlatformName1. The TestProgram1 program includes 2 course(s), with total 10 Hours of effort required to complete it." # pylint: disable=line-too-long - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data assert composed_obv3["credentialSubject"]["achievement"]["description"] == expected_description @pytest.mark.django_db def test_credential_subject_achievement_criteria( - self, monkeypatch, issuance_line, user, site_configuration + self, monkeypatch, program_issuance_line, user, site_configuration ): # pylint: disable=unused-argument """ Credential Subject Achievement `criteria` property. """ expected_narrative_value = "TestUser1 FullName successfully completed all courses and received passing grades for a Professional Certificate in TestProgram1 a program offered by TestOrg1, TestOrg2, in collaboration with TestPlatformName1." # pylint: disable=line-too-long - monkeypatch.setattr(issuance_line.user_credential, "username", user.username) + monkeypatch.setattr(program_issuance_line.user_credential, "username", user.username) - composed_obv3 = OpenBadgesDataModel(issuance_line).data + composed_obv3 = OpenBadgesDataModel(program_issuance_line).data + + assert composed_obv3["credentialSubject"]["achievement"]["criteria"]["narrative"] == expected_narrative_value + + @pytest.mark.django_db + def test_credential_subject_achievement_default_name_course(self, course_issuance_line): + """ + Credential Subject Achievement default `name` property. + """ + expected_default_name = "course certificate" + + composed_obv3 = OpenBadgesDataModel(course_issuance_line).data + + assert composed_obv3["credentialSubject"]["achievement"]["name"] == expected_default_name + + @pytest.mark.django_db + def test_credential_subject_achievement_description_with_effort_course( + self, course_issuance_line, site_configuration + ): # pylint: disable=unused-argument + """ + Credential Subject Achievement `description` property (Course Certificate with Effort specified). + """ + expected_description = f"Course certificate is granted on course {course_issuance_line.course.title} completion offered by course-run-id, in collaboration with TestPlatformName1" # pylint: disable=line-too-long + + composed_obv3 = OpenBadgesDataModel(course_issuance_line).data + + assert composed_obv3["credentialSubject"]["achievement"]["description"] == expected_description + + @pytest.mark.django_db + def test_credential_subject_achievement_criteria_course( + self, course_issuance_line, site_configuration + ): # pylint: disable=unused-argument + """ + Credential Subject Achievement `criteria` property. + """ + expected_narrative_value = f"Recipient successfully completed a course and received a passing grade for a Course Certificate in {course_issuance_line.course.title} a course offered by course-run-id, in collaboration with TestPlatformName1. " # pylint: disable=line-too-long + + composed_obv3 = OpenBadgesDataModel(course_issuance_line).data assert composed_obv3["credentialSubject"]["achievement"]["criteria"]["narrative"] == expected_narrative_value 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..9f9ef98fd --- /dev/null +++ b/credentials/apps/verifiable_credentials/composition/tests/test_schemas.py @@ -0,0 +1,86 @@ +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/conftest.py b/credentials/apps/verifiable_credentials/conftest.py index 73c397290..2c2b62371 100644 --- a/credentials/apps/verifiable_credentials/conftest.py +++ b/credentials/apps/verifiable_credentials/conftest.py @@ -11,7 +11,11 @@ ProgramFactory, ) from credentials.apps.core.tests.factories import SiteConfigurationFactory, SiteFactory, UserFactory -from credentials.apps.credentials.tests.factories import ProgramCertificateFactory, UserCredentialFactory +from credentials.apps.credentials.tests.factories import ( + CourseCertificateFactory, + ProgramCertificateFactory, + UserCredentialFactory, +) from credentials.apps.verifiable_credentials.issuance.tests.factories import IssuanceLineFactory @@ -109,10 +113,25 @@ def program_certificate(site, program_setup): @pytest.fixture() -def user_credential(program_certificate): +def course_certificate(site, two_course_runs): + return CourseCertificateFactory.create(course_id=two_course_runs[0].key, course_run=two_course_runs[0], site=site) + + +@pytest.fixture() +def program_user_credential(program_certificate): return UserCredentialFactory(credential=program_certificate) @pytest.fixture() -def issuance_line(user_credential): - return IssuanceLineFactory(user_credential=user_credential, subject_id="did:key:test") +def course_user_credential(course_certificate): + return UserCredentialFactory(credential=course_certificate) + + +@pytest.fixture() +def program_issuance_line(program_user_credential): + return IssuanceLineFactory(user_credential=program_user_credential, subject_id="did:key:test") + + +@pytest.fixture() +def course_issuance_line(course_user_credential): + return IssuanceLineFactory(user_credential=course_user_credential, subject_id="did:key:test") diff --git a/credentials/apps/verifiable_credentials/constants.py b/credentials/apps/verifiable_credentials/constants.py new file mode 100644 index 000000000..9e141accb --- /dev/null +++ b/credentials/apps/verifiable_credentials/constants.py @@ -0,0 +1,7 @@ +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..1c7340ae0 100644 --- a/credentials/apps/verifiable_credentials/issuance/models.py +++ b/credentials/apps/verifiable_credentials/issuance/models.py @@ -12,10 +12,12 @@ 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 ..constants import CredentialsType from ..settings import vc_settings from ..storages.utils import get_storage @@ -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,60 @@ 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}" # pylint: disable=line-too-long + ).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) # pylint: disable=possibly-used-before-assignment @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) # pylint: disable=possibly-used-before-assignment @property def credential_content_type(self): @@ -183,6 +197,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..f2afd8093 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 @@ -1,6 +1,7 @@ import json from unittest import mock +from ddt import data, ddt, unpack from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls import reverse @@ -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,21 +75,47 @@ 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): @@ -217,7 +245,7 @@ def test_post_valid_request(self, mock_issue): mock_issue.return_value = {"verifiable_credential": "test"} url_path = reverse("verifiable_credentials:api:v1:credentials-issue", args=[str(self.issuance_line.uuid)]) - data = {"holder": "test-holder-id"} + data = {"holder": "test-holder-id"} # pylint: disable=redefined-outer-name response = self.client.post(url_path, json.dumps(data), JSON_CONTENT_TYPE) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data, {"verifiable_credential": "test"}) @@ -226,7 +254,7 @@ def test_post_valid_request(self, mock_issue): def test_post_invalid_request_raises_validation_error(self, mock_issue): self.authenticate_user(self.user) mock_issue.side_effect = IssuanceException(detail="Invalid request.") - data = {"holder": "test-holder-id"} + data = {"holder": "test-holder-id"} # pylint: disable=redefined-outer-name url_path = reverse("verifiable_credentials:api:v1:credentials-issue", args=[str(self.issuance_line.uuid)]) response = self.client.post(url_path, json.dumps(data), JSON_CONTENT_TYPE) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 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..98a57672f 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,16 @@ User = get_user_model() -class ProgramCredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): +class CredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + """ + A view set to list all credentials issued to the authenticated user. + Supports filtering by credential type. + + Credential Types: + - programcertificate: Credentials associated with a program. + - coursecertificate: Credentials associated with a course. + """ + authentication_classes = ( JwtAuthentication, SessionAuthentication, @@ -43,17 +52,74 @@ 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/ + Retrieve a list of issued credentials for the authenticated user. + + GET: /verifiable_credentials/api/v1/credentials?types=coursecertificate,programcertificate + + Query Parameters: + types (str, optional): A comma-separated list of credential types to filter the results. + Valid types are "programcertificate" and "coursecertificate". + If not provided, all credential types will be included. + Arguments: - request: A request to control data returned in endpoint response + request: The HTTP request object containing the query parameters. + Returns: - response(dict): Information about the user's program credentials + response(dict): A dictionary where each key corresponds to a credential type, and the value + is a list of issued credentials for that type. + + Example Response: + { + "program_credentials": [ + { + "uuid": "4a665745d1ba4dfd8f54b58e822b6585", + "status": "awarded", + "username": "staff", + "download_url": null, + "credential_id": 1, + "credential_uuid": "525756b010aa4c788881141acca72538", # Program UUID + "credential_title": "Title of a program", + "credential_org": "", + "modified_date": "2024-12-18" + } + ], + "course_credentials": [ + { + "uuid": "5135e99ef1d14bca9135972270ef887b", + "status": "awarded", + "username": "staff", + "download_url": null, + "credential_id": 1, + "credential_uuid": "course-v1:rg+program_course+2024", # Course ID + "credential_title": "Course cert configuration", + "credential_org": "rg", + "modified_date": "2024-12-18" + } + ] + } """ - 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 credential_type in types: + if credential_type in self.CREDENTIAL_TYPES_MAP: + response[self.CREDENTIAL_TYPES_MAP[credential_type]] = get_user_credentials_data( + request.user.username, credential_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..106782752 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,27 @@ 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,22 +110,65 @@ 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.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.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.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): diff --git a/credentials/apps/verifiable_credentials/utils.py b/credentials/apps/verifiable_credentials/utils.py index 11428aef3..641b6a9bf 100644 --- a/credentials/apps/verifiable_credentials/utils.py +++ b/credentials/apps/verifiable_credentials/utils.py @@ -5,41 +5,62 @@ import qrcode from django.contrib.contenttypes.models import ContentType +from credentials.apps.catalog.models import CourseRun from credentials.apps.credentials.api import get_user_credentials_by_content_type 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 [ - { - "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( + + 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) - ), - "modified_date": credential.modified.date().isoformat(), - } - for credential in program_credentials - ] + ) + elif model == "coursecertificate": + course_run = CourseRun.objects.filter(key=credential.credential.course_id).first() + course = getattr(course_run, "course", None) + credential_uuid = credential.credential.course_id + credential_title = credential.credential.title or getattr(course, "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, + "credential_uuid": credential_uuid, # pylint: disable=possibly-used-before-assignment + "credential_title": credential_title, # pylint: disable=possibly-used-before-assignment + "credential_org": credential_org, # pylint: disable=possibly-used-before-assignment + "modified_date": credential.modified.date().isoformat(), + } + ) + + 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 13fa51d52..f379a2687 100644 Binary files a/credentials/conf/locale/eo/LC_MESSAGES/django.mo and b/credentials/conf/locale/eo/LC_MESSAGES/django.mo differ diff --git a/credentials/conf/locale/eo/LC_MESSAGES/django.po b/credentials/conf/locale/eo/LC_MESSAGES/django.po index b87bed099..a1b284508 100644 --- a/credentials/conf/locale/eo/LC_MESSAGES/django.po +++ b/credentials/conf/locale/eo/LC_MESSAGES/django.po @@ -1,7 +1,7 @@ # edX translation file. -# Copyright (C) 2024 EdX +# Copyright (C) 2025 EdX # This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. -# EdX Team , 2024. +# EdX Team , 2025. # msgid "" msgstr "" @@ -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 24b820aae..83d12d829 100644 Binary files a/credentials/conf/locale/rtl/LC_MESSAGES/django.mo and b/credentials/conf/locale/rtl/LC_MESSAGES/django.mo differ diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/django.po b/credentials/conf/locale/rtl/LC_MESSAGES/django.po index 5a64fdeda..40e712e0f 100644 --- a/credentials/conf/locale/rtl/LC_MESSAGES/django.po +++ b/credentials/conf/locale/rtl/LC_MESSAGES/django.po @@ -1,7 +1,7 @@ # edX translation file. -# Copyright (C) 2024 EdX +# Copyright (C) 2025 EdX # This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. -# EdX Team , 2024. +# EdX Team , 2025. # msgid "" msgstr "" @@ -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 "" diff --git a/docs/verifiable_credentials/components.rst b/docs/verifiable_credentials/components.rst index 286e75751..0fe40e16d 100644 --- a/docs/verifiable_credentials/components.rst +++ b/docs/verifiable_credentials/components.rst @@ -64,7 +64,7 @@ The Verifiable Credentials feature extends the `Learner Record MFE`_ with additi :alt: Verifiable Credentials page 1. Once the Verifiable Credentials feature `is enabled `__ tabs navigation appears -2. All learner's Open edX credentials (currently, program certificates only) are listed within the page +2. All learner's Open edX credentials are listed within the page 3. Achievement card has an action button that allows verifiable credential requesting based on the corresponding Open edX credential 4. Storages options (experimental) diff --git a/docs/verifiable_credentials/usage.rst b/docs/verifiable_credentials/usage.rst index 1001cca03..cb07f267f 100644 --- a/docs/verifiable_credentials/usage.rst +++ b/docs/verifiable_credentials/usage.rst @@ -14,8 +14,8 @@ Verifiable credential includes data about: - student's unique decentralized identifier (DID); - student's arbitrary personal data (optional); - what exactly was achieved (credential) - - type (program certificate); - - title (program name, possibly courses list); + - type (program or course certificate); + - title (program or course name, possibly courses list); - when it happened (timestamp) - date and time verifiable credential was created (issued); - expiration moment (optionally);