Skip to content

Commit

Permalink
feat: [FC-0006] add Verifiable Credentials feature
Browse files Browse the repository at this point in the history
  • Loading branch information
wowkalucky committed Apr 19, 2023
1 parent 761a167 commit 9cb0612
Show file tree
Hide file tree
Showing 88 changed files with 3,841 additions and 97 deletions.
2 changes: 1 addition & 1 deletion credentials/apps/credentials/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def get_user_credentials_by_content_type(request_username, course_cert_content_t
)
)

return user_credentials
return user_credentials.distinct()


def get_credential_dates(user_credentials, many):
Expand Down
Empty file.
95 changes: 95 additions & 0 deletions credentials/apps/verifiable_credentials/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from django import forms
from django.contrib import admin
from django.utils.translation import gettext_lazy as _

from .issuance.models import IssuanceConfiguration, IssuanceLine
from .issuance.utils import get_issuers
from .toggles import is_verifiable_credentials_enabled


class IssuersListFilter(admin.SimpleListFilter):
title = _("Issuer ID")

parameter_name = "issuer_id"

def lookups(self, request, model_admin):
return [(issuer["issuer_id"], issuer["issuer_name"] or issuer["id"]) for issuer in get_issuers()]

def queryset(self, request, queryset):
if not self.value():
return queryset

return queryset.filter(issuer_id=self.value())


class IssuanceLineAdmin(admin.ModelAdmin):
"""
Issuance line admin setup.
"""

list_display = (
"uuid",
"user_credential",
"get_issuer_id",
"storage_id",
"processed",
"status_index",
"status",
)
readonly_fields = [
"uuid",
"status_index",
"get_issuer_id",
]
list_filter = [
IssuersListFilter,
"storage_id",
"status",
"processed",
]
search_fields = ("uuid",)

@admin.display(description="issuer id")
def get_issuer_id(self, issiance_line):
limit = 30
if len(issiance_line.issuer_id) > limit:
return f"{issiance_line.issuer_id[:limit]}..."
return issiance_line.issuer_id


class IssuanceConfigurationForm(forms.ModelForm):
def clean_enabled(self):
"""
Do not allow to disable the last issuer.
"""
# don't validate if new:
if not self.instance.pk:
return self.cleaned_data["enabled"]

enabled_count = self.instance.__class__.objects.filter(enabled=True).count()
if enabled_count < 2 and self.cleaned_data["enabled"] is False:
raise forms.ValidationError(_("At least one Issuer must be always enabled!"))

return self.cleaned_data["enabled"]


class IssuanceConfigurationAdmin(admin.ModelAdmin):
"""
Issuance configuration admin setup.
"""

form = IssuanceConfigurationForm

list_display = [
"issuer_id",
"issuer_name",
"enabled",
]

def has_delete_permission(self, request, obj=None):
return False


if is_verifiable_credentials_enabled():
admin.site.register(IssuanceLine, IssuanceLineAdmin)
admin.site.register(IssuanceConfiguration, IssuanceConfigurationAdmin)
16 changes: 16 additions & 0 deletions credentials/apps/verifiable_credentials/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.apps import AppConfig

from .toggles import is_verifiable_credentials_enabled


class VerifiableCredentialsConfig(AppConfig):
name = "credentials.apps.verifiable_credentials"
verbose_name = "Verifiable Credentials"

def ready(self):
"""
Performs initial registrations for checks, signals, etc.
"""
if is_verifiable_credentials_enabled():
from . import signals # pylint: disable=unused-import,import-outside-toplevel
from .checks import vc_settings_checks # pylint: disable=unused-import,import-outside-toplevel
59 changes: 59 additions & 0 deletions credentials/apps/verifiable_credentials/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from django.core.checks import Error, Tags, register

from .settings import vc_settings
from .toggles import ENABLE_VERIFIABLE_CREDENTIALS


@register(Tags.compatibility)
def vc_settings_checks(*args, **kwargs):
"""
Checks the consistency of the verifiable_credentials settings.
Raises compatibility Errors upon:
- No default data models defined
- No default storages defined
- DEFAULT_ISSUER[ID] is not set
- DEFAULT_ISSUER[KEY] is not set
Returns:
List of any Errors.
"""
errors = []

if not vc_settings.DEFAULT_DATA_MODELS:
errors.append(
Error(
"No default data models defined.",
hint="Add at least one data model to the DEFAULT_DATA_MODELS setting.",
id="verifiable_credentials.E001",
)
)

if not vc_settings.DEFAULT_STORAGES:
errors.append(
Error(
"No default storages defined.",
hint="Add at least one storage to the DEFAULT_STORAGES setting.",
id="verifiable_credentials.E003",
)
)

if not vc_settings.DEFAULT_ISSUER.get("ID"):
errors.append(
Error(
f"DEFAULT_ISSUER[ID] is mandatory when {ENABLE_VERIFIABLE_CREDENTIALS.name} is True.",
hint=" Set DEFAULT_ISSUER[ID] to a valid DID string.",
id="verifiable_credentials.E004",
)
)

if not vc_settings.DEFAULT_ISSUER.get("KEY"):
errors.append(
Error(
f"DEFAULT_ISSUER[KEY] is mandatory when {ENABLE_VERIFIABLE_CREDENTIALS.name} is True.",
hint="Set DEFAULT_ISSUER[KEY] to a valid key string.",
id="verifiable_credentials.E005",
)
)

return errors
103 changes: 103 additions & 0 deletions credentials/apps/verifiable_credentials/composition/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
Composition hierarchy.
CredentialDataModel
|
|_ VerifiableCredentialsDataModel + StatusList2021EntryMixin
|_ OpenBadgesDataModel + StatusList2021EntryMixin
|_ StatusListDataModel
"""
import inspect
from collections import OrderedDict

from rest_framework import serializers

from .schemas import IssuerSchema


class CredentialDataModel(serializers.Serializer): # pylint: disable=abstract-method
"""
Basic credential construction machinery.
"""

VERSION = None
ID = None
NAME = None

context = serializers.SerializerMethodField(
method_name="collect_context", help_text="https://www.w3.org/TR/vc-data-model/#contexts"
)
type = serializers.SerializerMethodField(help_text="https://www.w3.org/TR/vc-data-model/#types")
issuer = IssuerSchema(source="*", help_text="https://www.w3.org/TR/vc-data-model/#issuer")
issued = serializers.DateTimeField(source="modified", help_text="https://www.w3.org/2018/credentials/#issued")
issuanceDate = serializers.DateTimeField(
source="modified",
help_text="Deprecated (requred by the didkit for now) https://www.w3.org/2018/credentials/#issuanceDate",
)
validFrom = serializers.DateTimeField(source="modified", help_text="https://www.w3.org/2018/credentials/#validFrom")
validUntil = serializers.DateTimeField(
source="expiration_date", help_text="https://www.w3.org/2018/credentials/#validUntil"
)

@classmethod
def get_context(cls):
"""
Provide root context for all verifiable credentials.
"""
return [
"https://www.w3.org/2018/credentials/v1",
]

@classmethod
def get_types(cls):
"""
Provide root types for all verifiable credentials.
"""
return [
"VerifiableCredential",
]

def collect_context(self, __):
"""
Collect contexts.
- include default root context
- include data model context
"""
return self._collect_hierarchically(class_method="get_context")

def get_type(self, issuance_line):
"""
Collect corresponding types.
- include default root type(s)
- include data model type(s)
- include credential-specific type(s)
"""
data_model_types = self._collect_hierarchically(class_method="get_types")
credential_types = self.resolve_credential_type(issuance_line)
return data_model_types + credential_types

def resolve_credential_type(self, issuance_line): # pylint: disable=unused-argument
"""
Map Open edX credential type to data model types.
Decides: which types should be included based on the source Open edX credential type.
See:
https://w3c.github.io/vc-imp-guide/#creating-new-credential-types
https://schema.org/EducationalOccupationalCredential
"""
return []

def _collect_hierarchically(self, class_method):
"""
Call given method through model MRO and collect returned values.
"""
values = OrderedDict()
reversed_mro_classes = reversed(inspect.getmro(type(self)))
for base_class in reversed_mro_classes:
if hasattr(base_class, class_method):
values_list = getattr(base_class, class_method)()
for value in values_list:
values[value] = base_class.__name__
return list(values.keys())
97 changes: 97 additions & 0 deletions credentials/apps/verifiable_credentials/composition/open_badges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""
Open Badges 3.0.* data model.
See specification: https://1edtech.github.io/openbadges-specification/ob_v3p0.html
"""

from django.utils.translation import gettext as _
from rest_framework import serializers

from ..composition.status_list import CredentialWithStatusList2021DataModel


class AchievementSchema(serializers.Serializer): # pylint: disable=abstract-method
"""
Open Badges achievement.
https://1edtech.github.io/openbadges-specification/ob_v3p0.html#achievement-0
"""

TYPE = "Achievement"

id = serializers.CharField(source="user_credential.uuid")
type = serializers.CharField(default=TYPE)
name = serializers.CharField(source="user_credential.credential.title")
description = serializers.SerializerMethodField(source="user_credential.credential.program.title")

class Meta:
read_only_fields = "__all__"

def get_description(self, issuance_line):
return (
issuance_line.user_credential.attributes.filter(name="description").values_list("value", flat=True).first()
)


class CredentialSubjectSchema(serializers.Serializer): # pylint: disable=abstract-method
"""
Open Badges credential subject.
"""

TYPE = "AchievementSubject"

id = serializers.CharField(source="subject_id")
type = serializers.CharField(default=TYPE)
achievement = AchievementSchema(source="*")

class Meta:
read_only_fields = "__all__"


class OpenBadgesDataModel(CredentialWithStatusList2021DataModel): # pylint: disable=abstract-method
"""
Open Badges data model.
"""

VERSION = "3.0"
ID = "obv3"
NAME = _("Open Badges Specification v3.0")

id = serializers.UUIDField(
source="uuid", format="urn", help_text="https://www.w3.org/TR/vc-data-model/#identifiers"
)
name = serializers.CharField(source="credential_name")
credentialSubject = CredentialSubjectSchema(
source="*", help_text="https://1edtech.github.io/openbadges-specification/ob_v3p0.html#credentialsubject-0"
)

class Meta:
read_only_fields = "__all__"

@classmethod
def get_context(cls):
return [
"https://purl.imsglobal.org/spec/ob/v3p0/context.json",
]

@classmethod
def get_types(cls):
return [
"OpenBadgeCredential",
]


class OpenBadges301DataModel(OpenBadgesDataModel): # pylint: disable=abstract-method
"""
Open Badges data model.
"""

VERSION = "3.0.1"
ID = "obv301"
NAME = _("Open Badges Specification v3.0.1")

@classmethod
def get_context(cls):
return [
"https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.1.json",
"https://purl.imsglobal.org/spec/ob/v3p0/extensions.json",
]
Loading

0 comments on commit 9cb0612

Please sign in to comment.