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 Jan 2, 2025
1 parent 6a122d0 commit 4b47b06
Show file tree
Hide file tree
Showing 9 changed files with 305 additions and 77 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
@@ -0,0 +1,83 @@
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
6 changes: 6 additions & 0 deletions credentials/apps/verifiable_credentials/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class CredentialsType:
"""
Enum to define the type of credentials.
"""
PROGRAM = "programcertificate"
COURSE = "coursecertificate"
93 changes: 55 additions & 38 deletions credentials/apps/verifiable_credentials/issuance/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel

from credentials.apps.catalog.models import Course
from credentials.apps.credentials.models import UserCredential
from credentials.apps.verifiable_credentials.utils import capitalize_first

from ..composition.utils import get_data_model, get_data_models
from ..settings import vc_settings
from ..storages.utils import get_storage
from ..constants import CredentialsType


User = get_user_model()
Expand Down Expand Up @@ -106,8 +108,8 @@ def credential_verbose_type(self):
Map internal credential types to verbose labels (source models do not provide those).
"""
contenttype_to_verbose_name = {
"programcertificate": _("program certificate"),
"coursecertificate": _("course certificate"),
CredentialsType.PROGRAM: _("program certificate"),
CredentialsType.COURSE: _("course certificate"),
}
return contenttype_to_verbose_name.get(self.credential_content_type)

Expand All @@ -120,10 +122,10 @@ def credential_name(self):
return credential_title

contenttype_to_name = {
"programcertificate": _("program certificate for passing a program {program_title}").format(
CredentialsType.PROGRAM: _("program certificate for passing a program {program_title}").format(
program_title=getattr(self.program, "title", "")
),
"coursecertificate": self.credential_verbose_type,
CredentialsType.COURSE: self.credential_verbose_type,
}
return capitalize_first(contenttype_to_name.get(self.credential_content_type))

Expand All @@ -132,48 +134,58 @@ def credential_description(self):
"""
Verifiable credential achievement description resolution.
"""
effort_portion = (
_(", with total {hours_of_effort} Hours of effort required to complete it").format(
hours_of_effort=self.program.total_hours_of_effort
if self.credential_content_type == CredentialsType.PROGRAM:
effort_portion = (
_(", with total {hours_of_effort} Hours of effort required to complete it").format(
hours_of_effort=self.program.total_hours_of_effort
)
if self.program.total_hours_of_effort
else ""
)
if self.program.total_hours_of_effort
else ""
)

program_certificate_description = _(
"{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
).format(
credential_type=self.credential_verbose_type,
program_title=self.program.title,
organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))),
platform_name=self.platform_name,
course_count=self.program.course_runs.count(),
effort_info=effort_portion,
)
type_to_description = {
"programcertificate": program_certificate_description,
"coursecertificate": "",
}
return capitalize_first(type_to_description.get(self.credential_content_type))
description = _(
"{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
).format(
credential_type=self.credential_verbose_type,
program_title=self.program.title,
organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))),
platform_name=self.platform_name,
course_count=self.program.course_runs.count(),
effort_info=effort_portion,
)
elif self.credential_content_type == CredentialsType.COURSE:
description = _("{credential_type} is granted on course {course_title} completion offered by {organization}, in collaboration with {platform_name}").format(

Check warning on line 157 in credentials/apps/verifiable_credentials/issuance/models.py

View check run for this annotation

Codecov / codecov/patch

credentials/apps/verifiable_credentials/issuance/models.py#L157

Added line #L157 was not covered by tests
credential_type=self.credential_verbose_type,
course_title=getattr(self.course, "title", ""),
platform_name=self.platform_name,
organization=self.user_credential.credential.course_key.org,
)
return capitalize_first(description)

@property
def credential_narrative(self):
"""
Verifiable credential achievement criteria narrative.
"""
program_certificate_narrative = _(
"{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
).format(
recipient_fullname=self.subject_fullname or _("recipient"),
program_title=self.program.title,
organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))),
platform_name=self.platform_name,
)
type_to_narrative = {
"programcertificate": program_certificate_narrative,
"coursecertificate": "",
}
return capitalize_first(type_to_narrative.get(self.credential_content_type))
if self.credential_content_type == CredentialsType.PROGRAM:
narrative = _(
"{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
).format(
recipient_fullname=self.subject_fullname or _("recipient"),
program_title=self.program.title,
organizations=", ".join(list(self.program.authoring_organizations.values_list("name", flat=True))),
platform_name=self.platform_name,
)
elif self.credential_content_type == CredentialsType.COURSE:
narrative = _(

Check warning on line 180 in credentials/apps/verifiable_credentials/issuance/models.py

View check run for this annotation

Codecov / codecov/patch

credentials/apps/verifiable_credentials/issuance/models.py#L180

Added line #L180 was not covered by tests
"{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
).format(
recipient_fullname=self.subject_fullname or _("recipient"),
course_title=getattr(self.course, "title", ""),
organization=self.user_credential.credential.course_key.org,
platform_name=self.platform_name,
)
return capitalize_first(narrative)

@property
def credential_content_type(self):
Expand All @@ -183,6 +195,11 @@ def credential_content_type(self):
def program(self):
return getattr(self.user_credential.credential, "program", None)

@property
def course(self):
course_id = getattr(self.user_credential.credential, "course_id", None)
return Course.objects.filter(course_runs__key=course_id).first()

@property
def platform_name(self):
if not (site_configuration := getattr(self.user_credential.credential.site, "siteconfiguration", "")):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.urls import reverse
from ddt import ddt, data, unpack
from rest_framework import status

from credentials.apps.catalog.tests.factories import (
Expand All @@ -22,12 +23,13 @@
from credentials.apps.verifiable_credentials.issuance import IssuanceException
from credentials.apps.verifiable_credentials.issuance.tests.factories import IssuanceLineFactory
from credentials.apps.verifiable_credentials.storages.learner_credential_wallet import LCWallet
from credentials.apps.verifiable_credentials.utils import get_user_program_credentials_data
from credentials.apps.verifiable_credentials.utils import get_user_credentials_data


JSON_CONTENT_TYPE = "application/json"


@ddt
class ProgramCredentialsViewTests(SiteMixin, TestCase):
def setUp(self):
super().setUp()
Expand Down Expand Up @@ -73,22 +75,43 @@ def setUp(self):

def test_deny_unauthenticated_user(self):
self.client.logout()
response = self.client.get("/verifiable_credentials/api/v1/program_credentials/")
response = self.client.get("/verifiable_credentials/api/v1/credentials/")
self.assertEqual(response.status_code, 401)

def test_allow_authenticated_user(self):
"""Verify the endpoint requires an authenticated user."""
self.client.logout()
self.client.login(username=self.user.username, password=USER_PASSWORD)
response = self.client.get("/verifiable_credentials/api/v1/program_credentials/")
response = self.client.get("/verifiable_credentials/api/v1/credentials/")
self.assertEqual(response.status_code, 200)

def test_get(self):
def test_get_without_query_params(self):
self.client.login(username=self.user.username, password=USER_PASSWORD)
response = self.client.get("/verifiable_credentials/api/v1/program_credentials/")
response = self.client.get("/verifiable_credentials/api/v1/credentials/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["program_credentials"], get_user_program_credentials_data(self.user.username))
self.assertEqual(response.data["program_credentials"], get_user_credentials_data(self.user.username, "programcertificate"))
self.assertEqual(response.data["course_credentials"], get_user_credentials_data(self.user.username, "coursecertificate"))

@data(
("programcertificate", {"program_credentials": "programcertificate"}, ["course_credentials"]),
("coursecertificate", {"course_credentials": "coursecertificate"}, ["program_credentials"]),
("programcertificate,coursecertificate",
{"program_credentials": "programcertificate", "course_credentials": "coursecertificate"}, [])
)
@unpack
def test_get_with_query_params(self, types, expected_data, not_in_keys):
self.client.login(username=self.user.username, password=USER_PASSWORD)
response = self.client.get(f"/verifiable_credentials/api/v1/credentials/?types={types}")
self.assertEqual(response.status_code, 200)

for key, expected_value in expected_data.items():
self.assertEqual(
response.data[key],
get_user_credentials_data(self.user.username, expected_value)
)

for key in not_in_keys:
self.assertNotIn(key, response.data)

class InitIssuanceViewTestCase(SiteMixin, TestCase):
url_path = reverse("verifiable_credentials:api:v1:credentials-init")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


router = routers.DefaultRouter()
router.register(r"program_credentials", views.ProgramCredentialsViewSet, basename="program_credentials")
router.register(r"credentials", views.CredentialsViewSet, basename="credentials")

urlpatterns = [
path(r"credentials/init/", views.InitIssuanceView.as_view(), name="credentials-init"),
Expand Down
Loading

0 comments on commit 4b47b06

Please sign in to comment.