Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ omit =
manage.py
test_settings.py
*/migrations/*
*admin.py
*/static/*
*/templates/*
*/tests/*
Expand Down
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,3 @@ requirements/private.txt
# IDEs
.vscode/
.idea/

# Local credentials directory
external_certificates/
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ Unreleased

*

0.5.0 - 2026-01-29
******************

Added
=====

* Frontend form and backend API endpoint for verifying credentials.
Comment thread
Agrendalath marked this conversation as resolved.
Outdated
* Option to invalidate issued credentials.

0.4.0 - 2026-01-28
******************

Expand Down
69 changes: 59 additions & 10 deletions learning_credentials/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
import inspect
from typing import TYPE_CHECKING

import django
from django import forms
from django.contrib import admin
from django.contrib import admin, messages
from django.core.exceptions import ValidationError
from django.db.models import URLField
from django.urls import reverse
from django.utils.html import format_html
from django_object_actions import DjangoObjectActions, action
from django_reverse_admin import ReverseModelAdmin
Expand Down Expand Up @@ -225,31 +228,49 @@ def generate_credentials(self, _request: HttpRequest, obj: CredentialConfigurati


@admin.register(Credential)
class CredentialAdmin(admin.ModelAdmin): # noqa: D101
class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
list_display = (
'user_id',
'user',
'user_full_name',
'learning_context_key',
'credential_type',
'configuration',
'status',
'url',
'created',
'modified',
)
readonly_fields = (
'user_id',
'uuid',
'verify_uuid',
'user',
'configuration',
'created',
'modified',
'user_full_name',
'learning_context_key',
'credential_type',
'learning_context_name',
'status',
'url',
'legacy_id',
'generation_task_id',
)
search_fields = ("learning_context_key", "user_id", "user_full_name")
list_filter = ("learning_context_key", "credential_type", "status")
search_fields = (
"configuration__learning_context_key",
"user_full_name",
"user__username",
"user__email",
"uuid",
"verify_uuid",
)
list_filter = ("configuration__learning_context_key", "configuration__credential_type", "status")
change_actions = ('reissue_credential',)

def save_model(self, request: HttpRequest, obj: Credential, _form: forms.ModelForm, _change: bool): # noqa: FBT001
"""Display validation errors as messages in the admin interface."""
try:
obj.save()
except ValidationError as e:
self.message_user(request, e.message or "Invalid data", level=messages.ERROR)
# Optionally, redirect to the change form with the error message
return

def get_form(self, request: HttpRequest, obj: Credential | None = None, **kwargs) -> forms.ModelForm:
"""Hide the download_url field."""
Expand All @@ -263,3 +284,31 @@ def url(self, obj: Credential) -> str:
if obj.download_url:
return format_html("<a href='{url}'>{url}</a>", url=obj.download_url)
return "-"

@action(label="Reissue credential", description="Reissue the credential for the user.")
def reissue_credential(self, request: HttpRequest, obj: Credential):
"""Reissue the credential for the user."""
new_credential = obj.reissue()
admin_url = reverse('admin:learning_credentials_credential_change', args=[new_credential.pk])
message = format_html(
'The credential has been reissued as <a href="{}">{}</a>.', admin_url, new_credential.uuid
)
messages.success(request, message)

def has_add_permission(self, _request: HttpRequest) -> bool:
"""Hide the "Add" button in the admin interface."""
return False

def has_delete_permission(self, _request: HttpRequest, _obj: Credential | None = None) -> bool:
"""Hide the "Delete" button in the admin interface."""
return False

def formfield_for_dbfield(self, db_field, request, **kwargs): # noqa: ANN001, ANN201
"""
Assume HTTPS for scheme-less domains pasted into URLFields.

This method can be removed when support for Django versions below 5.0 is dropped.
"""
if django.VERSION[0] > 4 and isinstance(db_field, URLField): # pragma: no cover
kwargs["assume_scheme"] = "https"
return super().formfield_for_dbfield(db_field, request, **kwargs)
13 changes: 13 additions & 0 deletions learning_credentials/api/v1/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""API serializers for learning credentials."""

from rest_framework import serializers

from learning_credentials.models import Credential


class CredentialSerializer(serializers.ModelSerializer):
"""Serializer that returns credential metadata."""

class Meta: # noqa: D106
model = Credential
fields = ('user_full_name', 'created', 'learning_context_name', 'status', 'invalidation_reason')
3 changes: 2 additions & 1 deletion learning_credentials/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

from django.urls import path

from .views import CredentialConfigurationCheckView
from .views import CredentialConfigurationCheckView, CredentialMetadataView

urlpatterns = [
path(
'configured/<str:learning_context_key>/',
CredentialConfigurationCheckView.as_view(),
name='credential_configuration_check',
),
path('metadata/<uuid:uuid>/', CredentialMetadataView.as_view(), name='credential-metadata'),
]
62 changes: 61 additions & 1 deletion learning_credentials/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
from rest_framework.response import Response
from rest_framework.views import APIView

from learning_credentials.models import CredentialConfiguration
from learning_credentials.models import Credential, CredentialConfiguration

from .permissions import CanAccessLearningContext
from .serializers import CredentialSerializer

if TYPE_CHECKING:
from rest_framework.request import Request
Expand Down Expand Up @@ -81,3 +82,62 @@ def get(self, _request: "Request", learning_context_key: str) -> Response:
}

return Response(response_data, status=status.HTTP_200_OK)


class CredentialMetadataView(APIView):
"""API view to retrieve credential metadata by UUID."""

@apidocs.schema(
parameters=[
apidocs.string_parameter(
"uuid",
ParameterLocation.PATH,
description="The UUID of the credential to retrieve.",
),
],
responses={
200: "Successfully retrieved the credential metadata.",
404: "Credential not found or not valid.",
},
)
def get(self, _request: "Request", uuid: str) -> Response:
"""
Retrieve credential metadata by its UUID.

**Example Request**

``GET /api/learning_credentials/v1/metadata/123e4567-e89b-12d3-a456-426614174000/``

**Response Values**

- **200 OK**: Successfully retrieved the credential metadata.
- **404 Not Found**: Credential not found or not valid.

**Example Response**

.. code-block:: json

{
"user_full_name": "John Doe",
"created": "2023-01-01",
"learning_context_name": "Demo Course",
"status": "available",
"invalidation_reason": ""
}


{
"user_full_name": "John Doe",
"created": "2023-01-01",
"learning_context_name": "Demo Course",
"status": "invalidated",
"invalidation_reason": "Reissued due to name change."
}
"""
try:
credential = Credential.objects.get(verify_uuid=uuid)
except Credential.DoesNotExist:
return Response({'error': 'Credential not found.'}, status=status.HTTP_404_NOT_FOUND)

serializer = CredentialSerializer(credential)
return Response(serializer.data, status=status.HTTP_200_OK)
20 changes: 13 additions & 7 deletions learning_credentials/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@
from __future__ import annotations

from contextlib import contextmanager
from datetime import datetime
from typing import TYPE_CHECKING

import pytz
from celery import Celery
from django.conf import settings
from learning_paths.models import LearningPath

if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from datetime import datetime

from django.contrib.auth.models import User
from learning_paths.keys import LearningPathKey
from opaque_keys.edx.keys import CourseKey, LearningContextKey
Expand All @@ -33,7 +34,7 @@ def get_celery_app() -> Celery:
# noinspection PyUnresolvedReferences,PyPackageRequirements
from lms import CELERY_APP

return CELERY_APP # pragma: no cover
return CELERY_APP


def get_default_storage_url() -> str:
Expand Down Expand Up @@ -116,10 +117,15 @@ def get_course_grade(user: User, course_id: CourseKey): # noqa: ANN201
return CourseGradeFactory().read(user, course_key=course_id)


def get_localized_credential_date() -> str:
"""Get the localized date from Open edX."""
def get_localized_credential_date(date: datetime) -> str:
"""
Get the localized date from Open edX.

:param date: The datetime to format.
:returns: The formatted date string.
"""
# noinspection PyUnresolvedReferences,PyPackageRequirements
from common.djangoapps.util.date_utils import strftime_localized

date = datetime.now(pytz.timezone(settings.TIME_ZONE))
return strftime_localized(date, settings.CERTIFICATE_DATE_FORMAT)
localized_date = date.astimezone(pytz.timezone(settings.TIME_ZONE))
return strftime_localized(localized_date, settings.CERTIFICATE_DATE_FORMAT)
Loading
Loading