learning-credentials 0.4.0__py3-none-any.whl → 0.4.0rc2__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)
@@ -9,10 +9,8 @@ We will move this module to an external repository (a plugin).
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- import copy
13
12
  import io
14
13
  import logging
15
- import re
16
14
  import secrets
17
15
  from typing import TYPE_CHECKING, Any
18
16
 
@@ -26,7 +24,6 @@ from reportlab.pdfbase.ttfonts import TTFError, TTFont
26
24
  from reportlab.pdfgen.canvas import Canvas
27
25
 
28
26
  from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
29
- from .exceptions import AssetNotFoundError
30
27
  from .models import CredentialAsset
31
28
 
32
29
  log = logging.getLogger(__name__)
@@ -39,48 +36,6 @@ if TYPE_CHECKING: # pragma: no cover
39
36
  from pypdf import PageObject
40
37
 
41
38
 
42
- def _get_defaults() -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
43
- """
44
- Get default styling and text element configurations.
45
-
46
- Evaluated lazily to avoid accessing Django settings at import time.
47
-
48
- :returns: A tuple of (default_styling, default_text_elements).
49
- """
50
- default_styling = {
51
- 'font': 'Helvetica',
52
- 'color': '#000',
53
- 'size': 12,
54
- 'char_space': 0,
55
- 'uppercase': False,
56
- 'line_height': 1.1,
57
- }
58
-
59
- default_text_elements = {
60
- 'name': {
61
- 'text': '{name}',
62
- 'y': 290,
63
- 'size': 32,
64
- 'uppercase': getattr(settings, 'LEARNING_CREDENTIALS_NAME_UPPERCASE', False),
65
- },
66
- 'context': {
67
- 'text': '{context_name}',
68
- 'y': 220,
69
- 'size': 28,
70
- 'line_height': 1.1,
71
- },
72
- 'date': {
73
- 'text': '{issue_date}',
74
- 'y': 120,
75
- 'size': 12,
76
- 'uppercase': getattr(settings, 'LEARNING_CREDENTIALS_DATE_UPPERCASE', False),
77
- 'char_space': getattr(settings, 'LEARNING_CREDENTIALS_DATE_CHAR_SPACE', 0),
78
- },
79
- }
80
-
81
- return default_styling, default_text_elements
82
-
83
-
84
39
  def _get_user_name(user: User) -> str:
85
40
  """
86
41
  Retrieve the user's name.
@@ -91,193 +46,89 @@ def _get_user_name(user: User) -> str:
91
46
  return user.profile.name or f"{user.first_name} {user.last_name}"
92
47
 
93
48
 
94
- def _register_font(pdf_canvas: Canvas, font_name: str) -> str:
49
+ def _register_font(font_name: Any) -> str | None: # noqa: ANN401
95
50
  """
96
- Register a custom font if not already available.
51
+ Register a custom font if specified in options. If not specified, use the default font (Helvetica).
97
52
 
98
- Built-in fonts (like Helvetica) are already available and don't need registration.
99
- Custom fonts are loaded from CredentialAsset.
100
-
101
- :param pdf_canvas: The canvas to check available fonts on.
102
53
  :param font_name: The name of the font to register.
103
- :returns: The font name if available, otherwise use 'Helvetica' as fallback.
54
+ :returns: The font name if registered successfully, otherwise None.
104
55
  """
105
- # Check if font is already available (built-in or previously registered).
106
- if font_name in pdf_canvas.getAvailableFonts():
107
- return font_name
56
+ if not font_name:
57
+ return None
108
58
 
109
59
  try:
110
60
  registerFont(TTFont(font_name, CredentialAsset.get_asset_by_slug(font_name)))
111
- except AssetNotFoundError:
112
- log.warning("Font asset not found: %s", font_name)
113
61
  except (FontError, FontNotFoundError, TTFError):
114
62
  log.exception("Error registering font %s", font_name)
115
63
  else:
116
64
  return font_name
117
65
 
118
- return 'Helvetica'
119
-
120
-
121
- def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
122
- """
123
- Convert a hexadecimal color code to an RGB tuple with floating-point values.
124
-
125
- :param hex_color: A hexadecimal color string, which can start with '#' and be either 3 or 6 characters long.
126
- :returns: A tuple representing the RGB color as (red, green, blue), with each value ranging from 0.0 to 1.0.
127
- """
128
- hex_color = hex_color.lstrip('#')
129
- # Expand shorthand form (e.g. "158" to "115588")
130
- if len(hex_color) == 3:
131
- hex_color = ''.join([c * 2 for c in hex_color])
132
-
133
- # noinspection PyTypeChecker
134
- return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
135
-
136
-
137
- def _substitute_placeholders(text: str, placeholders: dict[str, str]) -> str:
138
- """
139
- Substitute placeholders in text using {placeholder} syntax.
140
-
141
- Supports escaping with {{ for literal braces.
142
-
143
- :param text: The text containing placeholders.
144
- :param placeholders: A dictionary mapping placeholder names to their values.
145
- :returns: The text with placeholders substituted.
146
- """
147
-
148
- def replace_placeholder(match: re.Match) -> str:
149
- key = match.group(1)
150
- return placeholders.get(key, match.group(0))
151
-
152
- # Use negative lookbehind to skip escaped braces ({{).
153
- # Match {word} but not {{word}.
154
- text = re.sub(r'(?<!\{)\{(\w+)\}', replace_placeholder, text)
155
-
156
- # Replace escaped braces with literal braces.
157
- return text.replace('{{', '{').replace('}}', '}')
158
-
159
66
 
160
- def _build_text_elements(options: dict[str, Any]) -> dict[str, dict[str, Any]]:
67
+ def _write_text_on_template(template: PageObject, username: str, context_name: str, options: dict[str, Any]) -> Canvas:
161
68
  """
162
- Build the final text elements configuration by merging defaults with user options.
163
-
164
- Standard elements (name, context, date) use defaults that are deep-merged with user overrides.
165
- Custom elements (any other key) must provide at least 'text' and 'y'.
166
-
167
- :param options: The options dictionary from the credential configuration.
168
- :returns: A dictionary of element configurations ready for rendering.
169
- """
170
- default_styling, default_text_elements = _get_defaults()
171
- user_elements = options.get('text_elements', {})
172
- defaults_config = {**default_styling, **options.get('defaults', {})}
173
- result = {}
174
-
175
- # Process standard elements (they have defaults).
176
- for key, default_config in default_text_elements.items():
177
- user_config = user_elements.get(key, {})
178
-
179
- if user_config is False:
180
- continue
181
-
182
- # Merge: element defaults -> global defaults -> user config.
183
- element_config = {**copy.deepcopy(default_config), **defaults_config, **user_config}
184
- result[key] = element_config
185
-
186
- # Process custom elements (non-standard keys).
187
- for key, user_config in user_elements.items():
188
- if key in default_text_elements:
189
- continue
190
-
191
- # Skip disabled elements.
192
- if user_config is False:
193
- continue
69
+ Prepare a new canvas and write the user and course name onto it.
194
70
 
195
- if not isinstance(user_config, dict):
196
- log.warning("Invalid custom element configuration for key '%s': expected dict", key)
197
- continue
198
-
199
- # Custom elements must have 'text' and 'y'.
200
- if 'text' not in user_config or 'y' not in user_config:
201
- log.warning("Custom element '%s' must have 'text' and 'y' properties", key)
202
- continue
203
-
204
- # Merge with global defaults only.
205
- element_config = {**defaults_config, **user_config}
206
- result[key] = element_config
207
-
208
- return result
209
-
210
-
211
- def _render_text_element(
212
- pdf_canvas: Canvas,
213
- template_width: float,
214
- config: dict[str, Any],
215
- placeholders: dict[str, str],
216
- ) -> None:
217
- """
218
- Render a single text element on the canvas.
219
-
220
- :param pdf_canvas: The canvas to draw on.
221
- :param template_width: Width of the template for centering.
222
- :param config: The element configuration (all defaults are already merged).
223
- :param placeholders: Dictionary of placeholder values.
71
+ :param template: Pdf template.
72
+ :param username: The name of the user to generate the credential for.
73
+ :param context_name: The name of the learning context.
74
+ :param options: A dictionary documented in the `generate_pdf_credential` function.
75
+ :returns: A canvas with written data.
224
76
  """
225
- text = _substitute_placeholders(config['text'], placeholders)
226
-
227
- if config['uppercase']:
228
- text = text.upper()
229
-
230
- font_name = _register_font(pdf_canvas, config['font'])
231
- pdf_canvas.setFont(font_name, config['size'])
232
77
 
233
- pdf_canvas.setFillColorRGB(*_hex_to_rgb(config['color']))
78
+ def hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
79
+ """
80
+ Convert a hexadecimal color code to an RGB tuple with floating-point values.
234
81
 
235
- y = config['y']
236
- char_space = config['char_space']
237
- line_height = config['line_height']
238
- size = config['size']
82
+ :param hex_color: A hexadecimal color string, which can start with '#' and be either 3 or 6 characters long.
83
+ :returns: A tuple representing the RGB color as (red, green, blue), with each value ranging from 0.0 to 1.0.
84
+ """
85
+ hex_color = hex_color.lstrip('#')
86
+ # Expand shorthand form (e.g. "158" to "115588")
87
+ if len(hex_color) == 3:
88
+ hex_color = ''.join([c * 2 for c in hex_color])
239
89
 
240
- # Handle multiline text (for context element).
241
- lines = text.split('\n')
242
- for line_number, line in enumerate(lines):
243
- text_width = pdf_canvas.stringWidth(line) + (char_space * max(0, len(line) - 1))
244
- line_x = (template_width - text_width) / 2
245
- line_y = y - (line_number * size * line_height)
246
- pdf_canvas.drawString(line_x, line_y, line, charSpace=char_space)
247
-
248
-
249
- def _write_text_on_template(
250
- template: PageObject,
251
- username: str,
252
- context_name: str,
253
- issue_date: str,
254
- options: dict[str, Any],
255
- ) -> Canvas:
256
- """
257
- Prepare a new canvas and write text elements onto it.
90
+ # noinspection PyTypeChecker
91
+ return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
258
92
 
259
- :param template: PDF template.
260
- :param username: The name of the user to generate the credential for.
261
- :param context_name: The name of the learning context.
262
- :param issue_date: The formatted issue date string.
263
- :param options: A dictionary documented in the ``generate_pdf_credential`` function.
264
- :returns: A canvas with written data.
265
- """
266
93
  template_width, template_height = template.mediabox[2:]
267
94
  pdf_canvas = Canvas(io.BytesIO(), pagesize=(template_width, template_height))
95
+ font = _register_font(options.get('font')) or 'Helvetica'
96
+
97
+ # Write the learner name.
98
+ name_font = _register_font(options.get('name_font')) or font
99
+ pdf_canvas.setFont(name_font, options.get('name_size', 32))
100
+ name_color = options.get('name_color', '#000')
101
+ pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
102
+
103
+ name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
104
+ name_y = options.get('name_y', 290)
105
+ pdf_canvas.drawString(name_x, name_y, username)
106
+
107
+ # Write the learning context name.
108
+ context_name_font = _register_font(options.get('context_name_font')) or font
109
+ pdf_canvas.setFont(context_name_font, options.get('context_name_size', 28))
110
+ context_name_color = options.get('context_name_color', '#000')
111
+ pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
112
+
113
+ context_name_y = options.get('context_name_y', 220)
114
+ context_name_line_height = 28 * 1.1
115
+
116
+ # Split the learning context name into lines and write each of them in the center of the template.
117
+ for line_number, line in enumerate(context_name.split('\n')):
118
+ line_x = (template_width - pdf_canvas.stringWidth(line)) / 2
119
+ line_y = context_name_y - (line_number * context_name_line_height)
120
+ pdf_canvas.drawString(line_x, line_y, line)
121
+
122
+ # Write the issue date.
123
+ issue_date = get_localized_credential_date()
124
+ issue_date_font = _register_font(options.get('issue_date_font')) or font
125
+ pdf_canvas.setFont(issue_date_font, options.get('issue_date_size', 12))
126
+ issue_date_color = options.get('issue_date_color', '#000')
127
+ pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
268
128
 
269
- # Build placeholder values.
270
- placeholders = {
271
- 'name': username,
272
- 'context_name': context_name,
273
- 'issue_date': issue_date,
274
- }
275
-
276
- # Build and render text elements.
277
- elements = _build_text_elements(options)
278
-
279
- for config in elements.values():
280
- _render_text_element(pdf_canvas, template_width, config, placeholders)
129
+ issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
130
+ issue_date_y = options.get('issue_date_y', 120)
131
+ pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
281
132
 
282
133
  return pdf_canvas
283
134
 
@@ -326,7 +177,7 @@ def generate_pdf_credential(
326
177
  credential_uuid: UUID,
327
178
  options: dict[str, Any],
328
179
  ) -> str:
329
- r"""
180
+ """
330
181
  Generate a PDF credential.
331
182
 
332
183
  :param learning_context_key: The ID of the course or learning path the credential is for.
@@ -336,66 +187,37 @@ def generate_pdf_credential(
336
187
  :returns: The URL of the saved credential.
337
188
 
338
189
  Options:
339
-
340
- - template (required): The slug of the PDF template asset.
341
- - template_multiline: Alternative template for multiline context names (when using '\n').
342
- - defaults: Global defaults for all text elements.
343
- - font: Font name (asset slug). Default: Helvetica.
344
- - color: Hex color code. Default: #000.
345
- - size: Font size in points. Default: 12.
346
- - char_space: Character spacing. Default: 0.
347
- - uppercase: Convert text to uppercase. Default: false.
348
- - line_height: Line height multiplier for multiline text. Default: 1.1.
349
- - text_elements: Configuration for text elements. Standard elements (name, context, date) have
350
- defaults and render automatically. Set to false to hide.
351
- Custom elements require 'text' and 'y' properties.
352
- Element properties:
353
- - text: Text content with {placeholder} substitution. Available: {name}, {context_name}, {issue_date}.
354
- - y: Vertical position (PDF coordinates from bottom).
355
- - size: Font size (inherited from defaults.size).
356
- - font: Font name (inherited from defaults.font).
357
- - color: Hex color (inherited from defaults.color).
358
- - char_space: Character spacing (inherited from defaults.char_space).
359
- - uppercase: Convert text to uppercase (inherited from defaults.uppercase).
360
- - line_height: Line height multiplier for multiline text (inherited from defaults.line_height).
361
-
362
- Example::
363
-
364
- {
365
- "template": "certificate-template",
366
- "defaults": {"font": "CustomFont", "color": "#333"},
367
- "text_elements": {
368
- "name": {"y": 300, "uppercase": true},
369
- "context": {"text": "Custom Course Name"},
370
- "date": false,
371
- "award_line": {"text": "Awarded on {issue_date}", "y": 140, "size": 14}
372
- }
373
- }
190
+ - template: The path to the PDF template file.
191
+ - template_two_lines: The path to the PDF template file for two-line context names.
192
+ A two-line context name is specified by using a semicolon as a separator.
193
+ - font: The name of the font to use. The default font is Helvetica.
194
+ - name_y: The Y coordinate of the name on the credential (vertical position on the template).
195
+ - name_color: The color of the name on the credential (hexadecimal color code).
196
+ - name_size: The font size of the name on the credential. The default value is 32.
197
+ - name_font: The font of the name on the credential. It overrides the `font` option.
198
+ - context_name: Specify the custom course or Learning Path name. If not provided, it will be retrieved
199
+ automatically from the "cert_name_long" or "display_name" fields for courses, or from the Learning Path model.
200
+ - context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
201
+ - context_name_color: The color of the context name on the credential (hexadecimal color code).
202
+ - context_name_size: The font size of the context name on the credential. The default value is 28.
203
+ - context_name_font: The font of the context name on the credential. It overrides the `font` option.
204
+ - issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
205
+ - issue_date_color: The color of the issue date on the credential (hexadecimal color code).
206
+ - issue_date_size: The font size of the issue date on the credential. The default value is 12.
207
+ - issue_date_font: The font of the issue date on the credential. It overrides the `font` option.
374
208
  """
375
209
  log.info("Starting credential generation for user %s", user.id)
376
210
 
377
211
  username = _get_user_name(user)
378
-
379
- # Handle multiline context name.
380
- context_name = get_learning_context_name(learning_context_key)
381
- custom_context_name = ''
382
- custom_context_text_element = options.get('text_elements', {}).get('context', {})
383
- if isinstance(custom_context_text_element, dict):
384
- custom_context_name = custom_context_text_element.get('text', '')
385
-
386
- template_path = options.get('template')
387
- if '\n' in context_name or '\n' in custom_context_name:
388
- template_path = options.get('template_multiline', template_path)
389
-
390
- if not template_path:
391
- msg = "Template path must be specified in options."
392
- raise ValueError(msg)
212
+ context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
393
213
 
394
214
  # Get template from the CredentialAsset.
395
- template_file = CredentialAsset.get_asset_by_slug(template_path)
396
-
397
- # Get the issue date.
398
- issue_date = get_localized_credential_date()
215
+ # HACK: We support two-line strings by using a semicolon as a separator.
216
+ if ';' in context_name and (template_path := options.get('template_two_lines')):
217
+ template_file = CredentialAsset.get_asset_by_slug(template_path)
218
+ context_name = context_name.replace(';', '\n')
219
+ else:
220
+ template_file = CredentialAsset.get_asset_by_slug(options['template'])
399
221
 
400
222
  # Load the PDF template.
401
223
  with template_file.open('rb') as template_file:
@@ -403,8 +225,8 @@ def generate_pdf_credential(
403
225
 
404
226
  credential = PdfWriter()
405
227
 
406
- # Create a new canvas, prepare the page and write the data.
407
- pdf_canvas = _write_text_on_template(template, username, context_name, issue_date, options)
228
+ # Create a new canvas, prepare the page and write the data
229
+ pdf_canvas = _write_text_on_template(template, username, context_name, options)
408
230
 
409
231
  overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
410
232
  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,7 +4,7 @@ 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
10
  from typing import TYPE_CHECKING, Self
@@ -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,25 +34,6 @@ if TYPE_CHECKING: # pragma: no cover
33
34
  log = logging.getLogger(__name__)
34
35
 
35
36
 
36
- def _deep_merge(base: dict, override: dict) -> dict:
37
- """
38
- Deep merge two dictionaries.
39
-
40
- Values from `override` take precedence. Nested dictionaries are merged recursively.
41
-
42
- :param base: The base dictionary.
43
- :param override: The dictionary with overriding values.
44
- :return: A new dictionary with merged values.
45
- """
46
- result = base.copy()
47
- for key, value in override.items():
48
- if key in result and isinstance(result[key], dict) and isinstance(value, dict):
49
- result[key] = _deep_merge(result[key], value)
50
- else:
51
- result[key] = value
52
- return result
53
-
54
-
55
37
  class CredentialType(TimeStampedModel):
56
38
  """
57
39
  Model to store global credential configurations for each type.
@@ -147,8 +129,9 @@ class CredentialConfiguration(TimeStampedModel):
147
129
  self.periodic_task.args = json.dumps([self.id])
148
130
  self.periodic_task.save()
149
131
 
132
+ # Replace the return type with `QuerySet[Self]` after migrating to Python 3.10+.
150
133
  @classmethod
151
- def get_enabled_configurations(cls) -> QuerySet[Self]:
134
+ def get_enabled_configurations(cls) -> QuerySet[CredentialConfiguration]:
152
135
  """
153
136
  Get the list of enabled configurations.
154
137
 
@@ -194,10 +177,10 @@ class CredentialConfiguration(TimeStampedModel):
194
177
  module = import_module(module_path)
195
178
  func = getattr(module, func_name)
196
179
 
197
- custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
180
+ custom_options = {**self.credential_type.custom_options, **self.custom_options}
198
181
  return func(self.learning_context_key, custom_options)
199
182
 
200
- 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:
201
184
  """
202
185
  Celery task for processing a single user's credential.
203
186
 
@@ -208,19 +191,24 @@ class CredentialConfiguration(TimeStampedModel):
208
191
  Args:
209
192
  user_id: The ID of the user to process the credential for.
210
193
  celery_task_id (optional): The ID of the Celery task that is running this function.
194
+
195
+ Returns:
196
+ The generated Credential object.
211
197
  """
212
198
  user = get_user_model().objects.get(id=user_id)
213
199
  # Use the name from the profile if it is not empty. Otherwise, use the first and last name.
214
200
  # We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
215
201
  user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
216
- custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
202
+ learning_context_name = get_learning_context_name(self.learning_context_key)
203
+ custom_options = {**self.credential_type.custom_options, **self.custom_options}
217
204
 
218
- credential, _ = Credential.objects.update_or_create(
205
+ credential, _ = Credential.objects.exclude(status=Credential.Status.INVALIDATED).update_or_create(
219
206
  user_id=user_id,
220
207
  learning_context_key=self.learning_context_key,
221
208
  credential_type=self.credential_type.name,
222
209
  defaults={
223
210
  'user_full_name': user_full_name,
211
+ 'learning_context_name': learning_context_name,
224
212
  'status': Credential.Status.GENERATING,
225
213
  'generation_task_id': celery_task_id,
226
214
  },
@@ -246,6 +234,8 @@ class CredentialConfiguration(TimeStampedModel):
246
234
  if user.is_active and user.has_usable_password():
247
235
  credential.send_email()
248
236
 
237
+ return credential
238
+
249
239
 
250
240
  # noinspection PyUnusedLocal
251
241
  @receiver(post_delete, sender=CredentialConfiguration)
@@ -280,16 +270,33 @@ class Credential(TimeStampedModel):
280
270
 
281
271
  uuid = models.UUIDField(
282
272
  primary_key=True,
283
- default=uuid.uuid4,
273
+ default=uuid_lib.uuid4,
284
274
  editable=False,
285
275
  help_text=_('Auto-generated UUID of the credential'),
286
276
  )
277
+ verify_uuid = models.UUIDField(
278
+ default=uuid_lib.uuid4,
279
+ editable=False,
280
+ help_text=_('UUID used for verifying the credential'),
281
+ )
287
282
  user_id = models.IntegerField(help_text=_('ID of the user receiving the credential'))
288
- 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
+ )
289
288
  learning_context_key = LearningContextKeyField(
290
289
  max_length=255,
291
290
  help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
292
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
+ )
293
300
  credential_type = models.CharField(max_length=255, help_text=_('Type of the credential'))
294
301
  status = models.CharField(
295
302
  max_length=32,
@@ -300,16 +307,26 @@ class Credential(TimeStampedModel):
300
307
  download_url = models.URLField(blank=True, help_text=_('URL of the generated credential PDF (e.g., to S3)'))
301
308
  legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the credential imported from another system'))
302
309
  generation_task_id = models.CharField(max_length=255, help_text=_('Task ID from the Celery queue'))
303
-
304
- class Meta: # noqa: D106
305
- 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
+ )
306
316
 
307
317
  def __str__(self): # noqa: D105
308
318
  return f"{self.credential_type} for {self.user_full_name} in {self.learning_context_key}"
309
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
+
310
328
  def send_email(self):
311
329
  """Send a credential link to the student."""
312
- learning_context_name = get_learning_context_name(self.learning_context_key)
313
330
  user = get_user_model().objects.get(id=self.user_id)
314
331
  msg = Message(
315
332
  name="certificate_generated",
@@ -318,12 +335,26 @@ class Credential(TimeStampedModel):
318
335
  language='en',
319
336
  context={
320
337
  'certificate_link': self.download_url,
321
- 'course_name': learning_context_name,
338
+ 'course_name': self.learning_context_name,
322
339
  'platform_name': settings.PLATFORM_NAME,
323
340
  },
324
341
  )
325
342
  ace.send(msg)
326
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
+
327
358
 
328
359
  class CredentialAsset(TimeStampedModel):
329
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.4.0
3
+ Version: 0.4.0rc2
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,7 +11,6 @@ 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
15
14
  Classifier: Intended Audience :: Developers
16
15
  Classifier: Natural Language :: English
17
16
  Classifier: Programming Language :: Python :: 3
@@ -177,38 +176,16 @@ Unreleased
177
176
 
178
177
  *
179
178
 
180
- 0.4.0 - 2026-01-28
179
+ 0.4.0 - 2025-11-03
181
180
  ******************
182
181
 
183
182
  Added
184
183
  =====
185
184
 
186
- * New ``text_elements`` format for PDF credential generation with flexible text positioning and placeholder support.
187
- * Support for custom text elements with ``{name}``, ``{context_name}``, and ``{issue_date}`` placeholders.
188
- * Global ``defaults`` configuration for font, color, and character spacing.
189
-
190
- Modified
191
- ========
192
-
193
- * Migrated generator options from flat format (``name_y``, ``context_name_color``, etc.) to structured ``text_elements`` format.
194
-
195
- 0.3.1 - 2025-12-15
196
- ******************
197
-
198
- Added
199
- =====
200
-
201
- * Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings).
185
+ * Frontend form and backend API endpoint for verifying credentials.
186
+ * Option to invalidate issued credentials.
187
+ * Support for defining the course name using the `cert_name_long` field (in Studio's Advanced Settings).
202
188
  * Support for specifying individual fonts for PDF text elements.
203
- * Support for \n in learning context names in PDF certificates.
204
- * Options for uppercase name and issue date in PDF certificates.
205
- * Option for defining character spacing for issue date in PDF certificates.
206
- * Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
207
-
208
- Modified
209
- ========
210
-
211
- * Replaced ``template_two_lines`` with ``template_multiline``.
212
189
 
213
190
  0.3.0 - 2025-09-17
214
191
  ******************
@@ -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
4
  learning_credentials/compat.py,sha256=bTAB6bTh99ZyhUqOsDtM_BuIPzFxCjySFtfvc-_fCd4,4731
5
5
  learning_credentials/exceptions.py,sha256=UaqBVXFMWR2Iob7_LMb3j4NNVmWQFAgLi_MNMRUvGsI,290
6
- learning_credentials/generators.py,sha256=gaw3zoEzVhSjo96QM6Q6K70e_iK44LxCuQIQ05p7lP0,14895
7
- learning_credentials/models.py,sha256=Nltf7cN6z2lUQM1L9eh4QRC8RSz3u59-Spf2_piAC1M,16581
6
+ learning_credentials/generators.py,sha256=N2h7w_8sO2q-xPOm5sF75h1R8EDz1Wc0urIeUB4BRn4,10218
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,20 +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
24
- learning_credentials/migrations/0007_migrate_to_text_elements_format.py,sha256=_olkaxPPuRys2c2X5fnyQIFVvqEfdoYu-JlApmXuHEM,4758
25
+ learning_credentials/migrations/0007_validation.py,sha256=3vzjwDDFlwtFWmaCQ1wYfyl7Jj-cJB5Jpfq3cGo_vuI,3391
25
26
  learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
27
  learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
27
28
  learning_credentials/settings/common.py,sha256=Cck-nyFt11G1NLiz-bHfKJp8MV6sDZGqTwdbC8_1WE0,360
28
29
  learning_credentials/settings/production.py,sha256=6P0P7JxbpWNsk4Lk8lfyxHirOWMgU4UWOb3EYKLjiVQ,542
29
30
  learning_credentials/templates/learning_credentials/base.html,sha256=wtjBYqfHmOnyEY5tN3VGOmzYLsOD24MXdEUhTZ7OmwI,662
31
+ learning_credentials/templates/learning_credentials/verify.html,sha256=vXTiZMZkTLhp6cnqKBJcQnDu7qUxLvrTzN2m1KM8_9k,3414
30
32
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html,sha256=t-i1Ra9AC4pX-rPRifDJIvBBZuxCxdrFqg1NKTjHBOk,813
31
33
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt,sha256=IF_x8aF_-dORlQB-RCh0IkJDl2ktD489E8qGgLe9M3Y,677
32
34
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
33
35
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
36
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt,sha256=S7Hc5T_sZSsSBXm5_H5HBNNv16Ohl0oZn0nVqqeWL0g,132
35
- learning_credentials-0.4.0.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
36
- learning_credentials-0.4.0.dist-info/METADATA,sha256=bmCj26iPdnz7Q0y_u9wCrmUz6QgUtZOxGgkn_42WPYE,8294
37
- learning_credentials-0.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
38
- learning_credentials-0.4.0.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
39
- learning_credentials-0.4.0.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
40
- learning_credentials-0.4.0.dist-info/RECORD,,
37
+ learning_credentials-0.4.0rc2.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
38
+ learning_credentials-0.4.0rc2.dist-info/METADATA,sha256=8nq8F2ETB4wO-cvMC0O2aJYN4_0X-JluJ1AZw7p7GeM,7468
39
+ learning_credentials-0.4.0rc2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
40
+ learning_credentials-0.4.0rc2.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
41
+ learning_credentials-0.4.0rc2.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
42
+ learning_credentials-0.4.0rc2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.2)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,138 +0,0 @@
1
- """Migration to convert credential options from flat format to text_elements format."""
2
-
3
- from django.db import migrations
4
-
5
- # Mapping from old option names to new text_elements structure.
6
- # Format: (old_key, element_key, property_name)
7
- _OPTION_MAPPINGS = [
8
- # Name element mappings.
9
- ('name_y', 'name', 'y'),
10
- ('name_color', 'name', 'color'),
11
- ('name_size', 'name', 'size'),
12
- ('name_font', 'name', 'font'),
13
- ('name_uppercase', 'name', 'uppercase'),
14
- # Context element mappings.
15
- ('context_name', 'context', 'text'),
16
- ('context_name_y', 'context', 'y'),
17
- ('context_name_color', 'context', 'color'),
18
- ('context_name_size', 'context', 'size'),
19
- ('context_name_font', 'context', 'font'),
20
- # Date element mappings.
21
- ('issue_date_y', 'date', 'y'),
22
- ('issue_date_color', 'date', 'color'),
23
- ('issue_date_size', 'date', 'size'),
24
- ('issue_date_font', 'date', 'font'),
25
- ('issue_date_char_space', 'date', 'char_space'),
26
- ('issue_date_uppercase', 'date', 'uppercase'),
27
- ]
28
-
29
-
30
- def _convert_to_text_elements(options):
31
- """
32
- Convert old flat options format to new text_elements format in-place.
33
-
34
- :param options: The options dictionary to convert.
35
- """
36
- if not options:
37
- return
38
-
39
- # If already in new format, skip conversion.
40
- if 'text_elements' in options or 'defaults' in options:
41
- return
42
-
43
- text_elements = {}
44
-
45
- # Handle template_two_lines -> template_multiline rename.
46
- if 'template_two_lines' in options:
47
- template_two_lines = options.pop('template_two_lines')
48
- # Only set template_multiline if it doesn't already exist.
49
- if 'template_multiline' not in options:
50
- options['template_multiline'] = template_two_lines
51
-
52
- # Handle global font -> defaults.font.
53
- if 'font' in options:
54
- options['defaults'] = {'font': options.pop('font')}
55
-
56
- # Convert element-specific options by popping them from the options dict.
57
- for old_key, element_key, prop_name in _OPTION_MAPPINGS:
58
- if old_key in options:
59
- if element_key not in text_elements:
60
- text_elements[element_key] = {}
61
- text_elements[element_key][prop_name] = options.pop(old_key)
62
-
63
- # Only add text_elements if we have any.
64
- if text_elements:
65
- options['text_elements'] = text_elements
66
-
67
-
68
- def _convert_to_flat_format(options):
69
- """
70
- Convert new text_elements format back to old flat options format in-place.
71
-
72
- :param options: The options dictionary to convert.
73
- """
74
- if not options:
75
- return
76
-
77
- # If not in new format, skip conversion.
78
- if 'text_elements' not in options and 'defaults' not in options:
79
- return
80
-
81
- # Handle template_multiline -> template_two_lines for backward compatibility.
82
- if 'template_multiline' in options:
83
- options['template_two_lines'] = options.pop('template_multiline')
84
-
85
- # Handle defaults.font -> font.
86
- defaults = options.pop('defaults', {})
87
- if 'font' in defaults:
88
- options['font'] = defaults['font']
89
-
90
- # Convert text_elements back to flat format.
91
- text_elements = options.pop('text_elements', {})
92
-
93
- for old_key, element_key, prop_name in _OPTION_MAPPINGS:
94
- element_config = text_elements.get(element_key, {})
95
- if isinstance(element_config, dict) and prop_name in element_config:
96
- options[old_key] = element_config[prop_name]
97
-
98
-
99
- def _migrate_all_options(apps, convert_func):
100
- """
101
- Apply a conversion function to all credential configurations.
102
-
103
- :param apps: Django apps registry.
104
- :param convert_func: Function to apply to each custom_options dict.
105
- """
106
- CredentialType = apps.get_model('learning_credentials', 'CredentialType')
107
- CredentialConfiguration = apps.get_model('learning_credentials', 'CredentialConfiguration')
108
-
109
- for credential_type in CredentialType.objects.all():
110
- if credential_type.custom_options:
111
- convert_func(credential_type.custom_options)
112
- credential_type.save()
113
-
114
- for config in CredentialConfiguration.objects.all():
115
- if config.custom_options:
116
- convert_func(config.custom_options)
117
- config.save()
118
-
119
-
120
- def _migrate_forward(apps, schema_editor):
121
- """Convert all credential configurations to the new text_elements format."""
122
- _migrate_all_options(apps, _convert_to_text_elements)
123
-
124
-
125
- def _migrate_backward(apps, schema_editor):
126
- """Convert all credential configurations back to the old flat format."""
127
- _migrate_all_options(apps, _convert_to_flat_format)
128
-
129
-
130
- class Migration(migrations.Migration):
131
-
132
- dependencies = [
133
- ('learning_credentials', '0006_cleanup_openedx_certificates_tables'),
134
- ]
135
-
136
- operations = [
137
- migrations.RunPython(_migrate_forward, _migrate_backward),
138
- ]