learning-credentials 0.4.1rc6__tar.gz → 0.5.0rc1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/CHANGELOG.rst +10 -1
- {learning_credentials-0.4.1rc6/learning_credentials.egg-info → learning_credentials-0.5.0rc1}/PKG-INFO +12 -2
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/admin.py +30 -3
- learning_credentials-0.5.0rc1/learning_credentials/api/v1/serializers.py +13 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/urls.py +2 -1
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/views.py +61 -1
- learning_credentials-0.5.0rc1/learning_credentials/migrations/0008_validation.py +94 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/models.py +61 -13
- learning_credentials-0.5.0rc1/learning_credentials/templates/learning_credentials/verify.html +83 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/urls.py +2 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1/learning_credentials.egg-info}/PKG-INFO +12 -2
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/SOURCES.txt +3 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/pyproject.toml +2 -6
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/tests/test_generators.py +13 -18
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/tests/test_migrations.py +5 -24
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/tests/test_models.py +211 -30
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/tests/test_processors.py +1 -26
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/tests/test_views.py +24 -106
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/LICENSE.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/MANIFEST.in +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/README.rst +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/api/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/api/urls.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/permissions.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/apps.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/compat.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/conf/locale/config.yaml +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/exceptions.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/generators.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0001_initial.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/processors.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/settings/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/settings/common.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/settings/production.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/tasks.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/base.html +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/dependency_links.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/entry_points.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/requires.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/top_level.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/setup.cfg +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/tests/test_tasks.py +0 -0
|
@@ -16,7 +16,16 @@ Unreleased
|
|
|
16
16
|
|
|
17
17
|
*
|
|
18
18
|
|
|
19
|
-
0.
|
|
19
|
+
0.5.0 - 2026-01-29
|
|
20
|
+
******************
|
|
21
|
+
|
|
22
|
+
Added
|
|
23
|
+
=====
|
|
24
|
+
|
|
25
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
26
|
+
* Option to invalidate issued credentials.
|
|
27
|
+
|
|
28
|
+
0.4.0 - 2026-01-28
|
|
20
29
|
******************
|
|
21
30
|
|
|
22
31
|
Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0rc1
|
|
4
4
|
Summary: A pluggable service for preparing Open edX credentials.
|
|
5
5
|
Author-email: OpenCraft <help@opencraft.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -11,6 +11,7 @@ Keywords: Python,edx,credentials,django
|
|
|
11
11
|
Classifier: Development Status :: 5 - Production/Stable
|
|
12
12
|
Classifier: Framework :: Django
|
|
13
13
|
Classifier: Framework :: Django :: 4.2
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
15
|
Classifier: Intended Audience :: Developers
|
|
15
16
|
Classifier: Natural Language :: English
|
|
16
17
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -176,7 +177,16 @@ Unreleased
|
|
|
176
177
|
|
|
177
178
|
*
|
|
178
179
|
|
|
179
|
-
0.
|
|
180
|
+
0.5.0 - 2026-01-29
|
|
181
|
+
******************
|
|
182
|
+
|
|
183
|
+
Added
|
|
184
|
+
=====
|
|
185
|
+
|
|
186
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
187
|
+
* Option to invalidate issued credentials.
|
|
188
|
+
|
|
189
|
+
0.4.0 - 2026-01-28
|
|
180
190
|
******************
|
|
181
191
|
|
|
182
192
|
Added
|
{learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/admin.py
RENAMED
|
@@ -7,8 +7,9 @@ import inspect
|
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from django import forms
|
|
10
|
-
from django.contrib import admin
|
|
10
|
+
from django.contrib import admin, messages
|
|
11
11
|
from django.core.exceptions import ValidationError
|
|
12
|
+
from django.urls import reverse
|
|
12
13
|
from django.utils.html import format_html
|
|
13
14
|
from django_object_actions import DjangoObjectActions, action
|
|
14
15
|
from django_reverse_admin import ReverseModelAdmin
|
|
@@ -225,7 +226,7 @@ class CredentialConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
|
|
|
225
226
|
|
|
226
227
|
|
|
227
228
|
@admin.register(Credential)
|
|
228
|
-
class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
229
|
+
class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
|
|
229
230
|
list_display = (
|
|
230
231
|
'user_id',
|
|
231
232
|
'user_full_name',
|
|
@@ -237,19 +238,32 @@ class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
|
237
238
|
'modified',
|
|
238
239
|
)
|
|
239
240
|
readonly_fields = (
|
|
241
|
+
'uuid',
|
|
242
|
+
'verify_uuid',
|
|
240
243
|
'user_id',
|
|
241
244
|
'created',
|
|
242
245
|
'modified',
|
|
243
246
|
'user_full_name',
|
|
244
247
|
'learning_context_key',
|
|
248
|
+
'learning_context_name',
|
|
245
249
|
'credential_type',
|
|
246
250
|
'status',
|
|
247
251
|
'url',
|
|
248
252
|
'legacy_id',
|
|
249
253
|
'generation_task_id',
|
|
250
254
|
)
|
|
251
|
-
search_fields = ("learning_context_key", "user_id", "user_full_name")
|
|
255
|
+
search_fields = ("learning_context_key", "user_id", "user_full_name", "uuid")
|
|
252
256
|
list_filter = ("learning_context_key", "credential_type", "status")
|
|
257
|
+
change_actions = ('reissue_credential',)
|
|
258
|
+
|
|
259
|
+
def save_model(self, request: HttpRequest, obj: Credential, _form: forms.ModelForm, _change: bool): # noqa: FBT001
|
|
260
|
+
"""Display validation errors as messages in the admin interface."""
|
|
261
|
+
try:
|
|
262
|
+
obj.save()
|
|
263
|
+
except ValidationError as e:
|
|
264
|
+
self.message_user(request, e.message or "Invalid data", level=messages.ERROR)
|
|
265
|
+
# Optionally, redirect to the change form with the error message
|
|
266
|
+
return
|
|
253
267
|
|
|
254
268
|
def get_form(self, request: HttpRequest, obj: Credential | None = None, **kwargs) -> forms.ModelForm:
|
|
255
269
|
"""Hide the download_url field."""
|
|
@@ -263,3 +277,16 @@ class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
|
263
277
|
if obj.download_url:
|
|
264
278
|
return format_html("<a href='{url}'>{url}</a>", url=obj.download_url)
|
|
265
279
|
return "-"
|
|
280
|
+
|
|
281
|
+
@action(label="Reissue credential", description="Reissue the credential for the user.")
|
|
282
|
+
def reissue_credential(self, request: HttpRequest, obj: Credential):
|
|
283
|
+
"""Reissue the credential for the user."""
|
|
284
|
+
try:
|
|
285
|
+
new_credential = obj.reissue()
|
|
286
|
+
admin_url = reverse('admin:learning_credentials_credential_change', args=[new_credential.pk])
|
|
287
|
+
message = format_html(
|
|
288
|
+
'The credential has been reissued as <a href="{}">{}</a>.', admin_url, new_credential.uuid
|
|
289
|
+
)
|
|
290
|
+
messages.success(request, message)
|
|
291
|
+
except CredentialConfiguration.DoesNotExist:
|
|
292
|
+
messages.error(request, "The configuration does not exist.")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""API serializers for learning credentials."""
|
|
2
|
+
|
|
3
|
+
from rest_framework import serializers
|
|
4
|
+
|
|
5
|
+
from learning_credentials.models import Credential
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CredentialSerializer(serializers.ModelSerializer):
|
|
9
|
+
"""Serializer that returns credential metadata."""
|
|
10
|
+
|
|
11
|
+
class Meta: # noqa: D106
|
|
12
|
+
model = Credential
|
|
13
|
+
fields = ('user_full_name', 'created', 'learning_context_name', 'status', 'invalidation_reason')
|
{learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/urls.py
RENAMED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from django.urls import path
|
|
4
4
|
|
|
5
|
-
from .views import CredentialConfigurationCheckView
|
|
5
|
+
from .views import CredentialConfigurationCheckView, CredentialMetadataView
|
|
6
6
|
|
|
7
7
|
urlpatterns = [
|
|
8
8
|
path(
|
|
@@ -10,4 +10,5 @@ urlpatterns = [
|
|
|
10
10
|
CredentialConfigurationCheckView.as_view(),
|
|
11
11
|
name='credential_configuration_check',
|
|
12
12
|
),
|
|
13
|
+
path('metadata/<uuid:uuid>/', CredentialMetadataView.as_view(), name='credential-metadata'),
|
|
13
14
|
]
|
{learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/views.py
RENAMED
|
@@ -9,9 +9,10 @@ from rest_framework.permissions import IsAuthenticated
|
|
|
9
9
|
from rest_framework.response import Response
|
|
10
10
|
from rest_framework.views import APIView
|
|
11
11
|
|
|
12
|
-
from learning_credentials.models import CredentialConfiguration
|
|
12
|
+
from learning_credentials.models import Credential, CredentialConfiguration
|
|
13
13
|
|
|
14
14
|
from .permissions import CanAccessLearningContext
|
|
15
|
+
from .serializers import CredentialSerializer
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
from rest_framework.request import Request
|
|
@@ -81,3 +82,62 @@ class CredentialConfigurationCheckView(APIView):
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
return Response(response_data, status=status.HTTP_200_OK)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CredentialMetadataView(APIView):
|
|
88
|
+
"""API view to retrieve credential metadata by UUID."""
|
|
89
|
+
|
|
90
|
+
@apidocs.schema(
|
|
91
|
+
parameters=[
|
|
92
|
+
apidocs.string_parameter(
|
|
93
|
+
"uuid",
|
|
94
|
+
ParameterLocation.PATH,
|
|
95
|
+
description="The UUID of the credential to retrieve.",
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
responses={
|
|
99
|
+
200: "Successfully retrieved the credential metadata.",
|
|
100
|
+
404: "Credential not found or not valid.",
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
def get(self, _request: "Request", uuid: str) -> Response:
|
|
104
|
+
"""
|
|
105
|
+
Retrieve credential metadata by its UUID.
|
|
106
|
+
|
|
107
|
+
**Example Request**
|
|
108
|
+
|
|
109
|
+
``GET /api/learning_credentials/v1/metadata/123e4567-e89b-12d3-a456-426614174000/``
|
|
110
|
+
|
|
111
|
+
**Response Values**
|
|
112
|
+
|
|
113
|
+
- **200 OK**: Successfully retrieved the credential metadata.
|
|
114
|
+
- **404 Not Found**: Credential not found or not valid.
|
|
115
|
+
|
|
116
|
+
**Example Response**
|
|
117
|
+
|
|
118
|
+
.. code-block:: json
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
"user_full_name": "John Doe",
|
|
122
|
+
"created": "2023-01-01",
|
|
123
|
+
"learning_context_name": "Demo Course",
|
|
124
|
+
"status": "available",
|
|
125
|
+
"invalidation_reason": ""
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
"user_full_name": "John Doe",
|
|
131
|
+
"created": "2023-01-01",
|
|
132
|
+
"learning_context_name": "Demo Course",
|
|
133
|
+
"status": "invalidated",
|
|
134
|
+
"invalidation_reason": "Reissued due to name change."
|
|
135
|
+
}
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
credential = Credential.objects.get(verify_uuid=uuid)
|
|
139
|
+
except Credential.DoesNotExist:
|
|
140
|
+
return Response({'error': 'Credential not found.'}, status=status.HTTP_404_NOT_FOUND)
|
|
141
|
+
|
|
142
|
+
serializer = CredentialSerializer(credential)
|
|
143
|
+
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Generated by Django 4.2.25 on 2025-10-31 17:43
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def backfill_credential_fields(apps, schema_editor):
|
|
8
|
+
"""Generate verification UUIDs and backfill learning_context_name for all existing credentials."""
|
|
9
|
+
from learning_credentials.compat import get_learning_context_name
|
|
10
|
+
|
|
11
|
+
Credential = apps.get_model("learning_credentials", "Credential")
|
|
12
|
+
for credential in Credential.objects.all():
|
|
13
|
+
credential.verify_uuid = uuid.uuid4()
|
|
14
|
+
credential.learning_context_name = get_learning_context_name(credential.learning_context_key)
|
|
15
|
+
credential.save(update_fields=["verify_uuid", "learning_context_name"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Migration(migrations.Migration):
|
|
19
|
+
dependencies = [
|
|
20
|
+
("learning_credentials", "0007_migrate_to_text_elements_format"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
operations = [
|
|
24
|
+
migrations.AlterUniqueTogether(
|
|
25
|
+
name="credential",
|
|
26
|
+
unique_together=set(),
|
|
27
|
+
),
|
|
28
|
+
migrations.AlterField(
|
|
29
|
+
model_name="credential",
|
|
30
|
+
name="user_full_name",
|
|
31
|
+
field=models.CharField(
|
|
32
|
+
editable=False,
|
|
33
|
+
help_text="User receiving the credential. This field is used for validation purposes.",
|
|
34
|
+
max_length=255,
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
migrations.AddField(
|
|
38
|
+
model_name="credential",
|
|
39
|
+
name="invalidated_at",
|
|
40
|
+
field=models.DateTimeField(
|
|
41
|
+
editable=False,
|
|
42
|
+
help_text="Timestamp when the credential was invalidated",
|
|
43
|
+
null=True,
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
migrations.AddField(
|
|
47
|
+
model_name="credential",
|
|
48
|
+
name="invalidation_reason",
|
|
49
|
+
field=models.CharField(
|
|
50
|
+
blank=True,
|
|
51
|
+
help_text="Reason for invalidating the credential",
|
|
52
|
+
max_length=255,
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
migrations.AddField(
|
|
56
|
+
model_name="credential",
|
|
57
|
+
name="learning_context_name",
|
|
58
|
+
field=models.CharField(
|
|
59
|
+
editable=False,
|
|
60
|
+
help_text="Name of the learning context for which the credential was issued. This field is used for validation purposes.",
|
|
61
|
+
max_length=255,
|
|
62
|
+
null=True,
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
migrations.AddField(
|
|
66
|
+
model_name="credential",
|
|
67
|
+
name="verify_uuid",
|
|
68
|
+
field=models.UUIDField(
|
|
69
|
+
default=uuid.uuid4,
|
|
70
|
+
editable=False,
|
|
71
|
+
help_text="UUID used for verifying the credential",
|
|
72
|
+
null=True,
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
migrations.RunPython(backfill_credential_fields, reverse_code=migrations.RunPython.noop),
|
|
76
|
+
migrations.AlterField(
|
|
77
|
+
model_name="credential",
|
|
78
|
+
name="learning_context_name",
|
|
79
|
+
field=models.CharField(
|
|
80
|
+
editable=False,
|
|
81
|
+
help_text="Name of the learning context for which the credential was issued. This field is used for validation purposes.",
|
|
82
|
+
max_length=255,
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
migrations.AlterField(
|
|
86
|
+
model_name="credential",
|
|
87
|
+
name="verify_uuid",
|
|
88
|
+
field=models.UUIDField(
|
|
89
|
+
default=uuid.uuid4,
|
|
90
|
+
editable=False,
|
|
91
|
+
help_text="UUID used for verifying the credential",
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
]
|
{learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/models.py
RENAMED
|
@@ -4,10 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
-
import uuid
|
|
7
|
+
import uuid as uuid_lib
|
|
8
8
|
from importlib import import_module
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import TYPE_CHECKING
|
|
10
|
+
from typing import TYPE_CHECKING, Self
|
|
11
11
|
|
|
12
12
|
import jsonfield
|
|
13
13
|
from django.conf import settings
|
|
@@ -16,6 +16,7 @@ from django.core.exceptions import ValidationError
|
|
|
16
16
|
from django.db import models
|
|
17
17
|
from django.db.models.signals import post_delete
|
|
18
18
|
from django.dispatch import receiver
|
|
19
|
+
from django.utils import timezone
|
|
19
20
|
from django.utils.translation import gettext_lazy as _
|
|
20
21
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
21
22
|
from edx_ace import Message, Recipient, ace
|
|
@@ -147,9 +148,8 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
147
148
|
self.periodic_task.args = json.dumps([self.id])
|
|
148
149
|
self.periodic_task.save()
|
|
149
150
|
|
|
150
|
-
# Replace the return type with `QuerySet[Self]` after migrating to Python 3.10+.
|
|
151
151
|
@classmethod
|
|
152
|
-
def get_enabled_configurations(cls) -> QuerySet[
|
|
152
|
+
def get_enabled_configurations(cls) -> QuerySet[Self]:
|
|
153
153
|
"""
|
|
154
154
|
Get the list of enabled configurations.
|
|
155
155
|
|
|
@@ -198,7 +198,7 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
198
198
|
custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
|
|
199
199
|
return func(self.learning_context_key, custom_options)
|
|
200
200
|
|
|
201
|
-
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
|
|
201
|
+
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0) -> Credential:
|
|
202
202
|
"""
|
|
203
203
|
Celery task for processing a single user's credential.
|
|
204
204
|
|
|
@@ -209,19 +209,24 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
209
209
|
Args:
|
|
210
210
|
user_id: The ID of the user to process the credential for.
|
|
211
211
|
celery_task_id (optional): The ID of the Celery task that is running this function.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The generated Credential object.
|
|
212
215
|
"""
|
|
213
216
|
user = get_user_model().objects.get(id=user_id)
|
|
214
217
|
# Use the name from the profile if it is not empty. Otherwise, use the first and last name.
|
|
215
218
|
# We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
|
|
216
219
|
user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
|
|
220
|
+
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
217
221
|
custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
|
|
218
222
|
|
|
219
|
-
credential, _ = Credential.objects.update_or_create(
|
|
223
|
+
credential, _ = Credential.objects.exclude(status=Credential.Status.INVALIDATED).update_or_create(
|
|
220
224
|
user_id=user_id,
|
|
221
225
|
learning_context_key=self.learning_context_key,
|
|
222
226
|
credential_type=self.credential_type.name,
|
|
223
227
|
defaults={
|
|
224
228
|
'user_full_name': user_full_name,
|
|
229
|
+
'learning_context_name': learning_context_name,
|
|
225
230
|
'status': Credential.Status.GENERATING,
|
|
226
231
|
'generation_task_id': celery_task_id,
|
|
227
232
|
},
|
|
@@ -247,6 +252,8 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
247
252
|
if user.is_active and user.has_usable_password():
|
|
248
253
|
credential.send_email()
|
|
249
254
|
|
|
255
|
+
return credential
|
|
256
|
+
|
|
250
257
|
|
|
251
258
|
# noinspection PyUnusedLocal
|
|
252
259
|
@receiver(post_delete, sender=CredentialConfiguration)
|
|
@@ -281,16 +288,33 @@ class Credential(TimeStampedModel):
|
|
|
281
288
|
|
|
282
289
|
uuid = models.UUIDField(
|
|
283
290
|
primary_key=True,
|
|
284
|
-
default=
|
|
291
|
+
default=uuid_lib.uuid4,
|
|
285
292
|
editable=False,
|
|
286
293
|
help_text=_('Auto-generated UUID of the credential'),
|
|
287
294
|
)
|
|
295
|
+
verify_uuid = models.UUIDField(
|
|
296
|
+
default=uuid_lib.uuid4,
|
|
297
|
+
editable=False,
|
|
298
|
+
help_text=_('UUID used for verifying the credential'),
|
|
299
|
+
)
|
|
288
300
|
user_id = models.IntegerField(help_text=_('ID of the user receiving the credential'))
|
|
289
|
-
user_full_name = models.CharField(
|
|
301
|
+
user_full_name = models.CharField(
|
|
302
|
+
max_length=255,
|
|
303
|
+
editable=False,
|
|
304
|
+
help_text=_('User receiving the credential. This field is used for validation purposes.'),
|
|
305
|
+
)
|
|
290
306
|
learning_context_key = LearningContextKeyField(
|
|
291
307
|
max_length=255,
|
|
292
308
|
help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
|
|
293
309
|
)
|
|
310
|
+
learning_context_name = models.CharField(
|
|
311
|
+
max_length=255,
|
|
312
|
+
editable=False,
|
|
313
|
+
help_text=_(
|
|
314
|
+
'Name of the learning context for which the credential was issued. '
|
|
315
|
+
'This field is used for validation purposes.'
|
|
316
|
+
),
|
|
317
|
+
)
|
|
294
318
|
credential_type = models.CharField(max_length=255, help_text=_('Type of the credential'))
|
|
295
319
|
status = models.CharField(
|
|
296
320
|
max_length=32,
|
|
@@ -301,16 +325,26 @@ class Credential(TimeStampedModel):
|
|
|
301
325
|
download_url = models.URLField(blank=True, help_text=_('URL of the generated credential PDF (e.g., to S3)'))
|
|
302
326
|
legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the credential imported from another system'))
|
|
303
327
|
generation_task_id = models.CharField(max_length=255, help_text=_('Task ID from the Celery queue'))
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
328
|
+
invalidated_at = models.DateTimeField(
|
|
329
|
+
null=True, editable=False, help_text=_('Timestamp when the credential was invalidated')
|
|
330
|
+
)
|
|
331
|
+
invalidation_reason = models.CharField(
|
|
332
|
+
max_length=255, blank=True, help_text=_('Reason for invalidating the credential')
|
|
333
|
+
)
|
|
307
334
|
|
|
308
335
|
def __str__(self): # noqa: D105
|
|
309
336
|
return f"{self.credential_type} for {self.user_full_name} in {self.learning_context_key}"
|
|
310
337
|
|
|
338
|
+
def save(self, *args, **kwargs):
|
|
339
|
+
"""If the invalidation reason is set, update the status and timestamp."""
|
|
340
|
+
if self.invalidation_reason and self.status != Credential.Status.INVALIDATED:
|
|
341
|
+
self.status = Credential.Status.INVALIDATED
|
|
342
|
+
if self.status == Credential.Status.INVALIDATED and not self.invalidated_at:
|
|
343
|
+
self.invalidated_at = timezone.now()
|
|
344
|
+
super().save(*args, **kwargs)
|
|
345
|
+
|
|
311
346
|
def send_email(self):
|
|
312
347
|
"""Send a credential link to the student."""
|
|
313
|
-
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
314
348
|
user = get_user_model().objects.get(id=self.user_id)
|
|
315
349
|
msg = Message(
|
|
316
350
|
name="certificate_generated",
|
|
@@ -319,12 +353,26 @@ class Credential(TimeStampedModel):
|
|
|
319
353
|
language='en',
|
|
320
354
|
context={
|
|
321
355
|
'certificate_link': self.download_url,
|
|
322
|
-
'course_name': learning_context_name,
|
|
356
|
+
'course_name': self.learning_context_name,
|
|
323
357
|
'platform_name': settings.PLATFORM_NAME,
|
|
324
358
|
},
|
|
325
359
|
)
|
|
326
360
|
ace.send(msg)
|
|
327
361
|
|
|
362
|
+
def reissue(self) -> Self:
|
|
363
|
+
"""Invalidate the current credential and create a new one."""
|
|
364
|
+
config = CredentialConfiguration.objects.get(
|
|
365
|
+
learning_context_key=self.learning_context_key,
|
|
366
|
+
credential_type__name=self.credential_type,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if self.invalidation_reason:
|
|
370
|
+
self.invalidation_reason += '\n'
|
|
371
|
+
self.invalidation_reason += 'Reissued'
|
|
372
|
+
self.save()
|
|
373
|
+
|
|
374
|
+
return config.generate_credential_for_user(self.user_id)
|
|
375
|
+
|
|
328
376
|
|
|
329
377
|
class CredentialAsset(TimeStampedModel):
|
|
330
378
|
"""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{% extends "main_django.html" %}
|
|
2
|
+
{% load i18n %}
|
|
3
|
+
{% load static %}
|
|
4
|
+
|
|
5
|
+
{% block bodyextra %}
|
|
6
|
+
<div id="content" class="credential-verification-container content-wrapper main-container">
|
|
7
|
+
<section class="container">
|
|
8
|
+
<h1>{% trans "Credential verification page" %}</h1>
|
|
9
|
+
<form id="credentialVerificationForm" method="post">
|
|
10
|
+
{% csrf_token %}
|
|
11
|
+
<div class="form-group">
|
|
12
|
+
<label for="credentialID">{% trans 'Credential ID' %}</label>
|
|
13
|
+
<input type="text" id="credentialID" class="form-control" name="credentialID" required>
|
|
14
|
+
<div id="formError" class="text-danger mt-2" style="display: none;"></div>
|
|
15
|
+
</div>
|
|
16
|
+
<button type="submit" class="btn btn-primary">
|
|
17
|
+
{% trans 'Verify' %}
|
|
18
|
+
</button>
|
|
19
|
+
</form>
|
|
20
|
+
|
|
21
|
+
<div id="verificationResults" class="p-0 border-0 mt-5"></div>
|
|
22
|
+
</section>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<script>
|
|
26
|
+
document.getElementById('credentialVerificationForm').onsubmit = async function(event) {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
let formError = document.getElementById('formError');
|
|
29
|
+
let verificationResults = document.getElementById('verificationResults');
|
|
30
|
+
|
|
31
|
+
let credentialId = document.getElementById('credentialID').value;
|
|
32
|
+
var url = '/api/learning_credentials/v1/metadata/' + encodeURIComponent(credentialId);
|
|
33
|
+
|
|
34
|
+
// Hide previous error messages.
|
|
35
|
+
formError.style.display = 'none';
|
|
36
|
+
|
|
37
|
+
await fetch(url)
|
|
38
|
+
.then(response => response.json())
|
|
39
|
+
.then(data => {
|
|
40
|
+
const table = document.createElement('table');
|
|
41
|
+
table.className = 'table table-striped table-bordered';
|
|
42
|
+
const tbody = document.createElement('tbody');
|
|
43
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
44
|
+
if (!!value) {
|
|
45
|
+
const row = tbody.insertRow();
|
|
46
|
+
const cellKey = row.insertCell();
|
|
47
|
+
const cellValue = row.insertCell();
|
|
48
|
+
cellKey.textContent = key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
49
|
+
cellValue.textContent = value;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
table.appendChild(tbody);
|
|
53
|
+
verificationResults.textContent = '';
|
|
54
|
+
verificationResults.appendChild(table);
|
|
55
|
+
})
|
|
56
|
+
.catch((error) => {
|
|
57
|
+
console.error('Error:', error);
|
|
58
|
+
verificationResults.textContent = '';
|
|
59
|
+
formError.innerHTML = "An error occurred during verification. Please check the Credential ID and try again.";
|
|
60
|
+
formError.style.display = 'block';
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
$(document).ready(function(){
|
|
66
|
+
// Fill the credential ID field with URL 'credential' query param.
|
|
67
|
+
let urlParams = new URLSearchParams(window.location.search);
|
|
68
|
+
const uuid = urlParams.get('credential');
|
|
69
|
+
|
|
70
|
+
if (uuid) {
|
|
71
|
+
$('#credentialID').val(uuid);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If the field is filled, submit the form automatically.
|
|
75
|
+
if ($('#credentialID').val()) {
|
|
76
|
+
$('#credentialVerificationForm').submit();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Focus the field for user convenience.
|
|
80
|
+
$('#credentialID').focus();
|
|
81
|
+
});
|
|
82
|
+
</script>
|
|
83
|
+
{% endblock %}
|
{learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc1}/learning_credentials/urls.py
RENAMED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""URLs for learning_credentials."""
|
|
2
2
|
|
|
3
3
|
from django.urls import include, path
|
|
4
|
+
from django.views.generic import TemplateView
|
|
4
5
|
|
|
5
6
|
from .api import urls as api_urls
|
|
6
7
|
|
|
7
8
|
urlpatterns = [
|
|
8
9
|
path('api/learning_credentials/', include(api_urls)),
|
|
10
|
+
path('learning_credentials/verify/', TemplateView.as_view(template_name="learning_credentials/verify.html")),
|
|
9
11
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0rc1
|
|
4
4
|
Summary: A pluggable service for preparing Open edX credentials.
|
|
5
5
|
Author-email: OpenCraft <help@opencraft.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -11,6 +11,7 @@ Keywords: Python,edx,credentials,django
|
|
|
11
11
|
Classifier: Development Status :: 5 - Production/Stable
|
|
12
12
|
Classifier: Framework :: Django
|
|
13
13
|
Classifier: Framework :: Django :: 4.2
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
15
|
Classifier: Intended Audience :: Developers
|
|
15
16
|
Classifier: Natural Language :: English
|
|
16
17
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -176,7 +177,16 @@ Unreleased
|
|
|
176
177
|
|
|
177
178
|
*
|
|
178
179
|
|
|
179
|
-
0.
|
|
180
|
+
0.5.0 - 2026-01-29
|
|
181
|
+
******************
|
|
182
|
+
|
|
183
|
+
Added
|
|
184
|
+
=====
|
|
185
|
+
|
|
186
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
187
|
+
* Option to invalidate issued credentials.
|
|
188
|
+
|
|
189
|
+
0.4.0 - 2026-01-28
|
|
180
190
|
******************
|
|
181
191
|
|
|
182
192
|
Added
|
|
@@ -23,6 +23,7 @@ learning_credentials/api/__init__.py
|
|
|
23
23
|
learning_credentials/api/urls.py
|
|
24
24
|
learning_credentials/api/v1/__init__.py
|
|
25
25
|
learning_credentials/api/v1/permissions.py
|
|
26
|
+
learning_credentials/api/v1/serializers.py
|
|
26
27
|
learning_credentials/api/v1/urls.py
|
|
27
28
|
learning_credentials/api/v1/views.py
|
|
28
29
|
learning_credentials/conf/locale/config.yaml
|
|
@@ -33,11 +34,13 @@ learning_credentials/migrations/0004_replace_course_keys_with_learning_context_k
|
|
|
33
34
|
learning_credentials/migrations/0005_rename_processors_and_generators.py
|
|
34
35
|
learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py
|
|
35
36
|
learning_credentials/migrations/0007_migrate_to_text_elements_format.py
|
|
37
|
+
learning_credentials/migrations/0008_validation.py
|
|
36
38
|
learning_credentials/migrations/__init__.py
|
|
37
39
|
learning_credentials/settings/__init__.py
|
|
38
40
|
learning_credentials/settings/common.py
|
|
39
41
|
learning_credentials/settings/production.py
|
|
40
42
|
learning_credentials/templates/learning_credentials/base.html
|
|
43
|
+
learning_credentials/templates/learning_credentials/verify.html
|
|
41
44
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html
|
|
42
45
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt
|
|
43
46
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt
|