learning-credentials 0.4.1rc5__py3-none-any.whl → 0.5.0rc1__py3-none-any.whl
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/admin.py +30 -3
- learning_credentials/api/v1/serializers.py +13 -0
- learning_credentials/api/v1/urls.py +2 -1
- learning_credentials/api/v1/views.py +61 -1
- learning_credentials/migrations/0008_validation.py +94 -0
- learning_credentials/models.py +82 -15
- learning_credentials/templates/learning_credentials/verify.html +83 -0
- learning_credentials/urls.py +2 -0
- {learning_credentials-0.4.1rc5.dist-info → learning_credentials-0.5.0rc1.dist-info}/METADATA +12 -2
- {learning_credentials-0.4.1rc5.dist-info → learning_credentials-0.5.0rc1.dist-info}/RECORD +14 -11
- {learning_credentials-0.4.1rc5.dist-info → learning_credentials-0.5.0rc1.dist-info}/WHEEL +1 -1
- {learning_credentials-0.4.1rc5.dist-info → learning_credentials-0.5.0rc1.dist-info}/entry_points.txt +0 -0
- {learning_credentials-0.4.1rc5.dist-info → learning_credentials-0.5.0rc1.dist-info}/licenses/LICENSE.txt +0 -0
- {learning_credentials-0.4.1rc5.dist-info → learning_credentials-0.5.0rc1.dist-info}/top_level.txt +0 -0
learning_credentials/admin.py
CHANGED
|
@@ -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')
|
|
@@ -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
|
]
|
|
@@ -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/models.py
CHANGED
|
@@ -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
|
|
@@ -33,6 +34,25 @@ if TYPE_CHECKING: # pragma: no cover
|
|
|
33
34
|
log = logging.getLogger(__name__)
|
|
34
35
|
|
|
35
36
|
|
|
37
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
38
|
+
"""
|
|
39
|
+
Deep merge two dictionaries.
|
|
40
|
+
|
|
41
|
+
Values from `override` take precedence. Nested dictionaries are merged recursively.
|
|
42
|
+
|
|
43
|
+
:param base: The base dictionary.
|
|
44
|
+
:param override: The dictionary with overriding values.
|
|
45
|
+
:return: A new dictionary with merged values.
|
|
46
|
+
"""
|
|
47
|
+
result = base.copy()
|
|
48
|
+
for key, value in override.items():
|
|
49
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
50
|
+
result[key] = _deep_merge(result[key], value)
|
|
51
|
+
else:
|
|
52
|
+
result[key] = value
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
36
56
|
class CredentialType(TimeStampedModel):
|
|
37
57
|
"""
|
|
38
58
|
Model to store global credential configurations for each type.
|
|
@@ -128,9 +148,8 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
128
148
|
self.periodic_task.args = json.dumps([self.id])
|
|
129
149
|
self.periodic_task.save()
|
|
130
150
|
|
|
131
|
-
# Replace the return type with `QuerySet[Self]` after migrating to Python 3.10+.
|
|
132
151
|
@classmethod
|
|
133
|
-
def get_enabled_configurations(cls) -> QuerySet[
|
|
152
|
+
def get_enabled_configurations(cls) -> QuerySet[Self]:
|
|
134
153
|
"""
|
|
135
154
|
Get the list of enabled configurations.
|
|
136
155
|
|
|
@@ -176,10 +195,10 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
176
195
|
module = import_module(module_path)
|
|
177
196
|
func = getattr(module, func_name)
|
|
178
197
|
|
|
179
|
-
custom_options =
|
|
198
|
+
custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
|
|
180
199
|
return func(self.learning_context_key, custom_options)
|
|
181
200
|
|
|
182
|
-
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:
|
|
183
202
|
"""
|
|
184
203
|
Celery task for processing a single user's credential.
|
|
185
204
|
|
|
@@ -190,19 +209,24 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
190
209
|
Args:
|
|
191
210
|
user_id: The ID of the user to process the credential for.
|
|
192
211
|
celery_task_id (optional): The ID of the Celery task that is running this function.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The generated Credential object.
|
|
193
215
|
"""
|
|
194
216
|
user = get_user_model().objects.get(id=user_id)
|
|
195
217
|
# Use the name from the profile if it is not empty. Otherwise, use the first and last name.
|
|
196
218
|
# We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
|
|
197
219
|
user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
|
|
198
|
-
|
|
220
|
+
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
221
|
+
custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
|
|
199
222
|
|
|
200
|
-
credential, _ = Credential.objects.update_or_create(
|
|
223
|
+
credential, _ = Credential.objects.exclude(status=Credential.Status.INVALIDATED).update_or_create(
|
|
201
224
|
user_id=user_id,
|
|
202
225
|
learning_context_key=self.learning_context_key,
|
|
203
226
|
credential_type=self.credential_type.name,
|
|
204
227
|
defaults={
|
|
205
228
|
'user_full_name': user_full_name,
|
|
229
|
+
'learning_context_name': learning_context_name,
|
|
206
230
|
'status': Credential.Status.GENERATING,
|
|
207
231
|
'generation_task_id': celery_task_id,
|
|
208
232
|
},
|
|
@@ -228,6 +252,8 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
228
252
|
if user.is_active and user.has_usable_password():
|
|
229
253
|
credential.send_email()
|
|
230
254
|
|
|
255
|
+
return credential
|
|
256
|
+
|
|
231
257
|
|
|
232
258
|
# noinspection PyUnusedLocal
|
|
233
259
|
@receiver(post_delete, sender=CredentialConfiguration)
|
|
@@ -262,16 +288,33 @@ class Credential(TimeStampedModel):
|
|
|
262
288
|
|
|
263
289
|
uuid = models.UUIDField(
|
|
264
290
|
primary_key=True,
|
|
265
|
-
default=
|
|
291
|
+
default=uuid_lib.uuid4,
|
|
266
292
|
editable=False,
|
|
267
293
|
help_text=_('Auto-generated UUID of the credential'),
|
|
268
294
|
)
|
|
295
|
+
verify_uuid = models.UUIDField(
|
|
296
|
+
default=uuid_lib.uuid4,
|
|
297
|
+
editable=False,
|
|
298
|
+
help_text=_('UUID used for verifying the credential'),
|
|
299
|
+
)
|
|
269
300
|
user_id = models.IntegerField(help_text=_('ID of the user receiving the credential'))
|
|
270
|
-
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
|
+
)
|
|
271
306
|
learning_context_key = LearningContextKeyField(
|
|
272
307
|
max_length=255,
|
|
273
308
|
help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
|
|
274
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
|
+
)
|
|
275
318
|
credential_type = models.CharField(max_length=255, help_text=_('Type of the credential'))
|
|
276
319
|
status = models.CharField(
|
|
277
320
|
max_length=32,
|
|
@@ -282,16 +325,26 @@ class Credential(TimeStampedModel):
|
|
|
282
325
|
download_url = models.URLField(blank=True, help_text=_('URL of the generated credential PDF (e.g., to S3)'))
|
|
283
326
|
legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the credential imported from another system'))
|
|
284
327
|
generation_task_id = models.CharField(max_length=255, help_text=_('Task ID from the Celery queue'))
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
)
|
|
288
334
|
|
|
289
335
|
def __str__(self): # noqa: D105
|
|
290
336
|
return f"{self.credential_type} for {self.user_full_name} in {self.learning_context_key}"
|
|
291
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
|
+
|
|
292
346
|
def send_email(self):
|
|
293
347
|
"""Send a credential link to the student."""
|
|
294
|
-
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
295
348
|
user = get_user_model().objects.get(id=self.user_id)
|
|
296
349
|
msg = Message(
|
|
297
350
|
name="certificate_generated",
|
|
@@ -300,12 +353,26 @@ class Credential(TimeStampedModel):
|
|
|
300
353
|
language='en',
|
|
301
354
|
context={
|
|
302
355
|
'certificate_link': self.download_url,
|
|
303
|
-
'course_name': learning_context_name,
|
|
356
|
+
'course_name': self.learning_context_name,
|
|
304
357
|
'platform_name': settings.PLATFORM_NAME,
|
|
305
358
|
},
|
|
306
359
|
)
|
|
307
360
|
ace.send(msg)
|
|
308
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
|
+
|
|
309
376
|
|
|
310
377
|
class CredentialAsset(TimeStampedModel):
|
|
311
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/urls.py
CHANGED
|
@@ -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
|
]
|
{learning_credentials-0.4.1rc5.dist-info → learning_credentials-0.5.0rc1.dist-info}/METADATA
RENAMED
|
@@ -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
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
learning_credentials/__init__.py,sha256=8Q0-3Hdnfmcj41EKu1GSfzEfwWcYNDlItyEEke2r9bs,62
|
|
2
|
-
learning_credentials/admin.py,sha256=
|
|
2
|
+
learning_credentials/admin.py,sha256=gLVpCn5oOHLL3u-wnx4R1yJXfar1Z32vk8zcTVjtBFY,11791
|
|
3
3
|
learning_credentials/apps.py,sha256=trdQxe-JRhUdUaOQoQWiGL1sn6I1sfDiTvdCwy8yGuw,1037
|
|
4
4
|
learning_credentials/compat.py,sha256=bTAB6bTh99ZyhUqOsDtM_BuIPzFxCjySFtfvc-_fCd4,4731
|
|
5
5
|
learning_credentials/exceptions.py,sha256=UaqBVXFMWR2Iob7_LMb3j4NNVmWQFAgLi_MNMRUvGsI,290
|
|
6
6
|
learning_credentials/generators.py,sha256=gaw3zoEzVhSjo96QM6Q6K70e_iK44LxCuQIQ05p7lP0,14895
|
|
7
|
-
learning_credentials/models.py,sha256=
|
|
7
|
+
learning_credentials/models.py,sha256=6ObPBesQW7RPXOH1bnQf4ZEWoXwHfwN3K_OmvZZxW8E,18476
|
|
8
8
|
learning_credentials/processors.py,sha256=LkdjmkLBnXc9qeMcksB1T8AQ5ZhYaECyQO__KfHB_aU,15212
|
|
9
9
|
learning_credentials/tasks.py,sha256=byoFEUvN_ayVaU5K5SlEiA7vu9BRPaSSmKnB9g5toec,1927
|
|
10
|
-
learning_credentials/urls.py,sha256=
|
|
10
|
+
learning_credentials/urls.py,sha256=KXZtvPXXl2X_nTREWaCFxcAgY2XET1eWRbcx2rq_6eI,348
|
|
11
11
|
learning_credentials/api/__init__.py,sha256=q8sLFfwo5RwQu8FY6BJUL_Jrt3TUojbZK-Zlw9v08EM,40
|
|
12
12
|
learning_credentials/api/urls.py,sha256=wW27hrrJ7D_h8PbFDbSxzeaneNla0R-56gjKy9zISG8,216
|
|
13
13
|
learning_credentials/api/v1/__init__.py,sha256=A7ZqENtM4QM1A7j_cAfnzw4zn0kuyfXSWtylFIE0_f8,43
|
|
14
14
|
learning_credentials/api/v1/permissions.py,sha256=TqM50TpR3JGUgZgIgKZF0-R_g1_P2V9bqKzYXgk-VvY,3436
|
|
15
|
-
learning_credentials/api/v1/
|
|
16
|
-
learning_credentials/api/v1/
|
|
15
|
+
learning_credentials/api/v1/serializers.py,sha256=H7l-vRTwLBplveCBjnNgSawJqpSVskeHTz7wpUiNB3g,417
|
|
16
|
+
learning_credentials/api/v1/urls.py,sha256=RytArViuKZQkWs46sk58VfaVCwLV-QrgTG7cQLE_NtU,408
|
|
17
|
+
learning_credentials/api/v1/views.py,sha256=CJEVPwCXs_ii463agPRpJeX6NCgyyFX9ZIBJh0BAc9I,4926
|
|
17
18
|
learning_credentials/conf/locale/config.yaml,sha256=jPen2DmckNDKK30axCKEd2Q2ha9oOG3IBxrJ63Pvznk,2280
|
|
18
19
|
learning_credentials/migrations/0001_initial.py,sha256=61EvThCv-0UAnhCE5feyQVfjRodbp-6cDaAr4CY5PMA,8435
|
|
19
20
|
learning_credentials/migrations/0002_migrate_to_learning_credentials.py,sha256=vUhcnQKDdwOsppkXsjz2zZwOGMwIJ-fkQRsaj-K7l1o,1779
|
|
@@ -22,19 +23,21 @@ learning_credentials/migrations/0004_replace_course_keys_with_learning_context_k
|
|
|
22
23
|
learning_credentials/migrations/0005_rename_processors_and_generators.py,sha256=5UCqjq-CBJnRo1qBAoWs91ngyEuSMN8_tQtfzsuR5SI,5271
|
|
23
24
|
learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py,sha256=aJs_gOP4TmW9J-Dmr21m94jBfLQxzjAu6-ua7x4uYLE,727
|
|
24
25
|
learning_credentials/migrations/0007_migrate_to_text_elements_format.py,sha256=_olkaxPPuRys2c2X5fnyQIFVvqEfdoYu-JlApmXuHEM,4758
|
|
26
|
+
learning_credentials/migrations/0008_validation.py,sha256=jcTg4Lnlmcyp1Czc9b-52gPJ_s8W7Dwodvi_LggpVjw,3387
|
|
25
27
|
learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
28
|
learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
|
|
27
29
|
learning_credentials/settings/common.py,sha256=Cck-nyFt11G1NLiz-bHfKJp8MV6sDZGqTwdbC8_1WE0,360
|
|
28
30
|
learning_credentials/settings/production.py,sha256=6P0P7JxbpWNsk4Lk8lfyxHirOWMgU4UWOb3EYKLjiVQ,542
|
|
29
31
|
learning_credentials/templates/learning_credentials/base.html,sha256=wtjBYqfHmOnyEY5tN3VGOmzYLsOD24MXdEUhTZ7OmwI,662
|
|
32
|
+
learning_credentials/templates/learning_credentials/verify.html,sha256=vXTiZMZkTLhp6cnqKBJcQnDu7qUxLvrTzN2m1KM8_9k,3414
|
|
30
33
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html,sha256=t-i1Ra9AC4pX-rPRifDJIvBBZuxCxdrFqg1NKTjHBOk,813
|
|
31
34
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt,sha256=IF_x8aF_-dORlQB-RCh0IkJDl2ktD489E8qGgLe9M3Y,677
|
|
32
35
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
|
|
33
36
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
37
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt,sha256=S7Hc5T_sZSsSBXm5_H5HBNNv16Ohl0oZn0nVqqeWL0g,132
|
|
35
|
-
learning_credentials-0.
|
|
36
|
-
learning_credentials-0.
|
|
37
|
-
learning_credentials-0.
|
|
38
|
-
learning_credentials-0.
|
|
39
|
-
learning_credentials-0.
|
|
40
|
-
learning_credentials-0.
|
|
38
|
+
learning_credentials-0.5.0rc1.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
|
|
39
|
+
learning_credentials-0.5.0rc1.dist-info/METADATA,sha256=8O8HFW30e2DKY_0Yzirkh_Nt0jQV7aWyCEBluO_Erfg,8461
|
|
40
|
+
learning_credentials-0.5.0rc1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
41
|
+
learning_credentials-0.5.0rc1.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
|
|
42
|
+
learning_credentials-0.5.0rc1.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
|
|
43
|
+
learning_credentials-0.5.0rc1.dist-info/RECORD,,
|
{learning_credentials-0.4.1rc5.dist-info → learning_credentials-0.5.0rc1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{learning_credentials-0.4.1rc5.dist-info → learning_credentials-0.5.0rc1.dist-info}/top_level.txt
RENAMED
|
File without changes
|