learning-credentials 0.4.1rc5__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.
Files changed (57) hide show
  1. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/CHANGELOG.rst +10 -1
  2. {learning_credentials-0.4.1rc5/learning_credentials.egg-info → learning_credentials-0.5.0rc1}/PKG-INFO +12 -2
  3. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/admin.py +30 -3
  4. learning_credentials-0.5.0rc1/learning_credentials/api/v1/serializers.py +13 -0
  5. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/urls.py +2 -1
  6. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/views.py +61 -1
  7. learning_credentials-0.5.0rc1/learning_credentials/migrations/0008_validation.py +94 -0
  8. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/models.py +82 -15
  9. learning_credentials-0.5.0rc1/learning_credentials/templates/learning_credentials/verify.html +83 -0
  10. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/urls.py +2 -0
  11. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1/learning_credentials.egg-info}/PKG-INFO +12 -2
  12. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/SOURCES.txt +3 -0
  13. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/pyproject.toml +2 -6
  14. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/tests/test_generators.py +13 -18
  15. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/tests/test_migrations.py +5 -24
  16. learning_credentials-0.5.0rc1/tests/test_models.py +572 -0
  17. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/tests/test_processors.py +1 -26
  18. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/tests/test_views.py +24 -106
  19. learning_credentials-0.4.1rc5/tests/test_models.py +0 -336
  20. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/LICENSE.txt +0 -0
  21. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/MANIFEST.in +0 -0
  22. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/README.rst +0 -0
  23. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/__init__.py +0 -0
  24. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/api/__init__.py +0 -0
  25. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/api/urls.py +0 -0
  26. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/__init__.py +0 -0
  27. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/api/v1/permissions.py +0 -0
  28. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/apps.py +0 -0
  29. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/compat.py +0 -0
  30. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/conf/locale/config.yaml +0 -0
  31. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/exceptions.py +0 -0
  32. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/generators.py +0 -0
  33. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0001_initial.py +0 -0
  34. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
  35. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
  36. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
  37. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
  38. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
  39. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +0 -0
  40. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/migrations/__init__.py +0 -0
  41. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/processors.py +0 -0
  42. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/settings/__init__.py +0 -0
  43. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/settings/common.py +0 -0
  44. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/settings/production.py +0 -0
  45. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/tasks.py +0 -0
  46. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/base.html +0 -0
  47. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
  48. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
  49. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
  50. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
  51. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
  52. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/dependency_links.txt +0 -0
  53. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/entry_points.txt +0 -0
  54. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/requires.txt +0 -0
  55. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/learning_credentials.egg-info/top_level.txt +0 -0
  56. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/setup.cfg +0 -0
  57. {learning_credentials-0.4.1rc5 → learning_credentials-0.5.0rc1}/tests/test_tasks.py +0 -0
@@ -16,7 +16,16 @@ Unreleased
16
16
 
17
17
  *
18
18
 
19
- 0.4.1 - 2025-12-31
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.4.1rc5
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.4.1 - 2025-12-31
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
@@ -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
+ ]
@@ -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[CredentialConfiguration]:
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 = {**self.credential_type.custom_options, **self.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
- custom_options = {**self.credential_type.custom_options, **self.custom_options}
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=uuid.uuid4,
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(max_length=255, help_text=_('User receiving the credential'))
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
- class Meta: # noqa: D106
287
- unique_together = (('user_id', 'learning_context_key', 'credential_type'),)
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 %}
@@ -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.4.1rc5
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.4.1 - 2025-12-31
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