Skip to content

Commit 272acea

Browse files
committed
feat: course credentials as verifiable credentials
1 parent f7ff165 commit 272acea

File tree

19 files changed

+536
-132
lines changed

19 files changed

+536
-132
lines changed

credentials/apps/verifiable_credentials/composition/schemas.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from rest_framework import serializers
66

7+
from ..constants import CredentialsType
8+
79

810
class EducationalOccupationalProgramSchema(serializers.Serializer): # pylint: disable=abstract-method
911
"""
@@ -20,6 +22,21 @@ class Meta:
2022
read_only_fields = "__all__"
2123

2224

25+
class EducationalOccupationalCourseSchema(serializers.Serializer): # pylint: disable=abstract-method
26+
"""
27+
Defines Open edX Course.
28+
"""
29+
30+
TYPE = "Course"
31+
32+
id = serializers.CharField(default=TYPE, help_text="https://schema.org/Course")
33+
name = serializers.CharField(source="course.title")
34+
courseCode = serializers.CharField(source="user_credential.credential.course_id")
35+
36+
class Meta:
37+
read_only_fields = "__all__"
38+
39+
2340
class EducationalOccupationalCredentialSchema(serializers.Serializer): # pylint: disable=abstract-method
2441
"""
2542
Defines Open edX user credential.
@@ -30,7 +47,19 @@ class EducationalOccupationalCredentialSchema(serializers.Serializer): # pylint
3047
id = serializers.CharField(default=TYPE, help_text="https://schema.org/EducationalOccupationalCredential")
3148
name = serializers.CharField(source="user_credential.credential.title")
3249
description = serializers.CharField(source="user_credential.uuid")
33-
program = EducationalOccupationalProgramSchema(source="*")
50+
51+
def to_representation(self, instance):
52+
"""
53+
Dynamically add fields based on the type.
54+
"""
55+
representation = super().to_representation(instance)
56+
57+
if instance.user_credential.credential_content_type.model == CredentialsType.PROGRAM:
58+
representation["program"] = EducationalOccupationalProgramSchema(instance).data
59+
elif instance.user_credential.credential_content_type.model == CredentialsType.COURSE:
60+
representation["course"] = EducationalOccupationalCourseSchema(instance).data
61+
62+
return representation
3463

3564
class Meta:
3665
read_only_fields = "__all__"

credentials/apps/verifiable_credentials/composition/tests/test_open_badges.py

Lines changed: 68 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,144 +21,181 @@ class TestOpenBadgesDataModel:
2121
"""
2222

2323
@pytest.mark.django_db
24-
def test_default_name(self, issuance_line):
24+
def test_default_name(self, program_issuance_line):
2525
"""
2626
Predefined for Program certificate value is used as `name` property.
2727
"""
2828
expected_default_name = "Program certificate for passing a program TestProgram1"
2929

30-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
30+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
3131

3232
assert composed_obv3["name"] == expected_default_name
3333

3434
@pytest.mark.django_db
35-
def test_overridden_name(self, monkeypatch, issuance_line):
35+
def test_overridden_name(self, monkeypatch, program_issuance_line):
3636
"""
3737
Program certificate title overrides `name` property.
3838
"""
3939
expected_overridden_name = "Explicit Credential Title"
40-
monkeypatch.setattr(issuance_line.user_credential.credential, "title", expected_overridden_name)
40+
monkeypatch.setattr(program_issuance_line.user_credential.credential, "title", expected_overridden_name)
4141

42-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
42+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
4343

4444
assert composed_obv3["name"] == expected_overridden_name
4545

4646
@pytest.mark.django_db
47-
def test_credential_subject_id(self, issuance_line):
47+
def test_credential_subject_id(self, program_issuance_line):
4848
"""
4949
Credential Subject `id` property.
5050
"""
51-
expected_id = issuance_line.subject_id
51+
expected_id = program_issuance_line.subject_id
5252

53-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
53+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
5454

5555
assert composed_obv3["credentialSubject"]["id"] == expected_id
5656

5757
@pytest.mark.django_db
58-
def test_credential_subject_type(self, issuance_line):
58+
def test_credential_subject_type(self, program_issuance_line):
5959
"""
6060
Credential Subject `type` property.
6161
"""
6262
expected_type = CredentialSubjectSchema.TYPE
6363

64-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
64+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
6565

6666
assert composed_obv3["credentialSubject"]["type"] == expected_type
6767

6868
@pytest.mark.django_db
69-
def test_credential_subject_name(self, monkeypatch, issuance_line, user):
69+
def test_credential_subject_name(self, monkeypatch, program_issuance_line, user):
7070
"""
7171
Credential Subject `name` property.
7272
"""
7373
expected_name = user.full_name
74-
monkeypatch.setattr(issuance_line.user_credential, "username", user.username)
74+
monkeypatch.setattr(program_issuance_line.user_credential, "username", user.username)
7575

76-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
76+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
7777

7878
assert composed_obv3["credentialSubject"]["name"] == expected_name
7979

8080
@pytest.mark.django_db
81-
def test_credential_subject_achievement_id(self, issuance_line):
81+
def test_credential_subject_achievement_id(self, program_issuance_line):
8282
"""
8383
Credential Subject Achievement `id` property.
8484
"""
85-
expected_id = str(issuance_line.user_credential.uuid)
85+
expected_id = str(program_issuance_line.user_credential.uuid)
8686

87-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
87+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
8888

8989
assert composed_obv3["credentialSubject"]["achievement"]["id"] == expected_id
9090

9191
@pytest.mark.django_db
92-
def test_credential_subject_achievement_type(self, issuance_line):
92+
def test_credential_subject_achievement_type(self, program_issuance_line):
9393
"""
9494
Credential Subject Achievement `type` property.
9595
"""
9696
expected_type = AchievementSchema.TYPE
9797

98-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
98+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
9999

100100
assert composed_obv3["credentialSubject"]["achievement"]["type"] == expected_type
101101

102102
@pytest.mark.django_db
103-
def test_credential_subject_achievement_default_name(self, issuance_line):
103+
def test_credential_subject_achievement_default_name(self, program_issuance_line):
104104
"""
105105
Credential Subject Achievement default `name` property.
106106
"""
107107
expected_default_name = "Program certificate for passing a program TestProgram1"
108108

109-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
109+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
110110

111111
assert composed_obv3["credentialSubject"]["achievement"]["name"] == expected_default_name
112112

113113
@pytest.mark.django_db
114-
def test_credential_subject_achievement_overridden_name(self, monkeypatch, issuance_line):
114+
def test_credential_subject_achievement_overridden_name(self, monkeypatch, program_issuance_line):
115115
"""
116116
Credential Subject Achievement overridden `name` property.
117117
"""
118118
expected_overridden_name = "Explicit Credential Title"
119-
monkeypatch.setattr(issuance_line.user_credential.credential, "title", expected_overridden_name)
119+
monkeypatch.setattr(program_issuance_line.user_credential.credential, "title", expected_overridden_name)
120120

121-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
121+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
122122

123123
assert composed_obv3["credentialSubject"]["achievement"]["name"] == expected_overridden_name
124124

125125
@pytest.mark.django_db
126126
def test_credential_subject_achievement_description(
127-
self, monkeypatch, issuance_line, user_credential, site_configuration
127+
self, monkeypatch, program_issuance_line, program_user_credential, site_configuration
128128
): # pylint: disable=unused-argument
129129
"""
130130
Credential Subject Achievement `description` property.
131131
"""
132132
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
133-
monkeypatch.setattr(issuance_line.user_credential.credential.program, "total_hours_of_effort", None)
133+
monkeypatch.setattr(program_issuance_line.user_credential.credential.program, "total_hours_of_effort", None)
134134

135-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
135+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
136136

137137
assert composed_obv3["credentialSubject"]["achievement"]["description"] == expected_description
138138

139139
@pytest.mark.django_db
140140
def test_credential_subject_achievement_description_with_effort(
141-
self, issuance_line, user_credential, site_configuration
141+
self, program_issuance_line, program_user_credential, site_configuration
142142
): # pylint: disable=unused-argument
143143
"""
144144
Credential Subject Achievement `description` property (Program Certificate with Effort specified).
145145
"""
146146
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
147147

148-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
148+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
149149

150150
assert composed_obv3["credentialSubject"]["achievement"]["description"] == expected_description
151151

152152
@pytest.mark.django_db
153153
def test_credential_subject_achievement_criteria(
154-
self, monkeypatch, issuance_line, user, site_configuration
154+
self, monkeypatch, program_issuance_line, user, site_configuration
155155
): # pylint: disable=unused-argument
156156
"""
157157
Credential Subject Achievement `criteria` property.
158158
"""
159159
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
160-
monkeypatch.setattr(issuance_line.user_credential, "username", user.username)
160+
monkeypatch.setattr(program_issuance_line.user_credential, "username", user.username)
161161

162-
composed_obv3 = OpenBadgesDataModel(issuance_line).data
162+
composed_obv3 = OpenBadgesDataModel(program_issuance_line).data
163+
164+
assert composed_obv3["credentialSubject"]["achievement"]["criteria"]["narrative"] == expected_narrative_value
165+
166+
@pytest.mark.django_db
167+
def test_credential_subject_achievement_default_name_course(self, course_issuance_line):
168+
"""
169+
Credential Subject Achievement default `name` property.
170+
"""
171+
expected_default_name = "course certificate"
172+
173+
composed_obv3 = OpenBadgesDataModel(course_issuance_line).data
174+
175+
assert composed_obv3["credentialSubject"]["achievement"]["name"] == expected_default_name
176+
177+
@pytest.mark.django_db
178+
def test_credential_subject_achievement_description_with_effort_course(
179+
self, course_issuance_line, site_configuration
180+
): # pylint: disable=unused-argument
181+
"""
182+
Credential Subject Achievement `description` property (Course Certificate with Effort specified).
183+
"""
184+
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
185+
186+
composed_obv3 = OpenBadgesDataModel(course_issuance_line).data
187+
188+
assert composed_obv3["credentialSubject"]["achievement"]["description"] == expected_description
189+
190+
@pytest.mark.django_db
191+
def test_credential_subject_achievement_criteria_course(
192+
self, course_issuance_line, site_configuration
193+
): # pylint: disable=unused-argument
194+
"""
195+
Credential Subject Achievement `criteria` property.
196+
"""
197+
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
198+
199+
composed_obv3 = OpenBadgesDataModel(course_issuance_line).data
163200

164201
assert composed_obv3["credentialSubject"]["achievement"]["criteria"]["narrative"] == expected_narrative_value
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from django.contrib.contenttypes.models import ContentType
2+
from django.test import TestCase
3+
4+
from credentials.apps.catalog.tests.factories import (
5+
CourseFactory,
6+
CourseRunFactory,
7+
OrganizationFactory,
8+
ProgramFactory,
9+
)
10+
from credentials.apps.core.tests.factories import UserFactory
11+
from credentials.apps.core.tests.mixins import SiteMixin
12+
from credentials.apps.credentials.tests.factories import (
13+
CourseCertificateFactory,
14+
ProgramCertificateFactory,
15+
UserCredentialFactory,
16+
)
17+
from credentials.apps.verifiable_credentials.composition.schemas import EducationalOccupationalCredentialSchema
18+
from credentials.apps.verifiable_credentials.issuance.tests.factories import IssuanceLineFactory
19+
20+
21+
class EducationalOccupationalCredentialSchemaTests(SiteMixin, TestCase):
22+
def setUp(self):
23+
super().setUp()
24+
self.user = UserFactory()
25+
self.orgs = [OrganizationFactory.create(name=name, site=self.site) for name in ["TestOrg1", "TestOrg2"]]
26+
self.course = CourseFactory.create(site=self.site)
27+
self.course_runs = CourseRunFactory.create_batch(2, course=self.course)
28+
self.program = ProgramFactory(
29+
title="TestProgram1",
30+
course_runs=self.course_runs,
31+
authoring_organizations=self.orgs,
32+
site=self.site,
33+
)
34+
self.course_certs = [
35+
CourseCertificateFactory.create(
36+
course_id=course_run.key,
37+
course_run=course_run,
38+
site=self.site,
39+
)
40+
for course_run in self.course_runs
41+
]
42+
self.program_cert = ProgramCertificateFactory.create(
43+
program=self.program, program_uuid=self.program.uuid, site=self.site
44+
)
45+
self.course_credential_content_type = ContentType.objects.get(
46+
app_label="credentials", model="coursecertificate"
47+
)
48+
self.program_credential_content_type = ContentType.objects.get(
49+
app_label="credentials", model="programcertificate"
50+
)
51+
self.course_user_credential = UserCredentialFactory.create(
52+
username=self.user.username,
53+
credential_content_type=self.course_credential_content_type,
54+
credential=self.course_certs[0],
55+
)
56+
self.program_user_credential = UserCredentialFactory.create(
57+
username=self.user.username,
58+
credential_content_type=self.program_credential_content_type,
59+
credential=self.program_cert,
60+
)
61+
self.program_issuance_line = IssuanceLineFactory(
62+
user_credential=self.program_user_credential, subject_id="did:key:test"
63+
)
64+
self.course_issuance_line = IssuanceLineFactory(
65+
user_credential=self.course_user_credential, subject_id="did:key:test"
66+
)
67+
68+
def test_to_representation_program(self):
69+
data = EducationalOccupationalCredentialSchema(self.program_issuance_line).data
70+
71+
assert data["id"] == "EducationalOccupationalCredential"
72+
assert data["name"] == self.program_cert.title
73+
assert data["description"] == str(self.program_user_credential.uuid)
74+
assert data["program"]["id"] == "EducationalOccupationalProgram"
75+
assert data["program"]["name"] == self.program.title
76+
assert data["program"]["description"] == str(self.program.uuid)
77+
78+
def test_to_representation_course(self):
79+
data = EducationalOccupationalCredentialSchema(self.course_issuance_line).data
80+
81+
assert data["id"] == "EducationalOccupationalCredential"
82+
assert data["name"] == self.course_certs[0].title
83+
assert data["description"] == str(self.course_user_credential.uuid)
84+
assert data["course"]["id"] == "Course"
85+
assert data["course"]["name"] == self.course.title
86+
assert data["course"]["courseCode"] == self.course_certs[0].course_id

credentials/apps/verifiable_credentials/conftest.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
ProgramFactory,
1212
)
1313
from credentials.apps.core.tests.factories import SiteConfigurationFactory, SiteFactory, UserFactory
14-
from credentials.apps.credentials.tests.factories import ProgramCertificateFactory, UserCredentialFactory
14+
from credentials.apps.credentials.tests.factories import (
15+
CourseCertificateFactory,
16+
ProgramCertificateFactory,
17+
UserCredentialFactory,
18+
)
1519
from credentials.apps.verifiable_credentials.issuance.tests.factories import IssuanceLineFactory
1620

1721

@@ -109,10 +113,25 @@ def program_certificate(site, program_setup):
109113

110114

111115
@pytest.fixture()
112-
def user_credential(program_certificate):
116+
def course_certificate(site, two_course_runs):
117+
return CourseCertificateFactory.create(course_id=two_course_runs[0].key, course_run=two_course_runs[0], site=site)
118+
119+
120+
@pytest.fixture()
121+
def program_user_credential(program_certificate):
113122
return UserCredentialFactory(credential=program_certificate)
114123

115124

116125
@pytest.fixture()
117-
def issuance_line(user_credential):
118-
return IssuanceLineFactory(user_credential=user_credential, subject_id="did:key:test")
126+
def course_user_credential(course_certificate):
127+
return UserCredentialFactory(credential=course_certificate)
128+
129+
130+
@pytest.fixture()
131+
def program_issuance_line(program_user_credential):
132+
return IssuanceLineFactory(user_credential=program_user_credential, subject_id="did:key:test")
133+
134+
135+
@pytest.fixture()
136+
def course_issuance_line(course_user_credential):
137+
return IssuanceLineFactory(user_credential=course_user_credential, subject_id="did:key:test")

0 commit comments

Comments
 (0)