learning-credentials 0.3.1rc2__py3-none-any.whl → 0.4.0__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.
@@ -9,8 +9,10 @@ We will move this module to an external repository (a plugin).
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import copy
12
13
  import io
13
14
  import logging
15
+ import re
14
16
  import secrets
15
17
  from typing import TYPE_CHECKING, Any
16
18
 
@@ -24,6 +26,7 @@ from reportlab.pdfbase.ttfonts import TTFError, TTFont
24
26
  from reportlab.pdfgen.canvas import Canvas
25
27
 
26
28
  from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
29
+ from .exceptions import AssetNotFoundError
27
30
  from .models import CredentialAsset
28
31
 
29
32
  log = logging.getLogger(__name__)
@@ -36,6 +39,48 @@ if TYPE_CHECKING: # pragma: no cover
36
39
  from pypdf import PageObject
37
40
 
38
41
 
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
+
39
84
  def _get_user_name(user: User) -> str:
40
85
  """
41
86
  Retrieve the user's name.
@@ -46,102 +91,193 @@ def _get_user_name(user: User) -> str:
46
91
  return user.profile.name or f"{user.first_name} {user.last_name}"
47
92
 
48
93
 
49
- def _register_font(font_name: str) -> str | None:
94
+ def _register_font(pdf_canvas: Canvas, font_name: str) -> str:
50
95
  """
51
- Register a custom font if specified in options. If not specified, use the default font (Helvetica).
96
+ Register a custom font if not already available.
97
+
98
+ Built-in fonts (like Helvetica) are already available and don't need registration.
99
+ Custom fonts are loaded from CredentialAsset.
52
100
 
101
+ :param pdf_canvas: The canvas to check available fonts on.
53
102
  :param font_name: The name of the font to register.
54
- :returns: The font name if registered successfully, otherwise None.
103
+ :returns: The font name if available, otherwise use 'Helvetica' as fallback.
55
104
  """
56
- if not font_name:
57
- return None
105
+ # Check if font is already available (built-in or previously registered).
106
+ if font_name in pdf_canvas.getAvailableFonts():
107
+ return font_name
58
108
 
59
109
  try:
60
110
  registerFont(TTFont(font_name, CredentialAsset.get_asset_by_slug(font_name)))
111
+ except AssetNotFoundError:
112
+ log.warning("Font asset not found: %s", font_name)
61
113
  except (FontError, FontNotFoundError, TTFError):
62
114
  log.exception("Error registering font %s", font_name)
63
115
  else:
64
116
  return font_name
65
117
 
118
+ return 'Helvetica'
119
+
66
120
 
67
- def _write_text_on_template(template: PageObject, username: str, context_name: str, options: dict[str, Any]) -> Canvas:
121
+ def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
68
122
  """
69
- Prepare a new canvas and write the user and course name onto it.
123
+ Convert a hexadecimal color code to an RGB tuple with floating-point values.
70
124
 
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.
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.
76
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])
77
132
 
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.
133
+ # noinspection PyTypeChecker
134
+ return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
81
135
 
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])
89
136
 
90
- # noinspection PyTypeChecker
91
- return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
137
+ def _substitute_placeholders(text: str, placeholders: dict[str, str]) -> str:
138
+ """
139
+ Substitute placeholders in text using {placeholder} syntax.
92
140
 
93
- 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'
141
+ Supports escaping with {{ for literal braces.
96
142
 
97
- # Write the learner name.
98
- if options.get('name_uppercase', getattr(settings, 'LEARNING_CREDENTIALS_NAME_UPPERCASE', False)):
99
- username = username.upper()
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
+ """
100
147
 
101
- name_font = _register_font(options.get('name_font')) or font
102
- pdf_canvas.setFont(name_font, options.get('name_size', 32))
103
- name_color = options.get('name_color', '#000')
104
- pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
148
+ def replace_placeholder(match: re.Match) -> str:
149
+ key = match.group(1)
150
+ return placeholders.get(key, match.group(0))
105
151
 
106
- name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
107
- name_y = options.get('name_y', 290)
152
+ # Use negative lookbehind to skip escaped braces ({{).
153
+ # Match {word} but not {{word}.
154
+ text = re.sub(r'(?<!\{)\{(\w+)\}', replace_placeholder, text)
108
155
 
109
- pdf_canvas.drawString(name_x, name_y, username)
156
+ # Replace escaped braces with literal braces.
157
+ return text.replace('{{', '{').replace('}}', '}')
110
158
 
111
- # 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))
114
- context_name_color = options.get('context_name_color', '#000')
115
- pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
116
159
 
117
- context_name_y = options.get('context_name_y', 220)
118
- context_name_line_height = 28 * 1.1
160
+ def _build_text_elements(options: dict[str, Any]) -> dict[str, dict[str, Any]]:
161
+ """
162
+ Build the final text elements configuration by merging defaults with user options.
119
163
 
120
- # Split the learning context name into lines and write each of them in the center of the template.
121
- for line_number, line in enumerate(context_name.split('\n')):
122
- line_x = (template_width - pdf_canvas.stringWidth(line)) / 2
123
- line_y = context_name_y - (line_number * context_name_line_height)
124
- pdf_canvas.drawString(line_x, line_y, line)
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'.
125
166
 
126
- # Write the issue date.
127
- 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()
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 = {}
130
174
 
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))
133
- issue_date_color = options.get('issue_date_color', '#000')
134
- pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
175
+ # Process standard elements (they have defaults).
176
+ for key, default_config in default_text_elements.items():
177
+ user_config = user_elements.get(key, {})
135
178
 
136
- issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
137
- issue_date_x += options.get('issue_date_x', 0)
138
- issue_date_y = options.get('issue_date_y', 120)
179
+ if user_config is False:
180
+ continue
139
181
 
140
- issue_date_char_space = options.get(
141
- 'issue_date_char_space', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_CHAR_SPACE', 0)
142
- )
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
143
190
 
144
- pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date, charSpace=issue_date_char_space)
191
+ # Skip disabled elements.
192
+ if user_config is False:
193
+ continue
194
+
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.
224
+ """
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
+
233
+ pdf_canvas.setFillColorRGB(*_hex_to_rgb(config['color']))
234
+
235
+ y = config['y']
236
+ char_space = config['char_space']
237
+ line_height = config['line_height']
238
+ size = config['size']
239
+
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.
258
+
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
+ template_width, template_height = template.mediabox[2:]
267
+ pdf_canvas = Canvas(io.BytesIO(), pagesize=(template_width, template_height))
268
+
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)
145
281
 
146
282
  return pdf_canvas
147
283
 
@@ -200,44 +336,56 @@ def generate_pdf_credential(
200
336
  :returns: The URL of the saved credential.
201
337
 
202
338
  Options:
203
- - 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.
207
- - name_y: The Y coordinate of the name on the credential (vertical position on the template).
208
- - name_color: The color of the name on the credential (hexadecimal color code).
209
- - 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.
215
- - context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
216
- - context_name_color: The color of the context name on the credential (hexadecimal color code).
217
- - 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
- - issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
222
- - 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.
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
+ }
229
374
  """
230
375
  log.info("Starting credential generation for user %s", user.id)
231
376
 
232
377
  username = _get_user_name(user)
233
- context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
234
- template_path = options.get('template')
235
378
 
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))
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)
241
389
 
242
390
  if not template_path:
243
391
  msg = "Template path must be specified in options."
@@ -246,14 +394,17 @@ def generate_pdf_credential(
246
394
  # Get template from the CredentialAsset.
247
395
  template_file = CredentialAsset.get_asset_by_slug(template_path)
248
396
 
397
+ # Get the issue date.
398
+ issue_date = get_localized_credential_date()
399
+
249
400
  # Load the PDF template.
250
401
  with template_file.open('rb') as template_file:
251
402
  template = PdfReader(template_file).pages[0]
252
403
 
253
404
  credential = PdfWriter()
254
405
 
255
- # Create a new canvas, prepare the page and write the data
256
- pdf_canvas = _write_text_on_template(template, username, context_name, options)
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)
257
408
 
258
409
  overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
259
410
  template.merge_page(overlay_pdf.pages[0])
@@ -0,0 +1,138 @@
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
+ ]
@@ -7,7 +7,7 @@ import logging
7
7
  import uuid
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
@@ -33,6 +33,25 @@ if TYPE_CHECKING: # pragma: no cover
33
33
  log = logging.getLogger(__name__)
34
34
 
35
35
 
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
+
36
55
  class CredentialType(TimeStampedModel):
37
56
  """
38
57
  Model to store global credential configurations for each type.
@@ -128,9 +147,8 @@ class CredentialConfiguration(TimeStampedModel):
128
147
  self.periodic_task.args = json.dumps([self.id])
129
148
  self.periodic_task.save()
130
149
 
131
- # Replace the return type with `QuerySet[Self]` after migrating to Python 3.10+.
132
150
  @classmethod
133
- def get_enabled_configurations(cls) -> QuerySet[CredentialConfiguration]:
151
+ def get_enabled_configurations(cls) -> QuerySet[Self]:
134
152
  """
135
153
  Get the list of enabled configurations.
136
154
 
@@ -176,7 +194,7 @@ class CredentialConfiguration(TimeStampedModel):
176
194
  module = import_module(module_path)
177
195
  func = getattr(module, func_name)
178
196
 
179
- custom_options = {**self.credential_type.custom_options, **self.custom_options}
197
+ custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
180
198
  return func(self.learning_context_key, custom_options)
181
199
 
182
200
  def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
@@ -195,7 +213,7 @@ class CredentialConfiguration(TimeStampedModel):
195
213
  # Use the name from the profile if it is not empty. Otherwise, use the first and last name.
196
214
  # We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
197
215
  user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
198
- custom_options = {**self.credential_type.custom_options, **self.custom_options}
216
+ custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
199
217
 
200
218
  credential, _ = Credential.objects.update_or_create(
201
219
  user_id=user_id,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.3.1rc2
3
+ Version: 0.4.0
4
4
  Summary: A pluggable service for preparing Open edX credentials.
5
5
  Author-email: OpenCraft <help@opencraft.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -11,6 +11,7 @@ Keywords: Python,edx,credentials,django
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Framework :: Django
13
13
  Classifier: Framework :: Django :: 4.2
14
+ Classifier: Framework :: Django :: 5.2
14
15
  Classifier: Intended Audience :: Developers
15
16
  Classifier: Natural Language :: English
16
17
  Classifier: Programming Language :: Python :: 3
@@ -176,6 +177,21 @@ Unreleased
176
177
 
177
178
  *
178
179
 
180
+ 0.4.0 - 2026-01-28
181
+ ******************
182
+
183
+ Added
184
+ =====
185
+
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
+
179
195
  0.3.1 - 2025-12-15
180
196
  ******************
181
197
 
@@ -3,8 +3,8 @@ learning_credentials/admin.py,sha256=ynK3tVJwLsIeV7Jk66t1FAVyVsU1G-KRIAdRkycVTmA
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=Ya0TbBD0-T0AWmp6VMQTtBmtgi2obs78vYX38SUmQX8,11696
7
- learning_credentials/models.py,sha256=J_SCNiu42yhdi12eDMLsxNCTkJK7_vqneQjyGYG5KJ4,16048
6
+ learning_credentials/generators.py,sha256=gaw3zoEzVhSjo96QM6Q6K70e_iK44LxCuQIQ05p7lP0,14895
7
+ learning_credentials/models.py,sha256=Nltf7cN6z2lUQM1L9eh4QRC8RSz3u59-Spf2_piAC1M,16581
8
8
  learning_credentials/processors.py,sha256=LkdjmkLBnXc9qeMcksB1T8AQ5ZhYaECyQO__KfHB_aU,15212
9
9
  learning_credentials/tasks.py,sha256=byoFEUvN_ayVaU5K5SlEiA7vu9BRPaSSmKnB9g5toec,1927
10
10
  learning_credentials/urls.py,sha256=gO_c930rzMylP-riQ9SGHXH9JIMF7ajySDT2Tc-E8x4,188
@@ -21,6 +21,7 @@ learning_credentials/migrations/0003_rename_certificates_to_credentials.py,sha25
21
21
  learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py,sha256=5KaXvASl69qbEaHX5_Ty_3Dr7K4WV6p8VWOx72yJnTU,1919
22
22
  learning_credentials/migrations/0005_rename_processors_and_generators.py,sha256=5UCqjq-CBJnRo1qBAoWs91ngyEuSMN8_tQtfzsuR5SI,5271
23
23
  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
24
25
  learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
26
  learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
26
27
  learning_credentials/settings/common.py,sha256=Cck-nyFt11G1NLiz-bHfKJp8MV6sDZGqTwdbC8_1WE0,360
@@ -31,9 +32,9 @@ learning_credentials/templates/learning_credentials/edx_ace/certificate_generate
31
32
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
32
33
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
34
  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,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5