Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: course credentials as verifiable credentials #2674

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading