learning-credentials 0.3.1rc2__py3-none-any.whl → 0.4.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.
@@ -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)
@@ -52,13 +52,10 @@ def get_course_grading_policy(course_id: CourseKey) -> dict:
52
52
  def _get_course_name(course_id: CourseKey) -> str:
53
53
  """Get the course name from Open edX."""
54
54
  # noinspection PyUnresolvedReferences,PyPackageRequirements
55
- from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
55
+ from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline
56
56
 
57
- name = str(course_id)
58
- if course_overview := get_course_overview_or_none(course_id):
59
- name = course_overview.cert_name_long or course_overview.display_name or name
60
-
61
- return name
57
+ course_outline = get_course_outline(course_id)
58
+ return (course_outline and course_outline.title) or str(course_id)
62
59
 
63
60
 
64
61
  def _get_learning_path_name(learning_path_key: LearningPathKey) -> str:
@@ -19,9 +19,9 @@ from django.core.files.base import ContentFile
19
19
  from django.core.files.storage import FileSystemStorage, default_storage
20
20
  from pypdf import PdfReader, PdfWriter
21
21
  from pypdf.constants import UserAccessPermissions
22
- from reportlab.pdfbase.pdfmetrics import FontError, FontNotFoundError, registerFont
23
- from reportlab.pdfbase.ttfonts import TTFError, TTFont
24
- from reportlab.pdfgen.canvas import Canvas
22
+ from reportlab.pdfbase import pdfmetrics
23
+ from reportlab.pdfbase.ttfonts import TTFont
24
+ from reportlab.pdfgen import canvas
25
25
 
26
26
  from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
27
27
  from .models import CredentialAsset
@@ -33,7 +33,6 @@ if TYPE_CHECKING: # pragma: no cover
33
33
 
34
34
  from django.contrib.auth.models import User
35
35
  from opaque_keys.edx.keys import CourseKey
36
- from pypdf import PageObject
37
36
 
38
37
 
39
38
  def _get_user_name(user: User) -> str:
@@ -46,29 +45,25 @@ def _get_user_name(user: User) -> str:
46
45
  return user.profile.name or f"{user.first_name} {user.last_name}"
47
46
 
48
47
 
49
- def _register_font(font_name: str) -> str | None:
48
+ def _register_font(options: dict[str, Any]) -> str:
50
49
  """
51
50
  Register a custom font if specified in options. If not specified, use the default font (Helvetica).
52
51
 
53
- :param font_name: The name of the font to register.
54
- :returns: The font name if registered successfully, otherwise None.
52
+ :param options: A dictionary containing the font.
53
+ :returns: The font name.
55
54
  """
56
- if not font_name:
57
- return None
55
+ if font := options.get('font'):
56
+ pdfmetrics.registerFont(TTFont(font, CredentialAsset.get_asset_by_slug(font)))
58
57
 
59
- try:
60
- registerFont(TTFont(font_name, CredentialAsset.get_asset_by_slug(font_name)))
61
- except (FontError, FontNotFoundError, TTFError):
62
- log.exception("Error registering font %s", font_name)
63
- else:
64
- return font_name
58
+ return font or 'Helvetica'
65
59
 
66
60
 
67
- def _write_text_on_template(template: PageObject, username: str, context_name: str, options: dict[str, Any]) -> Canvas:
61
+ def _write_text_on_template(template: any, font: str, username: str, context_name: str, options: dict[str, Any]) -> any:
68
62
  """
69
63
  Prepare a new canvas and write the user and course name onto it.
70
64
 
71
65
  :param template: Pdf template.
66
+ :param font: Font name.
72
67
  :param username: The name of the user to generate the credential for.
73
68
  :param context_name: The name of the learning context.
74
69
  :param options: A dictionary documented in the `generate_pdf_credential` function.
@@ -91,26 +86,19 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
91
86
  return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
92
87
 
93
88
  template_width, template_height = template.mediabox[2:]
94
- pdf_canvas = Canvas(io.BytesIO(), pagesize=(template_width, template_height))
95
- font = _register_font(options.get('font')) or 'Helvetica'
89
+ pdf_canvas = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height))
96
90
 
97
91
  # Write the learner name.
98
- if options.get('name_uppercase', getattr(settings, 'LEARNING_CREDENTIALS_NAME_UPPERCASE', False)):
99
- username = username.upper()
100
-
101
- name_font = _register_font(options.get('name_font')) or font
102
- pdf_canvas.setFont(name_font, options.get('name_size', 32))
92
+ pdf_canvas.setFont(font, options.get('name_size', 32))
103
93
  name_color = options.get('name_color', '#000')
104
94
  pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
105
95
 
106
96
  name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
107
97
  name_y = options.get('name_y', 290)
108
-
109
98
  pdf_canvas.drawString(name_x, name_y, username)
110
99
 
111
100
  # Write the learning context name.
112
- context_name_font = _register_font(options.get('context_name_font')) or font
113
- pdf_canvas.setFont(context_name_font, options.get('context_name_size', 28))
101
+ pdf_canvas.setFont(font, options.get('context_name_size', 28))
114
102
  context_name_color = options.get('context_name_color', '#000')
115
103
  pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
116
104
 
@@ -125,23 +113,13 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
125
113
 
126
114
  # Write the issue date.
127
115
  issue_date = get_localized_credential_date()
128
- if options.get('issue_date_uppercase', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_UPPERCASE', False)):
129
- issue_date = issue_date.upper()
130
-
131
- issue_date_font = _register_font(options.get('issue_date_font')) or font
132
- pdf_canvas.setFont(issue_date_font, options.get('issue_date_size', 12))
116
+ pdf_canvas.setFont(font, 12)
133
117
  issue_date_color = options.get('issue_date_color', '#000')
134
118
  pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
135
119
 
136
120
  issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
137
- issue_date_x += options.get('issue_date_x', 0)
138
121
  issue_date_y = options.get('issue_date_y', 120)
139
-
140
- issue_date_char_space = options.get(
141
- 'issue_date_char_space', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_CHAR_SPACE', 0)
142
- )
143
-
144
- pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date, charSpace=issue_date_char_space)
122
+ pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
145
123
 
146
124
  return pdf_canvas
147
125
 
@@ -190,7 +168,7 @@ def generate_pdf_credential(
190
168
  credential_uuid: UUID,
191
169
  options: dict[str, Any],
192
170
  ) -> str:
193
- r"""
171
+ """
194
172
  Generate a PDF credential.
195
173
 
196
174
  :param learning_context_key: The ID of the course or learning path the credential is for.
@@ -201,50 +179,33 @@ def generate_pdf_credential(
201
179
 
202
180
  Options:
203
181
  - template: The path to the PDF template file.
204
- - template_multiline: The path to the PDF template file for multiline context names.
205
- A multiline context name is specified by using '\n' or ';' as a separator.
206
- - font: The name of the font to use. The default font is Helvetica.
182
+ - template_two_lines: The path to the PDF template file for two-line context names.
183
+ A two-line context name is specified by using a semicolon as a separator.
184
+ - font: The name of the font to use.
207
185
  - name_y: The Y coordinate of the name on the credential (vertical position on the template).
208
186
  - name_color: The color of the name on the credential (hexadecimal color code).
209
187
  - name_size: The font size of the name on the credential. The default value is 32.
210
- - name_font: The font of the name on the credential. It overrides the `font` option.
211
- - name_uppercase: If set to true (without quotes), the name will be converted to uppercase.
212
- The default value is False, unless specified otherwise in the instance settings.
213
- - context_name: Specify the custom course or Learning Path name. If not provided, it will be retrieved
214
- automatically from the "cert_name_long" or "display_name" fields for courses, or from the Learning Path model.
188
+ - context_name: Specify the custom course or Learning Path name.
215
189
  - context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
216
190
  - context_name_color: The color of the context name on the credential (hexadecimal color code).
217
191
  - context_name_size: The font size of the context name on the credential. The default value is 28.
218
- - context_name_font: The font of the context name on the credential. It overrides the `font` option.
219
- - issue_date_x: The horizontal offset for the issue date from its centered position
220
- (positive values move right, negative values move left; default is 0).
221
192
  - issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
222
193
  - issue_date_color: The color of the issue date on the credential (hexadecimal color code).
223
- - issue_date_size: The font size of the issue date on the credential. The default value is 12.
224
- - issue_date_font: The font of the issue date on the credential. It overrides the `font` option.
225
- - issue_date_char_space: The character spacing of the issue date on the credential
226
- (default is 0.0, unless specified otherwise in the instance settings).
227
- - issue_date_uppercase: If set to true (without quotes), the issue date will be converted to uppercase.
228
- The default value is False, unless specified otherwise in the instance settings.
229
194
  """
230
195
  log.info("Starting credential generation for user %s", user.id)
231
196
 
232
197
  username = _get_user_name(user)
233
198
  context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
234
- template_path = options.get('template')
235
-
236
- # Handle multiline context name (we support semicolon as a separator to preserve backward compatibility).
237
- context_name = context_name.replace(';', '\n').replace(r'\n', '\n')
238
- if '\n' in context_name:
239
- # `template_two_lines` is kept for backward compatibility.
240
- template_path = options.get('template_multiline', options.get('template_two_lines', template_path))
241
-
242
- if not template_path:
243
- msg = "Template path must be specified in options."
244
- raise ValueError(msg)
245
199
 
246
200
  # Get template from the CredentialAsset.
247
- template_file = CredentialAsset.get_asset_by_slug(template_path)
201
+ # HACK: We support two-line strings by using a semicolon as a separator.
202
+ if ';' in context_name and (template_path := options.get('template_two_lines')):
203
+ template_file = CredentialAsset.get_asset_by_slug(template_path)
204
+ context_name = context_name.replace(';', '\n')
205
+ else:
206
+ template_file = CredentialAsset.get_asset_by_slug(options['template'])
207
+
208
+ font = _register_font(options)
248
209
 
249
210
  # Load the PDF template.
250
211
  with template_file.open('rb') as template_file:
@@ -253,7 +214,7 @@ def generate_pdf_credential(
253
214
  credential = PdfWriter()
254
215
 
255
216
  # Create a new canvas, prepare the page and write the data
256
- pdf_canvas = _write_text_on_template(template, username, context_name, options)
217
+ pdf_canvas = _write_text_on_template(template, font, username, context_name, options)
257
218
 
258
219
  overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
259
220
  template.merge_page(overlay_pdf.pages[0])
@@ -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", "0006_cleanup_openedx_certificates_tables"),
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
@@ -179,7 +180,7 @@ class CredentialConfiguration(TimeStampedModel):
179
180
  custom_options = {**self.credential_type.custom_options, **self.custom_options}
180
181
  return func(self.learning_context_key, custom_options)
181
182
 
182
- def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
183
+ def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0) -> Credential:
183
184
  """
184
185
  Celery task for processing a single user's credential.
185
186
 
@@ -190,19 +191,24 @@ class CredentialConfiguration(TimeStampedModel):
190
191
  Args:
191
192
  user_id: The ID of the user to process the credential for.
192
193
  celery_task_id (optional): The ID of the Celery task that is running this function.
194
+
195
+ Returns:
196
+ The generated Credential object.
193
197
  """
194
198
  user = get_user_model().objects.get(id=user_id)
195
199
  # Use the name from the profile if it is not empty. Otherwise, use the first and last name.
196
200
  # We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
197
201
  user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
202
+ learning_context_name = get_learning_context_name(self.learning_context_key)
198
203
  custom_options = {**self.credential_type.custom_options, **self.custom_options}
199
204
 
200
- credential, _ = Credential.objects.update_or_create(
205
+ credential, _ = Credential.objects.exclude(status=Credential.Status.INVALIDATED).update_or_create(
201
206
  user_id=user_id,
202
207
  learning_context_key=self.learning_context_key,
203
208
  credential_type=self.credential_type.name,
204
209
  defaults={
205
210
  'user_full_name': user_full_name,
211
+ 'learning_context_name': learning_context_name,
206
212
  'status': Credential.Status.GENERATING,
207
213
  'generation_task_id': celery_task_id,
208
214
  },
@@ -228,6 +234,8 @@ class CredentialConfiguration(TimeStampedModel):
228
234
  if user.is_active and user.has_usable_password():
229
235
  credential.send_email()
230
236
 
237
+ return credential
238
+
231
239
 
232
240
  # noinspection PyUnusedLocal
233
241
  @receiver(post_delete, sender=CredentialConfiguration)
@@ -262,16 +270,33 @@ class Credential(TimeStampedModel):
262
270
 
263
271
  uuid = models.UUIDField(
264
272
  primary_key=True,
265
- default=uuid.uuid4,
273
+ default=uuid_lib.uuid4,
266
274
  editable=False,
267
275
  help_text=_('Auto-generated UUID of the credential'),
268
276
  )
277
+ verify_uuid = models.UUIDField(
278
+ default=uuid_lib.uuid4,
279
+ editable=False,
280
+ help_text=_('UUID used for verifying the credential'),
281
+ )
269
282
  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'))
283
+ user_full_name = models.CharField(
284
+ max_length=255,
285
+ editable=False,
286
+ help_text=_('User receiving the credential. This field is used for validation purposes.'),
287
+ )
271
288
  learning_context_key = LearningContextKeyField(
272
289
  max_length=255,
273
290
  help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
274
291
  )
292
+ learning_context_name = models.CharField(
293
+ max_length=255,
294
+ editable=False,
295
+ help_text=_(
296
+ 'Name of the learning context for which the credential was issued. '
297
+ 'This field is used for validation purposes.'
298
+ ),
299
+ )
275
300
  credential_type = models.CharField(max_length=255, help_text=_('Type of the credential'))
276
301
  status = models.CharField(
277
302
  max_length=32,
@@ -282,16 +307,26 @@ class Credential(TimeStampedModel):
282
307
  download_url = models.URLField(blank=True, help_text=_('URL of the generated credential PDF (e.g., to S3)'))
283
308
  legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the credential imported from another system'))
284
309
  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'),)
310
+ invalidated_at = models.DateTimeField(
311
+ null=True, editable=False, help_text=_('Timestamp when the credential was invalidated')
312
+ )
313
+ invalidation_reason = models.CharField(
314
+ max_length=255, blank=True, help_text=_('Reason for invalidating the credential')
315
+ )
288
316
 
289
317
  def __str__(self): # noqa: D105
290
318
  return f"{self.credential_type} for {self.user_full_name} in {self.learning_context_key}"
291
319
 
320
+ def save(self, *args, **kwargs):
321
+ """If the invalidation reason is set, update the status and timestamp."""
322
+ if self.invalidation_reason and self.status != Credential.Status.INVALIDATED:
323
+ self.status = Credential.Status.INVALIDATED
324
+ if self.status == Credential.Status.INVALIDATED and not self.invalidated_at:
325
+ self.invalidated_at = timezone.now()
326
+ super().save(*args, **kwargs)
327
+
292
328
  def send_email(self):
293
329
  """Send a credential link to the student."""
294
- learning_context_name = get_learning_context_name(self.learning_context_key)
295
330
  user = get_user_model().objects.get(id=self.user_id)
296
331
  msg = Message(
297
332
  name="certificate_generated",
@@ -300,12 +335,26 @@ class Credential(TimeStampedModel):
300
335
  language='en',
301
336
  context={
302
337
  'certificate_link': self.download_url,
303
- 'course_name': learning_context_name,
338
+ 'course_name': self.learning_context_name,
304
339
  'platform_name': settings.PLATFORM_NAME,
305
340
  },
306
341
  )
307
342
  ace.send(msg)
308
343
 
344
+ def reissue(self) -> Self:
345
+ """Invalidate the current credential and create a new one."""
346
+ config = CredentialConfiguration.objects.get(
347
+ learning_context_key=self.learning_context_key,
348
+ credential_type__name=self.credential_type,
349
+ )
350
+
351
+ if self.invalidation_reason:
352
+ self.invalidation_reason += '\n'
353
+ self.invalidation_reason += 'Reissued'
354
+ self.save()
355
+
356
+ return config.generate_credential_for_user(self.user_id)
357
+
309
358
 
310
359
  class CredentialAsset(TimeStampedModel):
311
360
  """
@@ -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.3.1rc2
3
+ Version: 0.4.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
@@ -176,23 +176,14 @@ Unreleased
176
176
 
177
177
  *
178
178
 
179
- 0.3.1 - 2025-12-15
179
+ 0.4.0 - 2025-11-03
180
180
  ******************
181
181
 
182
182
  Added
183
183
  =====
184
184
 
185
- * Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings).
186
- * Support for specifying individual fonts for PDF text elements.
187
- * Support for \n in learning context names in PDF certificates.
188
- * Options for uppercase name and issue date in PDF certificates.
189
- * Option for defining character spacing for issue date in PDF certificates.
190
- * Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
191
-
192
- Modified
193
- ========
194
-
195
- * Replaced ``template_two_lines`` with ``template_multiline``.
185
+ * Frontend form and backend API endpoint for verifying credentials.
186
+ * Option to invalidate issued credentials.
196
187
 
197
188
  0.3.0 - 2025-09-17
198
189
  ******************
@@ -1,19 +1,20 @@
1
1
  learning_credentials/__init__.py,sha256=8Q0-3Hdnfmcj41EKu1GSfzEfwWcYNDlItyEEke2r9bs,62
2
- learning_credentials/admin.py,sha256=ynK3tVJwLsIeV7Jk66t1FAVyVsU1G-KRIAdRkycVTmA,10439
2
+ learning_credentials/admin.py,sha256=gLVpCn5oOHLL3u-wnx4R1yJXfar1Z32vk8zcTVjtBFY,11791
3
3
  learning_credentials/apps.py,sha256=trdQxe-JRhUdUaOQoQWiGL1sn6I1sfDiTvdCwy8yGuw,1037
4
- learning_credentials/compat.py,sha256=bTAB6bTh99ZyhUqOsDtM_BuIPzFxCjySFtfvc-_fCd4,4731
4
+ learning_credentials/compat.py,sha256=OvgzxnhGG5A7Ij65mBv3kyaYojKKQ381RbUGsWXtyOg,4651
5
5
  learning_credentials/exceptions.py,sha256=UaqBVXFMWR2Iob7_LMb3j4NNVmWQFAgLi_MNMRUvGsI,290
6
- learning_credentials/generators.py,sha256=Ya0TbBD0-T0AWmp6VMQTtBmtgi2obs78vYX38SUmQX8,11696
7
- learning_credentials/models.py,sha256=J_SCNiu42yhdi12eDMLsxNCTkJK7_vqneQjyGYG5KJ4,16048
6
+ learning_credentials/generators.py,sha256=KCB166rOx-bwnm6kOc-Vz2HomGdJrQXqglv2MPVBF_Q,9075
7
+ learning_credentials/models.py,sha256=DncnadwVrmVcbrka9LBmKvTMqLq8os_wHXmWc3Zet6s,17949
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=gO_c930rzMylP-riQ9SGHXH9JIMF7ajySDT2Tc-E8x4,188
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/urls.py,sha256=6YqLS4aVfA1cuLOgVe4lFUFa38wVehYKleXBF8ImMm0,287
16
- learning_credentials/api/v1/views.py,sha256=rkdj1AfRBDzrpRC5uGMAxTUf4P1zs-MSF9lpNMIgYLw,3005
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
@@ -21,19 +22,21 @@ learning_credentials/migrations/0003_rename_certificates_to_credentials.py,sha25
21
22
  learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py,sha256=5KaXvASl69qbEaHX5_Ty_3Dr7K4WV6p8VWOx72yJnTU,1919
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
25
+ learning_credentials/migrations/0007_validation.py,sha256=3vzjwDDFlwtFWmaCQ1wYfyl7Jj-cJB5Jpfq3cGo_vuI,3391
24
26
  learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
27
  learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
26
28
  learning_credentials/settings/common.py,sha256=Cck-nyFt11G1NLiz-bHfKJp8MV6sDZGqTwdbC8_1WE0,360
27
29
  learning_credentials/settings/production.py,sha256=6P0P7JxbpWNsk4Lk8lfyxHirOWMgU4UWOb3EYKLjiVQ,542
28
30
  learning_credentials/templates/learning_credentials/base.html,sha256=wtjBYqfHmOnyEY5tN3VGOmzYLsOD24MXdEUhTZ7OmwI,662
31
+ learning_credentials/templates/learning_credentials/verify.html,sha256=vXTiZMZkTLhp6cnqKBJcQnDu7qUxLvrTzN2m1KM8_9k,3414
29
32
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html,sha256=t-i1Ra9AC4pX-rPRifDJIvBBZuxCxdrFqg1NKTjHBOk,813
30
33
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt,sha256=IF_x8aF_-dORlQB-RCh0IkJDl2ktD489E8qGgLe9M3Y,677
31
34
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
32
35
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
36
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt,sha256=S7Hc5T_sZSsSBXm5_H5HBNNv16Ohl0oZn0nVqqeWL0g,132
34
- learning_credentials-0.3.1rc2.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
35
- learning_credentials-0.3.1rc2.dist-info/METADATA,sha256=fHxCRRyDHCO3lP4GOMR4qW4zBcrPPuATFjPJQ0iESmU,7756
36
- learning_credentials-0.3.1rc2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
37
- learning_credentials-0.3.1rc2.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
38
- learning_credentials-0.3.1rc2.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
39
- learning_credentials-0.3.1rc2.dist-info/RECORD,,
37
+ learning_credentials-0.4.0rc1.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
38
+ learning_credentials-0.4.0rc1.dist-info/METADATA,sha256=AMzNwr8Uif8XG05ihc6hM0FmRXWQB7PVG8tdJSx8VF0,7298
39
+ learning_credentials-0.4.0rc1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
40
+ learning_credentials-0.4.0rc1.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
41
+ learning_credentials-0.4.0rc1.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
42
+ learning_credentials-0.4.0rc1.dist-info/RECORD,,