learning-credentials 0.4.0rc2__tar.gz → 0.4.1rc1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/CHANGELOG.rst +26 -4
  2. {learning_credentials-0.4.0rc2/learning_credentials.egg-info → learning_credentials-0.4.1rc1}/PKG-INFO +27 -5
  3. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/admin.py +3 -30
  4. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/api/v1/urls.py +1 -2
  5. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/api/v1/views.py +1 -61
  6. learning_credentials-0.4.1rc1/learning_credentials/generators.py +412 -0
  7. learning_credentials-0.4.1rc1/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +138 -0
  8. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/models.py +11 -60
  9. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/urls.py +0 -2
  10. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1/learning_credentials.egg-info}/PKG-INFO +27 -5
  11. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials.egg-info/SOURCES.txt +2 -3
  12. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/pyproject.toml +5 -4
  13. learning_credentials-0.4.1rc1/tests/test_generators.py +559 -0
  14. learning_credentials-0.4.1rc1/tests/test_migrations.py +269 -0
  15. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/tests/test_models.py +21 -11
  16. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/tests/test_processors.py +1 -1
  17. learning_credentials-0.4.0rc2/learning_credentials/api/v1/serializers.py +0 -13
  18. learning_credentials-0.4.0rc2/learning_credentials/generators.py +0 -238
  19. learning_credentials-0.4.0rc2/learning_credentials/migrations/0007_validation.py +0 -94
  20. learning_credentials-0.4.0rc2/learning_credentials/templates/learning_credentials/verify.html +0 -83
  21. learning_credentials-0.4.0rc2/tests/test_generators.py +0 -294
  22. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/LICENSE.txt +0 -0
  23. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/MANIFEST.in +0 -0
  24. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/README.rst +0 -0
  25. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/__init__.py +0 -0
  26. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/api/__init__.py +0 -0
  27. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/api/urls.py +0 -0
  28. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/api/v1/__init__.py +0 -0
  29. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/api/v1/permissions.py +0 -0
  30. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/apps.py +0 -0
  31. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/compat.py +0 -0
  32. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/conf/locale/config.yaml +0 -0
  33. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/exceptions.py +0 -0
  34. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/migrations/0001_initial.py +0 -0
  35. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
  36. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
  37. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
  38. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
  39. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
  40. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/migrations/__init__.py +0 -0
  41. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/processors.py +0 -0
  42. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/settings/__init__.py +0 -0
  43. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/settings/common.py +0 -0
  44. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/settings/production.py +0 -0
  45. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/tasks.py +0 -0
  46. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/templates/learning_credentials/base.html +0 -0
  47. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
  48. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
  49. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
  50. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
  51. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
  52. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials.egg-info/dependency_links.txt +0 -0
  53. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials.egg-info/entry_points.txt +0 -0
  54. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials.egg-info/requires.txt +0 -0
  55. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/learning_credentials.egg-info/top_level.txt +0 -0
  56. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/setup.cfg +0 -0
  57. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/tests/test_tasks.py +0 -0
  58. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.1rc1}/tests/test_views.py +0 -0
@@ -16,16 +16,38 @@ Unreleased
16
16
 
17
17
  *
18
18
 
19
- 0.4.0 - 2025-11-03
19
+ 0.4.1 - 2025-12-31
20
20
  ******************
21
21
 
22
22
  Added
23
23
  =====
24
24
 
25
- * Frontend form and backend API endpoint for verifying credentials.
26
- * Option to invalidate issued credentials.
27
- * Support for defining the course name using the `cert_name_long` field (in Studio's Advanced Settings).
25
+ * New ``text_elements`` format for PDF credential generation with flexible text positioning and placeholder support.
26
+ * Support for custom text elements with ``{name}``, ``{context_name}``, and ``{issue_date}`` placeholders.
27
+ * Global ``defaults`` configuration for font, color, and character spacing.
28
+
29
+ Modified
30
+ ========
31
+
32
+ * Migrated generator options from flat format (``name_y``, ``context_name_color``, etc.) to structured ``text_elements`` format.
33
+
34
+ 0.3.1 - 2025-12-15
35
+ ******************
36
+
37
+ Added
38
+ =====
39
+
40
+ * Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings).
28
41
  * Support for specifying individual fonts for PDF text elements.
42
+ * Support for \n in learning context names in PDF certificates.
43
+ * Options for uppercase name and issue date in PDF certificates.
44
+ * Option for defining character spacing for issue date in PDF certificates.
45
+ * Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
46
+
47
+ Modified
48
+ ========
49
+
50
+ * Replaced ``template_two_lines`` with ``template_multiline``.
29
51
 
30
52
  0.3.0 - 2025-09-17
31
53
  ******************
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.4.0rc2
3
+ Version: 0.4.1rc1
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,16 +176,38 @@ Unreleased
176
176
 
177
177
  *
178
178
 
179
- 0.4.0 - 2025-11-03
179
+ 0.4.1 - 2025-12-31
180
180
  ******************
181
181
 
182
182
  Added
183
183
  =====
184
184
 
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).
185
+ * New ``text_elements`` format for PDF credential generation with flexible text positioning and placeholder support.
186
+ * Support for custom text elements with ``{name}``, ``{context_name}``, and ``{issue_date}`` placeholders.
187
+ * Global ``defaults`` configuration for font, color, and character spacing.
188
+
189
+ Modified
190
+ ========
191
+
192
+ * Migrated generator options from flat format (``name_y``, ``context_name_color``, etc.) to structured ``text_elements`` format.
193
+
194
+ 0.3.1 - 2025-12-15
195
+ ******************
196
+
197
+ Added
198
+ =====
199
+
200
+ * Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings).
188
201
  * Support for specifying individual fonts for PDF text elements.
202
+ * Support for \n in learning context names in PDF certificates.
203
+ * Options for uppercase name and issue date in PDF certificates.
204
+ * Option for defining character spacing for issue date in PDF certificates.
205
+ * Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
206
+
207
+ Modified
208
+ ========
209
+
210
+ * Replaced ``template_two_lines`` with ``template_multiline``.
189
211
 
190
212
  0.3.0 - 2025-09-17
191
213
  ******************
@@ -7,9 +7,8 @@ import inspect
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  from django import forms
10
- from django.contrib import admin, messages
10
+ from django.contrib import admin
11
11
  from django.core.exceptions import ValidationError
12
- from django.urls import reverse
13
12
  from django.utils.html import format_html
14
13
  from django_object_actions import DjangoObjectActions, action
15
14
  from django_reverse_admin import ReverseModelAdmin
@@ -226,7 +225,7 @@ class CredentialConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
226
225
 
227
226
 
228
227
  @admin.register(Credential)
229
- class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
228
+ class CredentialAdmin(admin.ModelAdmin): # noqa: D101
230
229
  list_display = (
231
230
  'user_id',
232
231
  'user_full_name',
@@ -238,32 +237,19 @@ class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
238
237
  'modified',
239
238
  )
240
239
  readonly_fields = (
241
- 'uuid',
242
- 'verify_uuid',
243
240
  'user_id',
244
241
  'created',
245
242
  'modified',
246
243
  'user_full_name',
247
244
  'learning_context_key',
248
- 'learning_context_name',
249
245
  'credential_type',
250
246
  'status',
251
247
  'url',
252
248
  'legacy_id',
253
249
  'generation_task_id',
254
250
  )
255
- search_fields = ("learning_context_key", "user_id", "user_full_name", "uuid")
251
+ search_fields = ("learning_context_key", "user_id", "user_full_name")
256
252
  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
267
253
 
268
254
  def get_form(self, request: HttpRequest, obj: Credential | None = None, **kwargs) -> forms.ModelForm:
269
255
  """Hide the download_url field."""
@@ -277,16 +263,3 @@ class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
277
263
  if obj.download_url:
278
264
  return format_html("<a href='{url}'>{url}</a>", url=obj.download_url)
279
265
  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.")
@@ -2,7 +2,7 @@
2
2
 
3
3
  from django.urls import path
4
4
 
5
- from .views import CredentialConfigurationCheckView, CredentialMetadataView
5
+ from .views import CredentialConfigurationCheckView
6
6
 
7
7
  urlpatterns = [
8
8
  path(
@@ -10,5 +10,4 @@ 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'),
14
13
  ]
@@ -9,10 +9,9 @@ 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 Credential, CredentialConfiguration
12
+ from learning_credentials.models import CredentialConfiguration
13
13
 
14
14
  from .permissions import CanAccessLearningContext
15
- from .serializers import CredentialSerializer
16
15
 
17
16
  if TYPE_CHECKING:
18
17
  from rest_framework.request import Request
@@ -82,62 +81,3 @@ class CredentialConfigurationCheckView(APIView):
82
81
  }
83
82
 
84
83
  return Response(response_data, status=status.HTTP_200_OK)
85
-
86
-
87
- class CredentialMetadataView(APIView):
88
- """API view to retrieve credential metadata by UUID."""
89
-
90
- @apidocs.schema(
91
- parameters=[
92
- apidocs.string_parameter(
93
- "uuid",
94
- ParameterLocation.PATH,
95
- description="The UUID of the credential to retrieve.",
96
- ),
97
- ],
98
- responses={
99
- 200: "Successfully retrieved the credential metadata.",
100
- 404: "Credential not found or not valid.",
101
- },
102
- )
103
- def get(self, _request: "Request", uuid: str) -> Response:
104
- """
105
- Retrieve credential metadata by its UUID.
106
-
107
- **Example Request**
108
-
109
- ``GET /api/learning_credentials/v1/metadata/123e4567-e89b-12d3-a456-426614174000/``
110
-
111
- **Response Values**
112
-
113
- - **200 OK**: Successfully retrieved the credential metadata.
114
- - **404 Not Found**: Credential not found or not valid.
115
-
116
- **Example Response**
117
-
118
- .. code-block:: json
119
-
120
- {
121
- "user_full_name": "John Doe",
122
- "created": "2023-01-01",
123
- "learning_context_name": "Demo Course",
124
- "status": "available",
125
- "invalidation_reason": ""
126
- }
127
-
128
-
129
- {
130
- "user_full_name": "John Doe",
131
- "created": "2023-01-01",
132
- "learning_context_name": "Demo Course",
133
- "status": "invalidated",
134
- "invalidation_reason": "Reissued due to name change."
135
- }
136
- """
137
- try:
138
- credential = Credential.objects.get(verify_uuid=uuid)
139
- except Credential.DoesNotExist:
140
- return Response({'error': 'Credential not found.'}, status=status.HTTP_404_NOT_FOUND)
141
-
142
- serializer = CredentialSerializer(credential)
143
- return Response(serializer.data, status=status.HTTP_200_OK)
@@ -0,0 +1,412 @@
1
+ """
2
+ This module provides functions to generate credentials.
3
+
4
+ The functions prefixed with `generate_` are automatically detected by the admin page and are used to generate the
5
+ credentials for the users.
6
+
7
+ We will move this module to an external repository (a plugin).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import io
14
+ import logging
15
+ import re
16
+ import secrets
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from django.conf import settings
20
+ from django.core.files.base import ContentFile
21
+ from django.core.files.storage import FileSystemStorage, default_storage
22
+ from pypdf import PdfReader, PdfWriter
23
+ from pypdf.constants import UserAccessPermissions
24
+ from reportlab.pdfbase.pdfmetrics import FontError, FontNotFoundError, registerFont
25
+ from reportlab.pdfbase.ttfonts import TTFError, TTFont
26
+ from reportlab.pdfgen.canvas import Canvas
27
+
28
+ from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
29
+ from .exceptions import AssetNotFoundError
30
+ from .models import CredentialAsset
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ if TYPE_CHECKING: # pragma: no cover
35
+ from uuid import UUID
36
+
37
+ from django.contrib.auth.models import User
38
+ from opaque_keys.edx.keys import CourseKey
39
+ from pypdf import PageObject
40
+
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
+
84
+ def _get_user_name(user: User) -> str:
85
+ """
86
+ Retrieve the user's name.
87
+
88
+ :param user: The user to generate the credential for.
89
+ :return: Username.
90
+ """
91
+ return user.profile.name or f"{user.first_name} {user.last_name}"
92
+
93
+
94
+ def _register_font(pdf_canvas: Canvas, font_name: str) -> str:
95
+ """
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.
100
+
101
+ :param pdf_canvas: The canvas to check available fonts on.
102
+ :param font_name: The name of the font to register.
103
+ :returns: The font name if available, otherwise use 'Helvetica' as fallback.
104
+ """
105
+ # Check if font is already available (built-in or previously registered).
106
+ if font_name in pdf_canvas.getAvailableFonts():
107
+ return font_name
108
+
109
+ try:
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)
113
+ except (FontError, FontNotFoundError, TTFError):
114
+ log.exception("Error registering font %s", font_name)
115
+ else:
116
+ return font_name
117
+
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
+
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.
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
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)
281
+
282
+ return pdf_canvas
283
+
284
+
285
+ def _save_credential(credential: PdfWriter, credential_uuid: UUID) -> str:
286
+ """
287
+ Save the final PDF file to BytesIO and upload it using Django default storage.
288
+
289
+ :param credential: Pdf credential.
290
+ :param credential_uuid: The UUID of the credential.
291
+ :returns: The URL of the saved credential.
292
+ """
293
+ # Save the final PDF file to BytesIO.
294
+ output_path = f'external_certificates/{credential_uuid}.pdf'
295
+
296
+ view_print_extract_permission = (
297
+ UserAccessPermissions.PRINT
298
+ | UserAccessPermissions.PRINT_TO_REPRESENTATION
299
+ | UserAccessPermissions.EXTRACT_TEXT_AND_GRAPHICS
300
+ )
301
+ credential.encrypt('', secrets.token_hex(32), permissions_flag=view_print_extract_permission, algorithm='AES-256')
302
+
303
+ pdf_bytes = io.BytesIO()
304
+ credential.write(pdf_bytes)
305
+ pdf_bytes.seek(0) # Rewind to start.
306
+ # Upload with Django default storage.
307
+ credential_file = ContentFile(pdf_bytes.read())
308
+ # Delete the file if it already exists.
309
+ if default_storage.exists(output_path):
310
+ default_storage.delete(output_path)
311
+ default_storage.save(output_path, credential_file)
312
+ if isinstance(default_storage, FileSystemStorage):
313
+ url = f"{get_default_storage_url()}{output_path}"
314
+ else:
315
+ url = default_storage.url(output_path)
316
+
317
+ if custom_domain := getattr(settings, 'LEARNING_CREDENTIALS_CUSTOM_DOMAIN', None):
318
+ url = f"{custom_domain}/{credential_uuid}.pdf"
319
+
320
+ return url
321
+
322
+
323
+ def generate_pdf_credential(
324
+ learning_context_key: CourseKey,
325
+ user: User,
326
+ credential_uuid: UUID,
327
+ options: dict[str, Any],
328
+ ) -> str:
329
+ r"""
330
+ Generate a PDF credential.
331
+
332
+ :param learning_context_key: The ID of the course or learning path the credential is for.
333
+ :param user: The user to generate the credential for.
334
+ :param credential_uuid: The UUID of the credential to generate.
335
+ :param options: The custom options for the credential.
336
+ :returns: The URL of the saved credential.
337
+
338
+ Options:
339
+
340
+ - template (required): The slug of the PDF template asset.
341
+ - template_multiline: Alternative template for multiline context names (when using '\n' or ';').
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
+ }
374
+ """
375
+ log.info("Starting credential generation for user %s", user.id)
376
+
377
+ username = _get_user_name(user)
378
+ context_name = get_learning_context_name(learning_context_key)
379
+ template_path = options.get('template')
380
+
381
+ # Handle multiline context name (semicolon separator for backward compatibility).
382
+ context_name = context_name.replace(';', '\n').replace(r'\n', '\n')
383
+ if '\n' in context_name:
384
+ template_path = options.get('template_multiline', template_path)
385
+
386
+ if not template_path:
387
+ msg = "Template path must be specified in options."
388
+ raise ValueError(msg)
389
+
390
+ # Get template from the CredentialAsset.
391
+ template_file = CredentialAsset.get_asset_by_slug(template_path)
392
+
393
+ # Get the issue date.
394
+ issue_date = get_localized_credential_date()
395
+
396
+ # Load the PDF template.
397
+ with template_file.open('rb') as template_file:
398
+ template = PdfReader(template_file).pages[0]
399
+
400
+ credential = PdfWriter()
401
+
402
+ # Create a new canvas, prepare the page and write the data.
403
+ pdf_canvas = _write_text_on_template(template, username, context_name, issue_date, options)
404
+
405
+ overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
406
+ template.merge_page(overlay_pdf.pages[0])
407
+ credential.add_page(template)
408
+
409
+ url = _save_credential(credential, credential_uuid)
410
+
411
+ log.info("Credential saved to %s", url)
412
+ return url