Skip to content

Commit

Permalink
feat: course credentials as verifiable credentials
Browse files Browse the repository at this point in the history
  • Loading branch information
kyrylo-kh committed Feb 10, 2025
1 parent f7ff165 commit ae4ff7e
Show file tree
Hide file tree
Showing 20 changed files with 537 additions and 132 deletions.
31 changes: 30 additions & 1 deletion credentials/apps/verifiable_credentials/composition/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from rest_framework import serializers

from ..constants import CredentialsType


class EducationalOccupationalProgramSchema(serializers.Serializer): # pylint: disable=abstract-method
"""
Expand All @@ -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.
Expand All @@ -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__"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
27 changes: 23 additions & 4 deletions credentials/apps/verifiable_credentials/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Loading

0 comments on commit ae4ff7e

Please sign in to comment.