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 0f2911b
Show file tree
Hide file tree
Showing 8 changed files with 218 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)

Check warning on line 55 in credentials/apps/verifiable_credentials/composition/schemas.py

View check run for this annotation

Codecov / codecov/patch

credentials/apps/verifiable_credentials/composition/schemas.py#L55

Added line #L55 was not covered by tests

if instance.user_credential.credential_content_type.model == CredentialsType.PROGRAM:
representation["program"] = EducationalOccupationalProgramSchema(instance).data

Check warning on line 58 in credentials/apps/verifiable_credentials/composition/schemas.py

View check run for this annotation

Codecov / codecov/patch

credentials/apps/verifiable_credentials/composition/schemas.py#L58

Added line #L58 was not covered by tests
elif instance.user_credential.credential_content_type.model == CredentialsType.COURSE:
representation["course"] = EducationalOccupationalCourseSchema(instance).data

Check warning on line 60 in credentials/apps/verifiable_credentials/composition/schemas.py

View check run for this annotation

Codecov / codecov/patch

credentials/apps/verifiable_credentials/composition/schemas.py#L60

Added line #L60 was not covered by tests

return representation

Check warning on line 62 in credentials/apps/verifiable_credentials/composition/schemas.py

View check run for this annotation

Codecov / codecov/patch

credentials/apps/verifiable_credentials/composition/schemas.py#L62

Added line #L62 was not covered by tests

class Meta:
read_only_fields = "__all__"
Expand Down
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()

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

View check run for this annotation

Codecov / codecov/patch

credentials/apps/verifiable_credentials/issuance/models.py#L200-L201

Added lines #L200 - L201 were not covered by tests

@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
28 changes: 22 additions & 6 deletions credentials/apps/verifiable_credentials/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from credentials.apps.verifiable_credentials.storages.utils import get_available_storages, get_storage
from credentials.apps.verifiable_credentials.utils import (
generate_base64_qr_code,
get_user_program_credentials_data,
get_user_credentials_data,
is_valid_uuid,
)

Expand All @@ -35,25 +35,41 @@
User = get_user_model()


class ProgramCredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
class CredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
authentication_classes = (
JwtAuthentication,
SessionAuthentication,
)

permission_classes = (IsAuthenticated,)

CREDENTIAL_TYPES_MAP = {
"programcertificate": "program_credentials",
"coursecertificate": "course_credentials",
}

def list(self, request, *args, **kwargs):
"""
List data for all the user's issued program credentials.
GET: /verifiable_credentials/api/v1/program_credentials/
List data for all the user's issued credentials.
GET: /verifiable_credentials/api/v1/credentials?types=coursecertificate,programcertificate
Arguments:
request: A request to control data returned in endpoint response
Returns:
response(dict): Information about the user's program credentials
"""
program_credentials = get_user_program_credentials_data(request.user.username)
return Response({"program_credentials": program_credentials})
types = self.request.query_params.get('types')
response = {}

if types:
types = types.split(',')
else:
types = self.CREDENTIAL_TYPES_MAP.keys()

for type in types:
if type in self.CREDENTIAL_TYPES_MAP:
response[self.CREDENTIAL_TYPES_MAP[type]] = get_user_credentials_data(request.user.username, type)

return Response(response)


class InitIssuanceView(APIView):
Expand Down
Loading

0 comments on commit 0f2911b

Please sign in to comment.