learning-credentials 0.3.1rc2__tar.gz → 0.4.0__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.
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/CHANGELOG.rst +15 -0
- {learning_credentials-0.3.1rc2/learning_credentials.egg-info → learning_credentials-0.4.0}/PKG-INFO +17 -1
- learning_credentials-0.4.0/learning_credentials/generators.py +416 -0
- learning_credentials-0.4.0/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +138 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/models.py +23 -5
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0/learning_credentials.egg-info}/PKG-INFO +17 -1
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials.egg-info/SOURCES.txt +2 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/pyproject.toml +3 -6
- learning_credentials-0.4.0/tests/test_generators.py +571 -0
- learning_credentials-0.4.0/tests/test_migrations.py +250 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/tests/test_models.py +256 -14
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/tests/test_processors.py +1 -26
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/tests/test_views.py +24 -106
- learning_credentials-0.3.1rc2/learning_credentials/generators.py +0 -265
- learning_credentials-0.3.1rc2/tests/test_generators.py +0 -412
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/LICENSE.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/MANIFEST.in +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/README.rst +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/admin.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/api/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/api/urls.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/api/v1/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/api/v1/permissions.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/api/v1/urls.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/api/v1/views.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/apps.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/compat.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/conf/locale/config.yaml +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/exceptions.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/migrations/0001_initial.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/migrations/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/processors.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/settings/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/settings/common.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/settings/production.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/tasks.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/templates/learning_credentials/base.html +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials/urls.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials.egg-info/dependency_links.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials.egg-info/entry_points.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials.egg-info/requires.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/learning_credentials.egg-info/top_level.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/setup.cfg +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0}/tests/test_tasks.py +0 -0
|
@@ -16,6 +16,21 @@ Unreleased
|
|
|
16
16
|
|
|
17
17
|
*
|
|
18
18
|
|
|
19
|
+
0.4.0 - 2026-01-28
|
|
20
|
+
******************
|
|
21
|
+
|
|
22
|
+
Added
|
|
23
|
+
=====
|
|
24
|
+
|
|
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
|
+
|
|
19
34
|
0.3.1 - 2025-12-15
|
|
20
35
|
******************
|
|
21
36
|
|
{learning_credentials-0.3.1rc2/learning_credentials.egg-info → learning_credentials-0.4.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.
|
|
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
|
|
|
@@ -0,0 +1,416 @@
|
|
|
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').
|
|
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
|
+
|
|
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)
|
|
393
|
+
|
|
394
|
+
# 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()
|
|
399
|
+
|
|
400
|
+
# Load the PDF template.
|
|
401
|
+
with template_file.open('rb') as template_file:
|
|
402
|
+
template = PdfReader(template_file).pages[0]
|
|
403
|
+
|
|
404
|
+
credential = PdfWriter()
|
|
405
|
+
|
|
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)
|
|
408
|
+
|
|
409
|
+
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
410
|
+
template.merge_page(overlay_pdf.pages[0])
|
|
411
|
+
credential.add_page(template)
|
|
412
|
+
|
|
413
|
+
url = _save_credential(credential, credential_uuid)
|
|
414
|
+
|
|
415
|
+
log.info("Credential saved to %s", url)
|
|
416
|
+
return url
|
learning_credentials-0.4.0/learning_credentials/migrations/0007_migrate_to_text_elements_format.py
ADDED
|
@@ -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[
|
|
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 =
|
|
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 =
|
|
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,
|