Skip to content

Commit 0f2911b

Browse files
committed
feat: course credentials as verifiable credentials
1 parent 6a122d0 commit 0f2911b

File tree

8 files changed

+218
-77
lines changed

8 files changed

+218
-77
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: 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"),

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from credentials.apps.verifiable_credentials.storages.utils import get_available_storages, get_storage
2626
from credentials.apps.verifiable_credentials.utils import (
2727
generate_base64_qr_code,
28-
get_user_program_credentials_data,
28+
get_user_credentials_data,
2929
is_valid_uuid,
3030
)
3131

@@ -35,25 +35,41 @@
3535
User = get_user_model()
3636

3737

38-
class ProgramCredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
38+
class CredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
3939
authentication_classes = (
4040
JwtAuthentication,
4141
SessionAuthentication,
4242
)
4343

4444
permission_classes = (IsAuthenticated,)
4545

46+
CREDENTIAL_TYPES_MAP = {
47+
"programcertificate": "program_credentials",
48+
"coursecertificate": "course_credentials",
49+
}
50+
4651
def list(self, request, *args, **kwargs):
4752
"""
48-
List data for all the user's issued program credentials.
49-
GET: /verifiable_credentials/api/v1/program_credentials/
53+
List data for all the user's issued credentials.
54+
GET: /verifiable_credentials/api/v1/credentials?types=coursecertificate,programcertificate
5055
Arguments:
5156
request: A request to control data returned in endpoint response
5257
Returns:
5358
response(dict): Information about the user's program credentials
5459
"""
55-
program_credentials = get_user_program_credentials_data(request.user.username)
56-
return Response({"program_credentials": program_credentials})
60+
types = self.request.query_params.get('types')
61+
response = {}
62+
63+
if types:
64+
types = types.split(',')
65+
else:
66+
types = self.CREDENTIAL_TYPES_MAP.keys()
67+
68+
for type in types:
69+
if type in self.CREDENTIAL_TYPES_MAP:
70+
response[self.CREDENTIAL_TYPES_MAP[type]] = get_user_credentials_data(request.user.username, type)
71+
72+
return Response(response)
5773

5874

5975
class InitIssuanceView(APIView):

0 commit comments

Comments
 (0)