Skip to content

Commit 7ff3056

Browse files
committed
feat: course credentials as verifiable credentials
1 parent f7ff165 commit 7ff3056

File tree

15 files changed

+360
-85
lines changed

15 files changed

+360
-85
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__"
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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(user_credential=self.program_user_credential, subject_id="did:key:test")
62+
self.course_issuance_line = IssuanceLineFactory(user_credential=self.course_user_credential, subject_id="did:key:test")
63+
64+
65+
def test_to_representation_program(self):
66+
data = EducationalOccupationalCredentialSchema(self.program_issuance_line).data
67+
68+
assert data["id"] == "EducationalOccupationalCredential"
69+
assert data["name"] == self.program_cert.title
70+
assert data["description"] == str(self.program_user_credential.uuid)
71+
assert data["program"]["id"] == "EducationalOccupationalProgram"
72+
assert data["program"]["name"] == self.program.title
73+
assert data["program"]["description"] == str(self.program.uuid)
74+
75+
def test_to_representation_course(self):
76+
data = EducationalOccupationalCredentialSchema(self.course_issuance_line).data
77+
78+
assert data["id"] == "EducationalOccupationalCredential"
79+
assert data["name"] == self.course_certs[0].title
80+
assert data["description"] == str(self.course_user_credential.uuid)
81+
assert data["course"]["id"] == "Course"
82+
assert data["course"]["name"] == self.course.title
83+
assert data["course"]["courseCode"] == self.course_certs[0].course_id
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class CredentialsType:
2+
"""
3+
Enum to define the type of credentials.
4+
"""
5+
PROGRAM = "programcertificate"
6+
COURSE = "coursecertificate"

credentials/apps/verifiable_credentials/issuance/models.py

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
from django.utils.translation import gettext_lazy as _
1313
from django_extensions.db.models import TimeStampedModel
1414

15+
from credentials.apps.catalog.models import Course
1516
from credentials.apps.credentials.models import UserCredential
1617
from credentials.apps.verifiable_credentials.utils import capitalize_first
1718

1819
from ..composition.utils import get_data_model, get_data_models
1920
from ..settings import vc_settings
2021
from ..storages.utils import get_storage
22+
from ..constants import CredentialsType
2123

2224

2325
User = get_user_model()
@@ -106,8 +108,8 @@ def credential_verbose_type(self):
106108
Map internal credential types to verbose labels (source models do not provide those).
107109
"""
108110
contenttype_to_verbose_name = {
109-
"programcertificate": _("program certificate"),
110-
"coursecertificate": _("course certificate"),
111+
CredentialsType.PROGRAM: _("program certificate"),
112+
CredentialsType.COURSE: _("course certificate"),
111113
}
112114
return contenttype_to_verbose_name.get(self.credential_content_type)
113115

@@ -120,10 +122,10 @@ def credential_name(self):
120122
return credential_title
121123

122124
contenttype_to_name = {
123-
"programcertificate": _("program certificate for passing a program {program_title}").format(
125+
CredentialsType.PROGRAM: _("program certificate for passing a program {program_title}").format(
124126
program_title=getattr(self.program, "title", "")
125127
),
126-
"coursecertificate": self.credential_verbose_type,
128+
CredentialsType.COURSE: self.credential_verbose_type,
127129
}
128130
return capitalize_first(contenttype_to_name.get(self.credential_content_type))
129131

@@ -132,48 +134,58 @@ def credential_description(self):
132134
"""
133135
Verifiable credential achievement description resolution.
134136
"""
135-
effort_portion = (
136-
_(", with total {hours_of_effort} Hours of effort required to complete it").format(
137-
hours_of_effort=self.program.total_hours_of_effort
137+
if self.credential_content_type == CredentialsType.PROGRAM:
138+
effort_portion = (
139+
_(", with total {hours_of_effort} Hours of effort required to complete it").format(
140+
hours_of_effort=self.program.total_hours_of_effort
141+
)
142+
if self.program.total_hours_of_effort
143+
else ""
138144
)
139-
if self.program.total_hours_of_effort
140-
else ""
141-
)
142145

143-
program_certificate_description = _(
144-
"{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
145-
).format(
146-
credential_type=self.credential_verbose_type,
147-
program_title=self.program.title,
148-
organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))),
149-
platform_name=self.platform_name,
150-
course_count=self.program.course_runs.count(),
151-
effort_info=effort_portion,
152-
)
153-
type_to_description = {
154-
"programcertificate": program_certificate_description,
155-
"coursecertificate": "",
156-
}
157-
return capitalize_first(type_to_description.get(self.credential_content_type))
146+
description = _(
147+
"{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
148+
).format(
149+
credential_type=self.credential_verbose_type,
150+
program_title=self.program.title,
151+
organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))),
152+
platform_name=self.platform_name,
153+
course_count=self.program.course_runs.count(),
154+
effort_info=effort_portion,
155+
)
156+
elif self.credential_content_type == CredentialsType.COURSE:
157+
description = _("{credential_type} is granted on course {course_title} completion offered by {organization}, in collaboration with {platform_name}").format(
158+
credential_type=self.credential_verbose_type,
159+
course_title=getattr(self.course, "title", ""),
160+
platform_name=self.platform_name,
161+
organization=self.user_credential.credential.course_key.org,
162+
)
163+
return capitalize_first(description)
158164

159165
@property
160166
def credential_narrative(self):
161167
"""
162168
Verifiable credential achievement criteria narrative.
163169
"""
164-
program_certificate_narrative = _(
165-
"{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
166-
).format(
167-
recipient_fullname=self.subject_fullname or _("recipient"),
168-
program_title=self.program.title,
169-
organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))),
170-
platform_name=self.platform_name,
171-
)
172-
type_to_narrative = {
173-
"programcertificate": program_certificate_narrative,
174-
"coursecertificate": "",
175-
}
176-
return capitalize_first(type_to_narrative.get(self.credential_content_type))
170+
if self.credential_content_type == CredentialsType.PROGRAM:
171+
narrative = _(
172+
"{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
173+
).format(
174+
recipient_fullname=self.subject_fullname or _("recipient"),
175+
program_title=self.program.title,
176+
organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))),
177+
platform_name=self.platform_name,
178+
)
179+
elif self.credential_content_type == CredentialsType.COURSE:
180+
narrative = _(
181+
"{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
182+
).format(
183+
recipient_fullname=self.subject_fullname or _("recipient"),
184+
course_title=getattr(self.course, "title", ""),
185+
organization=self.user_credential.credential.course_key.org,
186+
platform_name=self.platform_name,
187+
)
188+
return capitalize_first(narrative)
177189

178190
@property
179191
def credential_content_type(self):
@@ -183,6 +195,11 @@ def credential_content_type(self):
183195
def program(self):
184196
return getattr(self.user_credential.credential, "program", None)
185197

198+
@property
199+
def course(self):
200+
course_id = getattr(self.user_credential.credential, "course_id", None)
201+
return Course.objects.filter(course_runs__key=course_id).first()
202+
186203
@property
187204
def platform_name(self):
188205
if not (site_configuration := getattr(self.user_credential.credential.site, "siteconfiguration", "")):

credentials/apps/verifiable_credentials/rest_api/v1/tests/test_views.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.contrib.contenttypes.models import ContentType
55
from django.test import TestCase
66
from django.urls import reverse
7+
from ddt import ddt, data, unpack
78
from rest_framework import status
89

910
from credentials.apps.catalog.tests.factories import (
@@ -22,12 +23,13 @@
2223
from credentials.apps.verifiable_credentials.issuance import IssuanceException
2324
from credentials.apps.verifiable_credentials.issuance.tests.factories import IssuanceLineFactory
2425
from credentials.apps.verifiable_credentials.storages.learner_credential_wallet import LCWallet
25-
from credentials.apps.verifiable_credentials.utils import get_user_program_credentials_data
26+
from credentials.apps.verifiable_credentials.utils import get_user_credentials_data
2627

2728

2829
JSON_CONTENT_TYPE = "application/json"
2930

3031

32+
@ddt
3133
class ProgramCredentialsViewTests(SiteMixin, TestCase):
3234
def setUp(self):
3335
super().setUp()
@@ -73,22 +75,43 @@ def setUp(self):
7375

7476
def test_deny_unauthenticated_user(self):
7577
self.client.logout()
76-
response = self.client.get("/verifiable_credentials/api/v1/program_credentials/")
78+
response = self.client.get("/verifiable_credentials/api/v1/credentials/")
7779
self.assertEqual(response.status_code, 401)
7880

7981
def test_allow_authenticated_user(self):
8082
"""Verify the endpoint requires an authenticated user."""
8183
self.client.logout()
8284
self.client.login(username=self.user.username, password=USER_PASSWORD)
83-
response = self.client.get("/verifiable_credentials/api/v1/program_credentials/")
85+
response = self.client.get("/verifiable_credentials/api/v1/credentials/")
8486
self.assertEqual(response.status_code, 200)
8587

86-
def test_get(self):
88+
def test_get_without_query_params(self):
8789
self.client.login(username=self.user.username, password=USER_PASSWORD)
88-
response = self.client.get("/verifiable_credentials/api/v1/program_credentials/")
90+
response = self.client.get("/verifiable_credentials/api/v1/credentials/")
8991
self.assertEqual(response.status_code, 200)
90-
self.assertEqual(response.data["program_credentials"], get_user_program_credentials_data(self.user.username))
92+
self.assertEqual(response.data["program_credentials"], get_user_credentials_data(self.user.username, "programcertificate"))
93+
self.assertEqual(response.data["course_credentials"], get_user_credentials_data(self.user.username, "coursecertificate"))
94+
95+
@data(
96+
("programcertificate", {"program_credentials": "programcertificate"}, ["course_credentials"]),
97+
("coursecertificate", {"course_credentials": "coursecertificate"}, ["program_credentials"]),
98+
("programcertificate,coursecertificate",
99+
{"program_credentials": "programcertificate", "course_credentials": "coursecertificate"}, [])
100+
)
101+
@unpack
102+
def test_get_with_query_params(self, types, expected_data, not_in_keys):
103+
self.client.login(username=self.user.username, password=USER_PASSWORD)
104+
response = self.client.get(f"/verifiable_credentials/api/v1/credentials/?types={types}")
105+
self.assertEqual(response.status_code, 200)
106+
107+
for key, expected_value in expected_data.items():
108+
self.assertEqual(
109+
response.data[key],
110+
get_user_credentials_data(self.user.username, expected_value)
111+
)
91112

113+
for key in not_in_keys:
114+
self.assertNotIn(key, response.data)
92115

93116
class InitIssuanceViewTestCase(SiteMixin, TestCase):
94117
url_path = reverse("verifiable_credentials:api:v1:credentials-init")

credentials/apps/verifiable_credentials/rest_api/v1/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
router = routers.DefaultRouter()
12-
router.register(r"program_credentials", views.ProgramCredentialsViewSet, basename="program_credentials")
12+
router.register(r"credentials", views.CredentialsViewSet, basename="credentials")
1313

1414
urlpatterns = [
1515
path(r"credentials/init/", views.InitIssuanceView.as_view(), name="credentials-init"),

0 commit comments

Comments
 (0)