learning-credentials 0.4.0rc3__py3-none-any.whl → 0.4.1rc2__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.
- learning_credentials/admin.py +3 -30
- learning_credentials/api/v1/urls.py +1 -2
- learning_credentials/api/v1/views.py +1 -61
- learning_credentials/generators.py +248 -95
- learning_credentials/migrations/0007_migrate_to_text_elements_format.py +138 -0
- learning_credentials/models.py +11 -60
- learning_credentials/urls.py +0 -2
- {learning_credentials-0.4.0rc3.dist-info → learning_credentials-0.4.1rc2.dist-info}/METADATA +27 -5
- {learning_credentials-0.4.0rc3.dist-info → learning_credentials-0.4.1rc2.dist-info}/RECORD +13 -15
- learning_credentials/api/v1/serializers.py +0 -13
- learning_credentials/migrations/0007_validation.py +0 -94
- learning_credentials/templates/learning_credentials/verify.html +0 -83
- {learning_credentials-0.4.0rc3.dist-info → learning_credentials-0.4.1rc2.dist-info}/WHEEL +0 -0
- {learning_credentials-0.4.0rc3.dist-info → learning_credentials-0.4.1rc2.dist-info}/entry_points.txt +0 -0
- {learning_credentials-0.4.0rc3.dist-info → learning_credentials-0.4.1rc2.dist-info}/licenses/LICENSE.txt +0 -0
- {learning_credentials-0.4.0rc3.dist-info → learning_credentials-0.4.1rc2.dist-info}/top_level.txt +0 -0
learning_credentials/admin.py
CHANGED
|
@@ -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
|
|
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(
|
|
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"
|
|
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
|
|
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
|
|
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)
|
|
@@ -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,101 +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:
|
|
94
|
+
def _register_font(pdf_canvas: Canvas, font_name: str) -> str:
|
|
50
95
|
"""
|
|
51
|
-
Register a custom font if
|
|
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
|
|
103
|
+
:returns: The font name if available, otherwise use 'Helvetica' as fallback.
|
|
55
104
|
"""
|
|
56
|
-
if
|
|
57
|
-
|
|
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
|
|
121
|
+
def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
|
|
68
122
|
"""
|
|
69
|
-
|
|
123
|
+
Convert a hexadecimal color code to an RGB tuple with floating-point values.
|
|
70
124
|
|
|
71
|
-
:param
|
|
72
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
137
|
+
def _substitute_placeholders(text: str, placeholders: dict[str, str]) -> str:
|
|
138
|
+
"""
|
|
139
|
+
Substitute placeholders in text using {placeholder} syntax.
|
|
92
140
|
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
179
|
+
if user_config is False:
|
|
180
|
+
continue
|
|
138
181
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
142
190
|
|
|
143
|
-
|
|
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)
|
|
144
281
|
|
|
145
282
|
return pdf_canvas
|
|
146
283
|
|
|
@@ -199,42 +336,55 @@ def generate_pdf_credential(
|
|
|
199
336
|
:returns: The URL of the saved credential.
|
|
200
337
|
|
|
201
338
|
Options:
|
|
202
|
-
|
|
203
|
-
-
|
|
204
|
-
|
|
205
|
-
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
}
|
|
226
374
|
"""
|
|
227
375
|
log.info("Starting credential generation for user %s", user.id)
|
|
228
376
|
|
|
229
377
|
username = _get_user_name(user)
|
|
230
|
-
context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
|
|
231
|
-
template_path = options.get('template')
|
|
232
378
|
|
|
233
|
-
# Handle multiline context name (
|
|
234
|
-
context_name =
|
|
379
|
+
# Handle multiline context name (semicolon separator for backward compatibility).
|
|
380
|
+
context_name = get_learning_context_name(learning_context_key).replace(';', '\n')
|
|
381
|
+
custom_context_text_element = options.get('text_elements', {}).get('context', {})
|
|
382
|
+
if isinstance(custom_context_text_element, dict):
|
|
383
|
+
context_name = custom_context_text_element.get('text', '').replace(';', '\n') or context_name
|
|
384
|
+
|
|
385
|
+
template_path = options.get('template')
|
|
235
386
|
if '\n' in context_name:
|
|
236
|
-
|
|
237
|
-
template_path = options.get('template_multiline', options.get('template_two_lines', template_path))
|
|
387
|
+
template_path = options.get('template_multiline', template_path)
|
|
238
388
|
|
|
239
389
|
if not template_path:
|
|
240
390
|
msg = "Template path must be specified in options."
|
|
@@ -243,14 +393,17 @@ def generate_pdf_credential(
|
|
|
243
393
|
# Get template from the CredentialAsset.
|
|
244
394
|
template_file = CredentialAsset.get_asset_by_slug(template_path)
|
|
245
395
|
|
|
396
|
+
# Get the issue date.
|
|
397
|
+
issue_date = get_localized_credential_date()
|
|
398
|
+
|
|
246
399
|
# Load the PDF template.
|
|
247
400
|
with template_file.open('rb') as template_file:
|
|
248
401
|
template = PdfReader(template_file).pages[0]
|
|
249
402
|
|
|
250
403
|
credential = PdfWriter()
|
|
251
404
|
|
|
252
|
-
# Create a new canvas, prepare the page and write the data
|
|
253
|
-
pdf_canvas = _write_text_on_template(template, username, context_name, options)
|
|
405
|
+
# Create a new canvas, prepare the page and write the data.
|
|
406
|
+
pdf_canvas = _write_text_on_template(template, username, context_name, issue_date, options)
|
|
254
407
|
|
|
255
408
|
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
256
409
|
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
|
+
]
|
learning_credentials/models.py
CHANGED
|
@@ -4,10 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
-
import uuid
|
|
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
|
|
11
11
|
|
|
12
12
|
import jsonfield
|
|
13
13
|
from django.conf import settings
|
|
@@ -16,7 +16,6 @@ from django.core.exceptions import ValidationError
|
|
|
16
16
|
from django.db import models
|
|
17
17
|
from django.db.models.signals import post_delete
|
|
18
18
|
from django.dispatch import receiver
|
|
19
|
-
from django.utils import timezone
|
|
20
19
|
from django.utils.translation import gettext_lazy as _
|
|
21
20
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
22
21
|
from edx_ace import Message, Recipient, ace
|
|
@@ -180,7 +179,7 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
180
179
|
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
181
180
|
return func(self.learning_context_key, custom_options)
|
|
182
181
|
|
|
183
|
-
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0)
|
|
182
|
+
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
|
|
184
183
|
"""
|
|
185
184
|
Celery task for processing a single user's credential.
|
|
186
185
|
|
|
@@ -191,24 +190,19 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
191
190
|
Args:
|
|
192
191
|
user_id: The ID of the user to process the credential for.
|
|
193
192
|
celery_task_id (optional): The ID of the Celery task that is running this function.
|
|
194
|
-
|
|
195
|
-
Returns:
|
|
196
|
-
The generated Credential object.
|
|
197
193
|
"""
|
|
198
194
|
user = get_user_model().objects.get(id=user_id)
|
|
199
195
|
# Use the name from the profile if it is not empty. Otherwise, use the first and last name.
|
|
200
196
|
# We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
|
|
201
197
|
user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
|
|
202
|
-
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
203
198
|
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
204
199
|
|
|
205
|
-
credential, _ = Credential.objects.
|
|
200
|
+
credential, _ = Credential.objects.update_or_create(
|
|
206
201
|
user_id=user_id,
|
|
207
202
|
learning_context_key=self.learning_context_key,
|
|
208
203
|
credential_type=self.credential_type.name,
|
|
209
204
|
defaults={
|
|
210
205
|
'user_full_name': user_full_name,
|
|
211
|
-
'learning_context_name': learning_context_name,
|
|
212
206
|
'status': Credential.Status.GENERATING,
|
|
213
207
|
'generation_task_id': celery_task_id,
|
|
214
208
|
},
|
|
@@ -234,8 +228,6 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
234
228
|
if user.is_active and user.has_usable_password():
|
|
235
229
|
credential.send_email()
|
|
236
230
|
|
|
237
|
-
return credential
|
|
238
|
-
|
|
239
231
|
|
|
240
232
|
# noinspection PyUnusedLocal
|
|
241
233
|
@receiver(post_delete, sender=CredentialConfiguration)
|
|
@@ -270,33 +262,16 @@ class Credential(TimeStampedModel):
|
|
|
270
262
|
|
|
271
263
|
uuid = models.UUIDField(
|
|
272
264
|
primary_key=True,
|
|
273
|
-
default=
|
|
265
|
+
default=uuid.uuid4,
|
|
274
266
|
editable=False,
|
|
275
267
|
help_text=_('Auto-generated UUID of the credential'),
|
|
276
268
|
)
|
|
277
|
-
verify_uuid = models.UUIDField(
|
|
278
|
-
default=uuid_lib.uuid4,
|
|
279
|
-
editable=False,
|
|
280
|
-
help_text=_('UUID used for verifying the credential'),
|
|
281
|
-
)
|
|
282
269
|
user_id = models.IntegerField(help_text=_('ID of the user receiving the credential'))
|
|
283
|
-
user_full_name = models.CharField(
|
|
284
|
-
max_length=255,
|
|
285
|
-
editable=False,
|
|
286
|
-
help_text=_('User receiving the credential. This field is used for validation purposes.'),
|
|
287
|
-
)
|
|
270
|
+
user_full_name = models.CharField(max_length=255, help_text=_('User receiving the credential'))
|
|
288
271
|
learning_context_key = LearningContextKeyField(
|
|
289
272
|
max_length=255,
|
|
290
273
|
help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
|
|
291
274
|
)
|
|
292
|
-
learning_context_name = models.CharField(
|
|
293
|
-
max_length=255,
|
|
294
|
-
editable=False,
|
|
295
|
-
help_text=_(
|
|
296
|
-
'Name of the learning context for which the credential was issued. '
|
|
297
|
-
'This field is used for validation purposes.'
|
|
298
|
-
),
|
|
299
|
-
)
|
|
300
275
|
credential_type = models.CharField(max_length=255, help_text=_('Type of the credential'))
|
|
301
276
|
status = models.CharField(
|
|
302
277
|
max_length=32,
|
|
@@ -307,26 +282,16 @@ class Credential(TimeStampedModel):
|
|
|
307
282
|
download_url = models.URLField(blank=True, help_text=_('URL of the generated credential PDF (e.g., to S3)'))
|
|
308
283
|
legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the credential imported from another system'))
|
|
309
284
|
generation_task_id = models.CharField(max_length=255, help_text=_('Task ID from the Celery queue'))
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
invalidation_reason = models.CharField(
|
|
314
|
-
max_length=255, blank=True, help_text=_('Reason for invalidating the credential')
|
|
315
|
-
)
|
|
285
|
+
|
|
286
|
+
class Meta: # noqa: D106
|
|
287
|
+
unique_together = (('user_id', 'learning_context_key', 'credential_type'),)
|
|
316
288
|
|
|
317
289
|
def __str__(self): # noqa: D105
|
|
318
290
|
return f"{self.credential_type} for {self.user_full_name} in {self.learning_context_key}"
|
|
319
291
|
|
|
320
|
-
def save(self, *args, **kwargs):
|
|
321
|
-
"""If the invalidation reason is set, update the status and timestamp."""
|
|
322
|
-
if self.invalidation_reason and self.status != Credential.Status.INVALIDATED:
|
|
323
|
-
self.status = Credential.Status.INVALIDATED
|
|
324
|
-
if self.status == Credential.Status.INVALIDATED and not self.invalidated_at:
|
|
325
|
-
self.invalidated_at = timezone.now()
|
|
326
|
-
super().save(*args, **kwargs)
|
|
327
|
-
|
|
328
292
|
def send_email(self):
|
|
329
293
|
"""Send a credential link to the student."""
|
|
294
|
+
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
330
295
|
user = get_user_model().objects.get(id=self.user_id)
|
|
331
296
|
msg = Message(
|
|
332
297
|
name="certificate_generated",
|
|
@@ -335,26 +300,12 @@ class Credential(TimeStampedModel):
|
|
|
335
300
|
language='en',
|
|
336
301
|
context={
|
|
337
302
|
'certificate_link': self.download_url,
|
|
338
|
-
'course_name':
|
|
303
|
+
'course_name': learning_context_name,
|
|
339
304
|
'platform_name': settings.PLATFORM_NAME,
|
|
340
305
|
},
|
|
341
306
|
)
|
|
342
307
|
ace.send(msg)
|
|
343
308
|
|
|
344
|
-
def reissue(self) -> Self:
|
|
345
|
-
"""Invalidate the current credential and create a new one."""
|
|
346
|
-
config = CredentialConfiguration.objects.get(
|
|
347
|
-
learning_context_key=self.learning_context_key,
|
|
348
|
-
credential_type__name=self.credential_type,
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
if self.invalidation_reason:
|
|
352
|
-
self.invalidation_reason += '\n'
|
|
353
|
-
self.invalidation_reason += 'Reissued'
|
|
354
|
-
self.save()
|
|
355
|
-
|
|
356
|
-
return config.generate_credential_for_user(self.user_id)
|
|
357
|
-
|
|
358
309
|
|
|
359
310
|
class CredentialAsset(TimeStampedModel):
|
|
360
311
|
"""
|
learning_credentials/urls.py
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
"""URLs for learning_credentials."""
|
|
2
2
|
|
|
3
3
|
from django.urls import include, path
|
|
4
|
-
from django.views.generic import TemplateView
|
|
5
4
|
|
|
6
5
|
from .api import urls as api_urls
|
|
7
6
|
|
|
8
7
|
urlpatterns = [
|
|
9
8
|
path('api/learning_credentials/', include(api_urls)),
|
|
10
|
-
path('learning_credentials/verify/', TemplateView.as_view(template_name="learning_credentials/verify.html")),
|
|
11
9
|
]
|
{learning_credentials-0.4.0rc3.dist-info → learning_credentials-0.4.1rc2.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1rc2
|
|
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.
|
|
179
|
+
0.4.1 - 2025-12-31
|
|
180
180
|
******************
|
|
181
181
|
|
|
182
182
|
Added
|
|
183
183
|
=====
|
|
184
184
|
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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
|
******************
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
learning_credentials/__init__.py,sha256=8Q0-3Hdnfmcj41EKu1GSfzEfwWcYNDlItyEEke2r9bs,62
|
|
2
|
-
learning_credentials/admin.py,sha256=
|
|
2
|
+
learning_credentials/admin.py,sha256=ynK3tVJwLsIeV7Jk66t1FAVyVsU1G-KRIAdRkycVTmA,10439
|
|
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=
|
|
7
|
-
learning_credentials/models.py,sha256=
|
|
6
|
+
learning_credentials/generators.py,sha256=QHl22r0Od027a9_-qfA2AtzLKbJ_cbQ5S_BN-K31fgM,14938
|
|
7
|
+
learning_credentials/models.py,sha256=J_SCNiu42yhdi12eDMLsxNCTkJK7_vqneQjyGYG5KJ4,16048
|
|
8
8
|
learning_credentials/processors.py,sha256=LkdjmkLBnXc9qeMcksB1T8AQ5ZhYaECyQO__KfHB_aU,15212
|
|
9
9
|
learning_credentials/tasks.py,sha256=byoFEUvN_ayVaU5K5SlEiA7vu9BRPaSSmKnB9g5toec,1927
|
|
10
|
-
learning_credentials/urls.py,sha256=
|
|
10
|
+
learning_credentials/urls.py,sha256=gO_c930rzMylP-riQ9SGHXH9JIMF7ajySDT2Tc-E8x4,188
|
|
11
11
|
learning_credentials/api/__init__.py,sha256=q8sLFfwo5RwQu8FY6BJUL_Jrt3TUojbZK-Zlw9v08EM,40
|
|
12
12
|
learning_credentials/api/urls.py,sha256=wW27hrrJ7D_h8PbFDbSxzeaneNla0R-56gjKy9zISG8,216
|
|
13
13
|
learning_credentials/api/v1/__init__.py,sha256=A7ZqENtM4QM1A7j_cAfnzw4zn0kuyfXSWtylFIE0_f8,43
|
|
14
14
|
learning_credentials/api/v1/permissions.py,sha256=TqM50TpR3JGUgZgIgKZF0-R_g1_P2V9bqKzYXgk-VvY,3436
|
|
15
|
-
learning_credentials/api/v1/
|
|
16
|
-
learning_credentials/api/v1/
|
|
17
|
-
learning_credentials/api/v1/views.py,sha256=CJEVPwCXs_ii463agPRpJeX6NCgyyFX9ZIBJh0BAc9I,4926
|
|
15
|
+
learning_credentials/api/v1/urls.py,sha256=6YqLS4aVfA1cuLOgVe4lFUFa38wVehYKleXBF8ImMm0,287
|
|
16
|
+
learning_credentials/api/v1/views.py,sha256=rkdj1AfRBDzrpRC5uGMAxTUf4P1zs-MSF9lpNMIgYLw,3005
|
|
18
17
|
learning_credentials/conf/locale/config.yaml,sha256=jPen2DmckNDKK30axCKEd2Q2ha9oOG3IBxrJ63Pvznk,2280
|
|
19
18
|
learning_credentials/migrations/0001_initial.py,sha256=61EvThCv-0UAnhCE5feyQVfjRodbp-6cDaAr4CY5PMA,8435
|
|
20
19
|
learning_credentials/migrations/0002_migrate_to_learning_credentials.py,sha256=vUhcnQKDdwOsppkXsjz2zZwOGMwIJ-fkQRsaj-K7l1o,1779
|
|
@@ -22,21 +21,20 @@ learning_credentials/migrations/0003_rename_certificates_to_credentials.py,sha25
|
|
|
22
21
|
learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py,sha256=5KaXvASl69qbEaHX5_Ty_3Dr7K4WV6p8VWOx72yJnTU,1919
|
|
23
22
|
learning_credentials/migrations/0005_rename_processors_and_generators.py,sha256=5UCqjq-CBJnRo1qBAoWs91ngyEuSMN8_tQtfzsuR5SI,5271
|
|
24
23
|
learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py,sha256=aJs_gOP4TmW9J-Dmr21m94jBfLQxzjAu6-ua7x4uYLE,727
|
|
25
|
-
learning_credentials/migrations/
|
|
24
|
+
learning_credentials/migrations/0007_migrate_to_text_elements_format.py,sha256=_olkaxPPuRys2c2X5fnyQIFVvqEfdoYu-JlApmXuHEM,4758
|
|
26
25
|
learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
26
|
learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
|
|
28
27
|
learning_credentials/settings/common.py,sha256=Cck-nyFt11G1NLiz-bHfKJp8MV6sDZGqTwdbC8_1WE0,360
|
|
29
28
|
learning_credentials/settings/production.py,sha256=6P0P7JxbpWNsk4Lk8lfyxHirOWMgU4UWOb3EYKLjiVQ,542
|
|
30
29
|
learning_credentials/templates/learning_credentials/base.html,sha256=wtjBYqfHmOnyEY5tN3VGOmzYLsOD24MXdEUhTZ7OmwI,662
|
|
31
|
-
learning_credentials/templates/learning_credentials/verify.html,sha256=vXTiZMZkTLhp6cnqKBJcQnDu7qUxLvrTzN2m1KM8_9k,3414
|
|
32
30
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html,sha256=t-i1Ra9AC4pX-rPRifDJIvBBZuxCxdrFqg1NKTjHBOk,813
|
|
33
31
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt,sha256=IF_x8aF_-dORlQB-RCh0IkJDl2ktD489E8qGgLe9M3Y,677
|
|
34
32
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
|
|
35
33
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
36
34
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt,sha256=S7Hc5T_sZSsSBXm5_H5HBNNv16Ohl0oZn0nVqqeWL0g,132
|
|
37
|
-
learning_credentials-0.4.
|
|
38
|
-
learning_credentials-0.4.
|
|
39
|
-
learning_credentials-0.4.
|
|
40
|
-
learning_credentials-0.4.
|
|
41
|
-
learning_credentials-0.4.
|
|
42
|
-
learning_credentials-0.4.
|
|
35
|
+
learning_credentials-0.4.1rc2.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
|
|
36
|
+
learning_credentials-0.4.1rc2.dist-info/METADATA,sha256=CMup6NqvOnILaXMPztLG4fRGRuUApLzguv59CyeO_vk,8258
|
|
37
|
+
learning_credentials-0.4.1rc2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
38
|
+
learning_credentials-0.4.1rc2.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
|
|
39
|
+
learning_credentials-0.4.1rc2.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
|
|
40
|
+
learning_credentials-0.4.1rc2.dist-info/RECORD,,
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
"""API serializers for learning credentials."""
|
|
2
|
-
|
|
3
|
-
from rest_framework import serializers
|
|
4
|
-
|
|
5
|
-
from learning_credentials.models import Credential
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class CredentialSerializer(serializers.ModelSerializer):
|
|
9
|
-
"""Serializer that returns credential metadata."""
|
|
10
|
-
|
|
11
|
-
class Meta: # noqa: D106
|
|
12
|
-
model = Credential
|
|
13
|
-
fields = ('user_full_name', 'created', 'learning_context_name', 'status', 'invalidation_reason')
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# Generated by Django 4.2.25 on 2025-10-31 17:43
|
|
2
|
-
|
|
3
|
-
from django.db import migrations, models
|
|
4
|
-
import uuid
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def backfill_credential_fields(apps, schema_editor):
|
|
8
|
-
"""Generate verification UUIDs and backfill learning_context_name for all existing credentials."""
|
|
9
|
-
from learning_credentials.compat import get_learning_context_name
|
|
10
|
-
|
|
11
|
-
Credential = apps.get_model("learning_credentials", "Credential")
|
|
12
|
-
for credential in Credential.objects.all():
|
|
13
|
-
credential.verify_uuid = uuid.uuid4()
|
|
14
|
-
credential.learning_context_name = get_learning_context_name(credential.learning_context_key)
|
|
15
|
-
credential.save(update_fields=["verify_uuid", "learning_context_name"])
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class Migration(migrations.Migration):
|
|
19
|
-
dependencies = [
|
|
20
|
-
("learning_credentials", "0006_cleanup_openedx_certificates_tables"),
|
|
21
|
-
]
|
|
22
|
-
|
|
23
|
-
operations = [
|
|
24
|
-
migrations.AlterUniqueTogether(
|
|
25
|
-
name="credential",
|
|
26
|
-
unique_together=set(),
|
|
27
|
-
),
|
|
28
|
-
migrations.AlterField(
|
|
29
|
-
model_name="credential",
|
|
30
|
-
name="user_full_name",
|
|
31
|
-
field=models.CharField(
|
|
32
|
-
editable=False,
|
|
33
|
-
help_text="User receiving the credential. This field is used for validation purposes.",
|
|
34
|
-
max_length=255,
|
|
35
|
-
),
|
|
36
|
-
),
|
|
37
|
-
migrations.AddField(
|
|
38
|
-
model_name="credential",
|
|
39
|
-
name="invalidated_at",
|
|
40
|
-
field=models.DateTimeField(
|
|
41
|
-
editable=False,
|
|
42
|
-
help_text="Timestamp when the credential was invalidated",
|
|
43
|
-
null=True,
|
|
44
|
-
),
|
|
45
|
-
),
|
|
46
|
-
migrations.AddField(
|
|
47
|
-
model_name="credential",
|
|
48
|
-
name="invalidation_reason",
|
|
49
|
-
field=models.CharField(
|
|
50
|
-
blank=True,
|
|
51
|
-
help_text="Reason for invalidating the credential",
|
|
52
|
-
max_length=255,
|
|
53
|
-
),
|
|
54
|
-
),
|
|
55
|
-
migrations.AddField(
|
|
56
|
-
model_name="credential",
|
|
57
|
-
name="learning_context_name",
|
|
58
|
-
field=models.CharField(
|
|
59
|
-
editable=False,
|
|
60
|
-
help_text="Name of the learning context for which the credential was issued. This field is used for validation purposes.",
|
|
61
|
-
max_length=255,
|
|
62
|
-
null=True,
|
|
63
|
-
),
|
|
64
|
-
),
|
|
65
|
-
migrations.AddField(
|
|
66
|
-
model_name="credential",
|
|
67
|
-
name="verify_uuid",
|
|
68
|
-
field=models.UUIDField(
|
|
69
|
-
default=uuid.uuid4,
|
|
70
|
-
editable=False,
|
|
71
|
-
help_text="UUID used for verifying the credential",
|
|
72
|
-
null=True,
|
|
73
|
-
),
|
|
74
|
-
),
|
|
75
|
-
migrations.RunPython(backfill_credential_fields, reverse_code=migrations.RunPython.noop),
|
|
76
|
-
migrations.AlterField(
|
|
77
|
-
model_name="credential",
|
|
78
|
-
name="learning_context_name",
|
|
79
|
-
field=models.CharField(
|
|
80
|
-
editable=False,
|
|
81
|
-
help_text="Name of the learning context for which the credential was issued. This field is used for validation purposes.",
|
|
82
|
-
max_length=255,
|
|
83
|
-
),
|
|
84
|
-
),
|
|
85
|
-
migrations.AlterField(
|
|
86
|
-
model_name="credential",
|
|
87
|
-
name="verify_uuid",
|
|
88
|
-
field=models.UUIDField(
|
|
89
|
-
default=uuid.uuid4,
|
|
90
|
-
editable=False,
|
|
91
|
-
help_text="UUID used for verifying the credential",
|
|
92
|
-
),
|
|
93
|
-
),
|
|
94
|
-
]
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
{% extends "main_django.html" %}
|
|
2
|
-
{% load i18n %}
|
|
3
|
-
{% load static %}
|
|
4
|
-
|
|
5
|
-
{% block bodyextra %}
|
|
6
|
-
<div id="content" class="credential-verification-container content-wrapper main-container">
|
|
7
|
-
<section class="container">
|
|
8
|
-
<h1>{% trans "Credential verification page" %}</h1>
|
|
9
|
-
<form id="credentialVerificationForm" method="post">
|
|
10
|
-
{% csrf_token %}
|
|
11
|
-
<div class="form-group">
|
|
12
|
-
<label for="credentialID">{% trans 'Credential ID' %}</label>
|
|
13
|
-
<input type="text" id="credentialID" class="form-control" name="credentialID" required>
|
|
14
|
-
<div id="formError" class="text-danger mt-2" style="display: none;"></div>
|
|
15
|
-
</div>
|
|
16
|
-
<button type="submit" class="btn btn-primary">
|
|
17
|
-
{% trans 'Verify' %}
|
|
18
|
-
</button>
|
|
19
|
-
</form>
|
|
20
|
-
|
|
21
|
-
<div id="verificationResults" class="p-0 border-0 mt-5"></div>
|
|
22
|
-
</section>
|
|
23
|
-
</div>
|
|
24
|
-
|
|
25
|
-
<script>
|
|
26
|
-
document.getElementById('credentialVerificationForm').onsubmit = async function(event) {
|
|
27
|
-
event.preventDefault();
|
|
28
|
-
let formError = document.getElementById('formError');
|
|
29
|
-
let verificationResults = document.getElementById('verificationResults');
|
|
30
|
-
|
|
31
|
-
let credentialId = document.getElementById('credentialID').value;
|
|
32
|
-
var url = '/api/learning_credentials/v1/metadata/' + encodeURIComponent(credentialId);
|
|
33
|
-
|
|
34
|
-
// Hide previous error messages.
|
|
35
|
-
formError.style.display = 'none';
|
|
36
|
-
|
|
37
|
-
await fetch(url)
|
|
38
|
-
.then(response => response.json())
|
|
39
|
-
.then(data => {
|
|
40
|
-
const table = document.createElement('table');
|
|
41
|
-
table.className = 'table table-striped table-bordered';
|
|
42
|
-
const tbody = document.createElement('tbody');
|
|
43
|
-
Object.entries(data).forEach(([key, value]) => {
|
|
44
|
-
if (!!value) {
|
|
45
|
-
const row = tbody.insertRow();
|
|
46
|
-
const cellKey = row.insertCell();
|
|
47
|
-
const cellValue = row.insertCell();
|
|
48
|
-
cellKey.textContent = key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
49
|
-
cellValue.textContent = value;
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
table.appendChild(tbody);
|
|
53
|
-
verificationResults.textContent = '';
|
|
54
|
-
verificationResults.appendChild(table);
|
|
55
|
-
})
|
|
56
|
-
.catch((error) => {
|
|
57
|
-
console.error('Error:', error);
|
|
58
|
-
verificationResults.textContent = '';
|
|
59
|
-
formError.innerHTML = "An error occurred during verification. Please check the Credential ID and try again.";
|
|
60
|
-
formError.style.display = 'block';
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
$(document).ready(function(){
|
|
66
|
-
// Fill the credential ID field with URL 'credential' query param.
|
|
67
|
-
let urlParams = new URLSearchParams(window.location.search);
|
|
68
|
-
const uuid = urlParams.get('credential');
|
|
69
|
-
|
|
70
|
-
if (uuid) {
|
|
71
|
-
$('#credentialID').val(uuid);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// If the field is filled, submit the form automatically.
|
|
75
|
-
if ($('#credentialID').val()) {
|
|
76
|
-
$('#credentialVerificationForm').submit();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Focus the field for user convenience.
|
|
80
|
-
$('#credentialID').focus();
|
|
81
|
-
});
|
|
82
|
-
</script>
|
|
83
|
-
{% endblock %}
|
|
File without changes
|
{learning_credentials-0.4.0rc3.dist-info → learning_credentials-0.4.1rc2.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{learning_credentials-0.4.0rc3.dist-info → learning_credentials-0.4.1rc2.dist-info}/top_level.txt
RENAMED
|
File without changes
|