learning-credentials 0.4.0__py3-none-any.whl → 0.4.0rc1__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 +30 -3
- learning_credentials/api/v1/serializers.py +13 -0
- learning_credentials/api/v1/urls.py +2 -1
- learning_credentials/api/v1/views.py +61 -1
- learning_credentials/compat.py +3 -6
- learning_credentials/generators.py +82 -272
- learning_credentials/migrations/0007_validation.py +94 -0
- learning_credentials/models.py +63 -32
- learning_credentials/templates/learning_credentials/verify.html +83 -0
- learning_credentials/urls.py +2 -0
- {learning_credentials-0.4.0.dist-info → learning_credentials-0.4.0rc1.dist-info}/METADATA +4 -29
- {learning_credentials-0.4.0.dist-info → learning_credentials-0.4.0rc1.dist-info}/RECORD +16 -14
- {learning_credentials-0.4.0.dist-info → learning_credentials-0.4.0rc1.dist-info}/WHEEL +1 -1
- learning_credentials/migrations/0007_migrate_to_text_elements_format.py +0 -138
- {learning_credentials-0.4.0.dist-info → learning_credentials-0.4.0rc1.dist-info}/entry_points.txt +0 -0
- {learning_credentials-0.4.0.dist-info → learning_credentials-0.4.0rc1.dist-info}/licenses/LICENSE.txt +0 -0
- {learning_credentials-0.4.0.dist-info → learning_credentials-0.4.0rc1.dist-info}/top_level.txt +0 -0
learning_credentials/admin.py
CHANGED
|
@@ -7,8 +7,9 @@ import inspect
|
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from django import forms
|
|
10
|
-
from django.contrib import admin
|
|
10
|
+
from django.contrib import admin, messages
|
|
11
11
|
from django.core.exceptions import ValidationError
|
|
12
|
+
from django.urls import reverse
|
|
12
13
|
from django.utils.html import format_html
|
|
13
14
|
from django_object_actions import DjangoObjectActions, action
|
|
14
15
|
from django_reverse_admin import ReverseModelAdmin
|
|
@@ -225,7 +226,7 @@ class CredentialConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
|
|
|
225
226
|
|
|
226
227
|
|
|
227
228
|
@admin.register(Credential)
|
|
228
|
-
class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
229
|
+
class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
|
|
229
230
|
list_display = (
|
|
230
231
|
'user_id',
|
|
231
232
|
'user_full_name',
|
|
@@ -237,19 +238,32 @@ class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
|
237
238
|
'modified',
|
|
238
239
|
)
|
|
239
240
|
readonly_fields = (
|
|
241
|
+
'uuid',
|
|
242
|
+
'verify_uuid',
|
|
240
243
|
'user_id',
|
|
241
244
|
'created',
|
|
242
245
|
'modified',
|
|
243
246
|
'user_full_name',
|
|
244
247
|
'learning_context_key',
|
|
248
|
+
'learning_context_name',
|
|
245
249
|
'credential_type',
|
|
246
250
|
'status',
|
|
247
251
|
'url',
|
|
248
252
|
'legacy_id',
|
|
249
253
|
'generation_task_id',
|
|
250
254
|
)
|
|
251
|
-
search_fields = ("learning_context_key", "user_id", "user_full_name")
|
|
255
|
+
search_fields = ("learning_context_key", "user_id", "user_full_name", "uuid")
|
|
252
256
|
list_filter = ("learning_context_key", "credential_type", "status")
|
|
257
|
+
change_actions = ('reissue_credential',)
|
|
258
|
+
|
|
259
|
+
def save_model(self, request: HttpRequest, obj: Credential, _form: forms.ModelForm, _change: bool): # noqa: FBT001
|
|
260
|
+
"""Display validation errors as messages in the admin interface."""
|
|
261
|
+
try:
|
|
262
|
+
obj.save()
|
|
263
|
+
except ValidationError as e:
|
|
264
|
+
self.message_user(request, e.message or "Invalid data", level=messages.ERROR)
|
|
265
|
+
# Optionally, redirect to the change form with the error message
|
|
266
|
+
return
|
|
253
267
|
|
|
254
268
|
def get_form(self, request: HttpRequest, obj: Credential | None = None, **kwargs) -> forms.ModelForm:
|
|
255
269
|
"""Hide the download_url field."""
|
|
@@ -263,3 +277,16 @@ class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
|
263
277
|
if obj.download_url:
|
|
264
278
|
return format_html("<a href='{url}'>{url}</a>", url=obj.download_url)
|
|
265
279
|
return "-"
|
|
280
|
+
|
|
281
|
+
@action(label="Reissue credential", description="Reissue the credential for the user.")
|
|
282
|
+
def reissue_credential(self, request: HttpRequest, obj: Credential):
|
|
283
|
+
"""Reissue the credential for the user."""
|
|
284
|
+
try:
|
|
285
|
+
new_credential = obj.reissue()
|
|
286
|
+
admin_url = reverse('admin:learning_credentials_credential_change', args=[new_credential.pk])
|
|
287
|
+
message = format_html(
|
|
288
|
+
'The credential has been reissued as <a href="{}">{}</a>.', admin_url, new_credential.uuid
|
|
289
|
+
)
|
|
290
|
+
messages.success(request, message)
|
|
291
|
+
except CredentialConfiguration.DoesNotExist:
|
|
292
|
+
messages.error(request, "The configuration does not exist.")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""API serializers for learning credentials."""
|
|
2
|
+
|
|
3
|
+
from rest_framework import serializers
|
|
4
|
+
|
|
5
|
+
from learning_credentials.models import Credential
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CredentialSerializer(serializers.ModelSerializer):
|
|
9
|
+
"""Serializer that returns credential metadata."""
|
|
10
|
+
|
|
11
|
+
class Meta: # noqa: D106
|
|
12
|
+
model = Credential
|
|
13
|
+
fields = ('user_full_name', 'created', 'learning_context_name', 'status', 'invalidation_reason')
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from django.urls import path
|
|
4
4
|
|
|
5
|
-
from .views import CredentialConfigurationCheckView
|
|
5
|
+
from .views import CredentialConfigurationCheckView, CredentialMetadataView
|
|
6
6
|
|
|
7
7
|
urlpatterns = [
|
|
8
8
|
path(
|
|
@@ -10,4 +10,5 @@ urlpatterns = [
|
|
|
10
10
|
CredentialConfigurationCheckView.as_view(),
|
|
11
11
|
name='credential_configuration_check',
|
|
12
12
|
),
|
|
13
|
+
path('metadata/<uuid:uuid>/', CredentialMetadataView.as_view(), name='credential-metadata'),
|
|
13
14
|
]
|
|
@@ -9,9 +9,10 @@ from rest_framework.permissions import IsAuthenticated
|
|
|
9
9
|
from rest_framework.response import Response
|
|
10
10
|
from rest_framework.views import APIView
|
|
11
11
|
|
|
12
|
-
from learning_credentials.models import CredentialConfiguration
|
|
12
|
+
from learning_credentials.models import Credential, CredentialConfiguration
|
|
13
13
|
|
|
14
14
|
from .permissions import CanAccessLearningContext
|
|
15
|
+
from .serializers import CredentialSerializer
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
from rest_framework.request import Request
|
|
@@ -81,3 +82,62 @@ class CredentialConfigurationCheckView(APIView):
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
return Response(response_data, status=status.HTTP_200_OK)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CredentialMetadataView(APIView):
|
|
88
|
+
"""API view to retrieve credential metadata by UUID."""
|
|
89
|
+
|
|
90
|
+
@apidocs.schema(
|
|
91
|
+
parameters=[
|
|
92
|
+
apidocs.string_parameter(
|
|
93
|
+
"uuid",
|
|
94
|
+
ParameterLocation.PATH,
|
|
95
|
+
description="The UUID of the credential to retrieve.",
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
responses={
|
|
99
|
+
200: "Successfully retrieved the credential metadata.",
|
|
100
|
+
404: "Credential not found or not valid.",
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
def get(self, _request: "Request", uuid: str) -> Response:
|
|
104
|
+
"""
|
|
105
|
+
Retrieve credential metadata by its UUID.
|
|
106
|
+
|
|
107
|
+
**Example Request**
|
|
108
|
+
|
|
109
|
+
``GET /api/learning_credentials/v1/metadata/123e4567-e89b-12d3-a456-426614174000/``
|
|
110
|
+
|
|
111
|
+
**Response Values**
|
|
112
|
+
|
|
113
|
+
- **200 OK**: Successfully retrieved the credential metadata.
|
|
114
|
+
- **404 Not Found**: Credential not found or not valid.
|
|
115
|
+
|
|
116
|
+
**Example Response**
|
|
117
|
+
|
|
118
|
+
.. code-block:: json
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
"user_full_name": "John Doe",
|
|
122
|
+
"created": "2023-01-01",
|
|
123
|
+
"learning_context_name": "Demo Course",
|
|
124
|
+
"status": "available",
|
|
125
|
+
"invalidation_reason": ""
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
"user_full_name": "John Doe",
|
|
131
|
+
"created": "2023-01-01",
|
|
132
|
+
"learning_context_name": "Demo Course",
|
|
133
|
+
"status": "invalidated",
|
|
134
|
+
"invalidation_reason": "Reissued due to name change."
|
|
135
|
+
}
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
credential = Credential.objects.get(verify_uuid=uuid)
|
|
139
|
+
except Credential.DoesNotExist:
|
|
140
|
+
return Response({'error': 'Credential not found.'}, status=status.HTTP_404_NOT_FOUND)
|
|
141
|
+
|
|
142
|
+
serializer = CredentialSerializer(credential)
|
|
143
|
+
return Response(serializer.data, status=status.HTTP_200_OK)
|
learning_credentials/compat.py
CHANGED
|
@@ -52,13 +52,10 @@ def get_course_grading_policy(course_id: CourseKey) -> dict:
|
|
|
52
52
|
def _get_course_name(course_id: CourseKey) -> str:
|
|
53
53
|
"""Get the course name from Open edX."""
|
|
54
54
|
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
55
|
-
from openedx.core.djangoapps.content.
|
|
55
|
+
from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
name = course_overview.cert_name_long or course_overview.display_name or name
|
|
60
|
-
|
|
61
|
-
return name
|
|
57
|
+
course_outline = get_course_outline(course_id)
|
|
58
|
+
return (course_outline and course_outline.title) or str(course_id)
|
|
62
59
|
|
|
63
60
|
|
|
64
61
|
def _get_learning_path_name(learning_path_key: LearningPathKey) -> str:
|
|
@@ -9,10 +9,8 @@ We will move this module to an external repository (a plugin).
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
import copy
|
|
13
12
|
import io
|
|
14
13
|
import logging
|
|
15
|
-
import re
|
|
16
14
|
import secrets
|
|
17
15
|
from typing import TYPE_CHECKING, Any
|
|
18
16
|
|
|
@@ -21,12 +19,11 @@ from django.core.files.base import ContentFile
|
|
|
21
19
|
from django.core.files.storage import FileSystemStorage, default_storage
|
|
22
20
|
from pypdf import PdfReader, PdfWriter
|
|
23
21
|
from pypdf.constants import UserAccessPermissions
|
|
24
|
-
from reportlab.pdfbase
|
|
25
|
-
from reportlab.pdfbase.ttfonts import
|
|
26
|
-
from reportlab.pdfgen
|
|
22
|
+
from reportlab.pdfbase import pdfmetrics
|
|
23
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
24
|
+
from reportlab.pdfgen import canvas
|
|
27
25
|
|
|
28
26
|
from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
|
|
29
|
-
from .exceptions import AssetNotFoundError
|
|
30
27
|
from .models import CredentialAsset
|
|
31
28
|
|
|
32
29
|
log = logging.getLogger(__name__)
|
|
@@ -36,49 +33,6 @@ if TYPE_CHECKING: # pragma: no cover
|
|
|
36
33
|
|
|
37
34
|
from django.contrib.auth.models import User
|
|
38
35
|
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
36
|
|
|
83
37
|
|
|
84
38
|
def _get_user_name(user: User) -> str:
|
|
@@ -91,193 +45,81 @@ def _get_user_name(user: User) -> str:
|
|
|
91
45
|
return user.profile.name or f"{user.first_name} {user.last_name}"
|
|
92
46
|
|
|
93
47
|
|
|
94
|
-
def _register_font(
|
|
48
|
+
def _register_font(options: dict[str, Any]) -> str:
|
|
95
49
|
"""
|
|
96
|
-
Register a custom font if not
|
|
50
|
+
Register a custom font if specified in options. If not specified, use the default font (Helvetica).
|
|
97
51
|
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
52
|
+
:param options: A dictionary containing the font.
|
|
53
|
+
:returns: The font name.
|
|
104
54
|
"""
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
55
|
+
if font := options.get('font'):
|
|
56
|
+
pdfmetrics.registerFont(TTFont(font, CredentialAsset.get_asset_by_slug(font)))
|
|
117
57
|
|
|
118
|
-
return 'Helvetica'
|
|
58
|
+
return font or 'Helvetica'
|
|
119
59
|
|
|
120
60
|
|
|
121
|
-
def
|
|
61
|
+
def _write_text_on_template(template: any, font: str, username: str, context_name: str, options: dict[str, Any]) -> any:
|
|
122
62
|
"""
|
|
123
|
-
|
|
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
|
|
63
|
+
Prepare a new canvas and write the user and course name onto it.
|
|
185
64
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
65
|
+
:param template: Pdf template.
|
|
66
|
+
:param font: Font name.
|
|
67
|
+
:param username: The name of the user to generate the credential for.
|
|
68
|
+
:param context_name: The name of the learning context.
|
|
69
|
+
:param options: A dictionary documented in the `generate_pdf_credential` function.
|
|
70
|
+
:returns: A canvas with written data.
|
|
224
71
|
"""
|
|
225
|
-
text = _substitute_placeholders(config['text'], placeholders)
|
|
226
72
|
|
|
227
|
-
|
|
228
|
-
|
|
73
|
+
def hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
|
|
74
|
+
"""
|
|
75
|
+
Convert a hexadecimal color code to an RGB tuple with floating-point values.
|
|
229
76
|
|
|
230
|
-
|
|
231
|
-
|
|
77
|
+
:param hex_color: A hexadecimal color string, which can start with '#' and be either 3 or 6 characters long.
|
|
78
|
+
:returns: A tuple representing the RGB color as (red, green, blue), with each value ranging from 0.0 to 1.0.
|
|
79
|
+
"""
|
|
80
|
+
hex_color = hex_color.lstrip('#')
|
|
81
|
+
# Expand shorthand form (e.g. "158" to "115588")
|
|
82
|
+
if len(hex_color) == 3:
|
|
83
|
+
hex_color = ''.join([c * 2 for c in hex_color])
|
|
232
84
|
|
|
233
|
-
|
|
85
|
+
# noinspection PyTypeChecker
|
|
86
|
+
return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
|
|
234
87
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
line_height = config['line_height']
|
|
238
|
-
size = config['size']
|
|
88
|
+
template_width, template_height = template.mediabox[2:]
|
|
89
|
+
pdf_canvas = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height))
|
|
239
90
|
|
|
240
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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)
|
|
91
|
+
# Write the learner name.
|
|
92
|
+
pdf_canvas.setFont(font, options.get('name_size', 32))
|
|
93
|
+
name_color = options.get('name_color', '#000')
|
|
94
|
+
pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
|
|
247
95
|
|
|
96
|
+
name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
|
|
97
|
+
name_y = options.get('name_y', 290)
|
|
98
|
+
pdf_canvas.drawString(name_x, name_y, username)
|
|
248
99
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
issue_date: str,
|
|
254
|
-
options: dict[str, Any],
|
|
255
|
-
) -> Canvas:
|
|
256
|
-
"""
|
|
257
|
-
Prepare a new canvas and write text elements onto it.
|
|
100
|
+
# Write the learning context name.
|
|
101
|
+
pdf_canvas.setFont(font, options.get('context_name_size', 28))
|
|
102
|
+
context_name_color = options.get('context_name_color', '#000')
|
|
103
|
+
pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
|
|
258
104
|
|
|
259
|
-
|
|
260
|
-
|
|
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))
|
|
105
|
+
context_name_y = options.get('context_name_y', 220)
|
|
106
|
+
context_name_line_height = 28 * 1.1
|
|
268
107
|
|
|
269
|
-
#
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
108
|
+
# Split the learning context name into lines and write each of them in the center of the template.
|
|
109
|
+
for line_number, line in enumerate(context_name.split('\n')):
|
|
110
|
+
line_x = (template_width - pdf_canvas.stringWidth(line)) / 2
|
|
111
|
+
line_y = context_name_y - (line_number * context_name_line_height)
|
|
112
|
+
pdf_canvas.drawString(line_x, line_y, line)
|
|
275
113
|
|
|
276
|
-
#
|
|
277
|
-
|
|
114
|
+
# Write the issue date.
|
|
115
|
+
issue_date = get_localized_credential_date()
|
|
116
|
+
pdf_canvas.setFont(font, 12)
|
|
117
|
+
issue_date_color = options.get('issue_date_color', '#000')
|
|
118
|
+
pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
|
|
278
119
|
|
|
279
|
-
|
|
280
|
-
|
|
120
|
+
issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
|
|
121
|
+
issue_date_y = options.get('issue_date_y', 120)
|
|
122
|
+
pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
|
|
281
123
|
|
|
282
124
|
return pdf_canvas
|
|
283
125
|
|
|
@@ -326,7 +168,7 @@ def generate_pdf_credential(
|
|
|
326
168
|
credential_uuid: UUID,
|
|
327
169
|
options: dict[str, Any],
|
|
328
170
|
) -> str:
|
|
329
|
-
|
|
171
|
+
"""
|
|
330
172
|
Generate a PDF credential.
|
|
331
173
|
|
|
332
174
|
:param learning_context_key: The ID of the course or learning path the credential is for.
|
|
@@ -336,66 +178,34 @@ def generate_pdf_credential(
|
|
|
336
178
|
:returns: The URL of the saved credential.
|
|
337
179
|
|
|
338
180
|
Options:
|
|
339
|
-
|
|
340
|
-
-
|
|
341
|
-
|
|
342
|
-
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
}
|
|
181
|
+
- template: The path to the PDF template file.
|
|
182
|
+
- template_two_lines: The path to the PDF template file for two-line context names.
|
|
183
|
+
A two-line context name is specified by using a semicolon as a separator.
|
|
184
|
+
- font: The name of the font to use.
|
|
185
|
+
- name_y: The Y coordinate of the name on the credential (vertical position on the template).
|
|
186
|
+
- name_color: The color of the name on the credential (hexadecimal color code).
|
|
187
|
+
- name_size: The font size of the name on the credential. The default value is 32.
|
|
188
|
+
- context_name: Specify the custom course or Learning Path name.
|
|
189
|
+
- context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
|
|
190
|
+
- context_name_color: The color of the context name on the credential (hexadecimal color code).
|
|
191
|
+
- context_name_size: The font size of the context name on the credential. The default value is 28.
|
|
192
|
+
- issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
|
|
193
|
+
- issue_date_color: The color of the issue date on the credential (hexadecimal color code).
|
|
374
194
|
"""
|
|
375
195
|
log.info("Starting credential generation for user %s", user.id)
|
|
376
196
|
|
|
377
197
|
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)
|
|
198
|
+
context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
|
|
393
199
|
|
|
394
200
|
# Get template from the CredentialAsset.
|
|
395
|
-
|
|
201
|
+
# HACK: We support two-line strings by using a semicolon as a separator.
|
|
202
|
+
if ';' in context_name and (template_path := options.get('template_two_lines')):
|
|
203
|
+
template_file = CredentialAsset.get_asset_by_slug(template_path)
|
|
204
|
+
context_name = context_name.replace(';', '\n')
|
|
205
|
+
else:
|
|
206
|
+
template_file = CredentialAsset.get_asset_by_slug(options['template'])
|
|
396
207
|
|
|
397
|
-
|
|
398
|
-
issue_date = get_localized_credential_date()
|
|
208
|
+
font = _register_font(options)
|
|
399
209
|
|
|
400
210
|
# Load the PDF template.
|
|
401
211
|
with template_file.open('rb') as template_file:
|
|
@@ -403,8 +213,8 @@ def generate_pdf_credential(
|
|
|
403
213
|
|
|
404
214
|
credential = PdfWriter()
|
|
405
215
|
|
|
406
|
-
# Create a new canvas, prepare the page and write the data
|
|
407
|
-
pdf_canvas = _write_text_on_template(template, username, context_name,
|
|
216
|
+
# Create a new canvas, prepare the page and write the data
|
|
217
|
+
pdf_canvas = _write_text_on_template(template, font, username, context_name, options)
|
|
408
218
|
|
|
409
219
|
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
410
220
|
template.merge_page(overlay_pdf.pages[0])
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Generated by Django 4.2.25 on 2025-10-31 17:43
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def backfill_credential_fields(apps, schema_editor):
|
|
8
|
+
"""Generate verification UUIDs and backfill learning_context_name for all existing credentials."""
|
|
9
|
+
from learning_credentials.compat import get_learning_context_name
|
|
10
|
+
|
|
11
|
+
Credential = apps.get_model("learning_credentials", "Credential")
|
|
12
|
+
for credential in Credential.objects.all():
|
|
13
|
+
credential.verify_uuid = uuid.uuid4()
|
|
14
|
+
credential.learning_context_name = get_learning_context_name(credential.learning_context_key)
|
|
15
|
+
credential.save(update_fields=["verify_uuid", "learning_context_name"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Migration(migrations.Migration):
|
|
19
|
+
dependencies = [
|
|
20
|
+
("learning_credentials", "0006_cleanup_openedx_certificates_tables"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
operations = [
|
|
24
|
+
migrations.AlterUniqueTogether(
|
|
25
|
+
name="credential",
|
|
26
|
+
unique_together=set(),
|
|
27
|
+
),
|
|
28
|
+
migrations.AlterField(
|
|
29
|
+
model_name="credential",
|
|
30
|
+
name="user_full_name",
|
|
31
|
+
field=models.CharField(
|
|
32
|
+
editable=False,
|
|
33
|
+
help_text="User receiving the credential. This field is used for validation purposes.",
|
|
34
|
+
max_length=255,
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
migrations.AddField(
|
|
38
|
+
model_name="credential",
|
|
39
|
+
name="invalidated_at",
|
|
40
|
+
field=models.DateTimeField(
|
|
41
|
+
editable=False,
|
|
42
|
+
help_text="Timestamp when the credential was invalidated",
|
|
43
|
+
null=True,
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
migrations.AddField(
|
|
47
|
+
model_name="credential",
|
|
48
|
+
name="invalidation_reason",
|
|
49
|
+
field=models.CharField(
|
|
50
|
+
blank=True,
|
|
51
|
+
help_text="Reason for invalidating the credential",
|
|
52
|
+
max_length=255,
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
migrations.AddField(
|
|
56
|
+
model_name="credential",
|
|
57
|
+
name="learning_context_name",
|
|
58
|
+
field=models.CharField(
|
|
59
|
+
editable=False,
|
|
60
|
+
help_text="Name of the learning context for which the credential was issued. This field is used for validation purposes.",
|
|
61
|
+
max_length=255,
|
|
62
|
+
null=True,
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
migrations.AddField(
|
|
66
|
+
model_name="credential",
|
|
67
|
+
name="verify_uuid",
|
|
68
|
+
field=models.UUIDField(
|
|
69
|
+
default=uuid.uuid4,
|
|
70
|
+
editable=False,
|
|
71
|
+
help_text="UUID used for verifying the credential",
|
|
72
|
+
null=True,
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
migrations.RunPython(backfill_credential_fields, reverse_code=migrations.RunPython.noop),
|
|
76
|
+
migrations.AlterField(
|
|
77
|
+
model_name="credential",
|
|
78
|
+
name="learning_context_name",
|
|
79
|
+
field=models.CharField(
|
|
80
|
+
editable=False,
|
|
81
|
+
help_text="Name of the learning context for which the credential was issued. This field is used for validation purposes.",
|
|
82
|
+
max_length=255,
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
migrations.AlterField(
|
|
86
|
+
model_name="credential",
|
|
87
|
+
name="verify_uuid",
|
|
88
|
+
field=models.UUIDField(
|
|
89
|
+
default=uuid.uuid4,
|
|
90
|
+
editable=False,
|
|
91
|
+
help_text="UUID used for verifying the credential",
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
]
|
learning_credentials/models.py
CHANGED
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
-
import uuid
|
|
7
|
+
import uuid as uuid_lib
|
|
8
8
|
from importlib import import_module
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import TYPE_CHECKING, Self
|
|
@@ -16,6 +16,7 @@ from django.core.exceptions import ValidationError
|
|
|
16
16
|
from django.db import models
|
|
17
17
|
from django.db.models.signals import post_delete
|
|
18
18
|
from django.dispatch import receiver
|
|
19
|
+
from django.utils import timezone
|
|
19
20
|
from django.utils.translation import gettext_lazy as _
|
|
20
21
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
21
22
|
from edx_ace import Message, Recipient, ace
|
|
@@ -33,25 +34,6 @@ if TYPE_CHECKING: # pragma: no cover
|
|
|
33
34
|
log = logging.getLogger(__name__)
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
def _deep_merge(base: dict, override: dict) -> dict:
|
|
37
|
-
"""
|
|
38
|
-
Deep merge two dictionaries.
|
|
39
|
-
|
|
40
|
-
Values from `override` take precedence. Nested dictionaries are merged recursively.
|
|
41
|
-
|
|
42
|
-
:param base: The base dictionary.
|
|
43
|
-
:param override: The dictionary with overriding values.
|
|
44
|
-
:return: A new dictionary with merged values.
|
|
45
|
-
"""
|
|
46
|
-
result = base.copy()
|
|
47
|
-
for key, value in override.items():
|
|
48
|
-
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
49
|
-
result[key] = _deep_merge(result[key], value)
|
|
50
|
-
else:
|
|
51
|
-
result[key] = value
|
|
52
|
-
return result
|
|
53
|
-
|
|
54
|
-
|
|
55
37
|
class CredentialType(TimeStampedModel):
|
|
56
38
|
"""
|
|
57
39
|
Model to store global credential configurations for each type.
|
|
@@ -147,8 +129,9 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
147
129
|
self.periodic_task.args = json.dumps([self.id])
|
|
148
130
|
self.periodic_task.save()
|
|
149
131
|
|
|
132
|
+
# Replace the return type with `QuerySet[Self]` after migrating to Python 3.10+.
|
|
150
133
|
@classmethod
|
|
151
|
-
def get_enabled_configurations(cls) -> QuerySet[
|
|
134
|
+
def get_enabled_configurations(cls) -> QuerySet[CredentialConfiguration]:
|
|
152
135
|
"""
|
|
153
136
|
Get the list of enabled configurations.
|
|
154
137
|
|
|
@@ -194,10 +177,10 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
194
177
|
module = import_module(module_path)
|
|
195
178
|
func = getattr(module, func_name)
|
|
196
179
|
|
|
197
|
-
custom_options =
|
|
180
|
+
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
198
181
|
return func(self.learning_context_key, custom_options)
|
|
199
182
|
|
|
200
|
-
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
|
|
183
|
+
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0) -> Credential:
|
|
201
184
|
"""
|
|
202
185
|
Celery task for processing a single user's credential.
|
|
203
186
|
|
|
@@ -208,19 +191,24 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
208
191
|
Args:
|
|
209
192
|
user_id: The ID of the user to process the credential for.
|
|
210
193
|
celery_task_id (optional): The ID of the Celery task that is running this function.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The generated Credential object.
|
|
211
197
|
"""
|
|
212
198
|
user = get_user_model().objects.get(id=user_id)
|
|
213
199
|
# Use the name from the profile if it is not empty. Otherwise, use the first and last name.
|
|
214
200
|
# We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
|
|
215
201
|
user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
|
|
216
|
-
|
|
202
|
+
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
203
|
+
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
217
204
|
|
|
218
|
-
credential, _ = Credential.objects.update_or_create(
|
|
205
|
+
credential, _ = Credential.objects.exclude(status=Credential.Status.INVALIDATED).update_or_create(
|
|
219
206
|
user_id=user_id,
|
|
220
207
|
learning_context_key=self.learning_context_key,
|
|
221
208
|
credential_type=self.credential_type.name,
|
|
222
209
|
defaults={
|
|
223
210
|
'user_full_name': user_full_name,
|
|
211
|
+
'learning_context_name': learning_context_name,
|
|
224
212
|
'status': Credential.Status.GENERATING,
|
|
225
213
|
'generation_task_id': celery_task_id,
|
|
226
214
|
},
|
|
@@ -246,6 +234,8 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
246
234
|
if user.is_active and user.has_usable_password():
|
|
247
235
|
credential.send_email()
|
|
248
236
|
|
|
237
|
+
return credential
|
|
238
|
+
|
|
249
239
|
|
|
250
240
|
# noinspection PyUnusedLocal
|
|
251
241
|
@receiver(post_delete, sender=CredentialConfiguration)
|
|
@@ -280,16 +270,33 @@ class Credential(TimeStampedModel):
|
|
|
280
270
|
|
|
281
271
|
uuid = models.UUIDField(
|
|
282
272
|
primary_key=True,
|
|
283
|
-
default=
|
|
273
|
+
default=uuid_lib.uuid4,
|
|
284
274
|
editable=False,
|
|
285
275
|
help_text=_('Auto-generated UUID of the credential'),
|
|
286
276
|
)
|
|
277
|
+
verify_uuid = models.UUIDField(
|
|
278
|
+
default=uuid_lib.uuid4,
|
|
279
|
+
editable=False,
|
|
280
|
+
help_text=_('UUID used for verifying the credential'),
|
|
281
|
+
)
|
|
287
282
|
user_id = models.IntegerField(help_text=_('ID of the user receiving the credential'))
|
|
288
|
-
user_full_name = models.CharField(
|
|
283
|
+
user_full_name = models.CharField(
|
|
284
|
+
max_length=255,
|
|
285
|
+
editable=False,
|
|
286
|
+
help_text=_('User receiving the credential. This field is used for validation purposes.'),
|
|
287
|
+
)
|
|
289
288
|
learning_context_key = LearningContextKeyField(
|
|
290
289
|
max_length=255,
|
|
291
290
|
help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
|
|
292
291
|
)
|
|
292
|
+
learning_context_name = models.CharField(
|
|
293
|
+
max_length=255,
|
|
294
|
+
editable=False,
|
|
295
|
+
help_text=_(
|
|
296
|
+
'Name of the learning context for which the credential was issued. '
|
|
297
|
+
'This field is used for validation purposes.'
|
|
298
|
+
),
|
|
299
|
+
)
|
|
293
300
|
credential_type = models.CharField(max_length=255, help_text=_('Type of the credential'))
|
|
294
301
|
status = models.CharField(
|
|
295
302
|
max_length=32,
|
|
@@ -300,16 +307,26 @@ class Credential(TimeStampedModel):
|
|
|
300
307
|
download_url = models.URLField(blank=True, help_text=_('URL of the generated credential PDF (e.g., to S3)'))
|
|
301
308
|
legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the credential imported from another system'))
|
|
302
309
|
generation_task_id = models.CharField(max_length=255, help_text=_('Task ID from the Celery queue'))
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
310
|
+
invalidated_at = models.DateTimeField(
|
|
311
|
+
null=True, editable=False, help_text=_('Timestamp when the credential was invalidated')
|
|
312
|
+
)
|
|
313
|
+
invalidation_reason = models.CharField(
|
|
314
|
+
max_length=255, blank=True, help_text=_('Reason for invalidating the credential')
|
|
315
|
+
)
|
|
306
316
|
|
|
307
317
|
def __str__(self): # noqa: D105
|
|
308
318
|
return f"{self.credential_type} for {self.user_full_name} in {self.learning_context_key}"
|
|
309
319
|
|
|
320
|
+
def save(self, *args, **kwargs):
|
|
321
|
+
"""If the invalidation reason is set, update the status and timestamp."""
|
|
322
|
+
if self.invalidation_reason and self.status != Credential.Status.INVALIDATED:
|
|
323
|
+
self.status = Credential.Status.INVALIDATED
|
|
324
|
+
if self.status == Credential.Status.INVALIDATED and not self.invalidated_at:
|
|
325
|
+
self.invalidated_at = timezone.now()
|
|
326
|
+
super().save(*args, **kwargs)
|
|
327
|
+
|
|
310
328
|
def send_email(self):
|
|
311
329
|
"""Send a credential link to the student."""
|
|
312
|
-
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
313
330
|
user = get_user_model().objects.get(id=self.user_id)
|
|
314
331
|
msg = Message(
|
|
315
332
|
name="certificate_generated",
|
|
@@ -318,12 +335,26 @@ class Credential(TimeStampedModel):
|
|
|
318
335
|
language='en',
|
|
319
336
|
context={
|
|
320
337
|
'certificate_link': self.download_url,
|
|
321
|
-
'course_name': learning_context_name,
|
|
338
|
+
'course_name': self.learning_context_name,
|
|
322
339
|
'platform_name': settings.PLATFORM_NAME,
|
|
323
340
|
},
|
|
324
341
|
)
|
|
325
342
|
ace.send(msg)
|
|
326
343
|
|
|
344
|
+
def reissue(self) -> Self:
|
|
345
|
+
"""Invalidate the current credential and create a new one."""
|
|
346
|
+
config = CredentialConfiguration.objects.get(
|
|
347
|
+
learning_context_key=self.learning_context_key,
|
|
348
|
+
credential_type__name=self.credential_type,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if self.invalidation_reason:
|
|
352
|
+
self.invalidation_reason += '\n'
|
|
353
|
+
self.invalidation_reason += 'Reissued'
|
|
354
|
+
self.save()
|
|
355
|
+
|
|
356
|
+
return config.generate_credential_for_user(self.user_id)
|
|
357
|
+
|
|
327
358
|
|
|
328
359
|
class CredentialAsset(TimeStampedModel):
|
|
329
360
|
"""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{% extends "main_django.html" %}
|
|
2
|
+
{% load i18n %}
|
|
3
|
+
{% load static %}
|
|
4
|
+
|
|
5
|
+
{% block bodyextra %}
|
|
6
|
+
<div id="content" class="credential-verification-container content-wrapper main-container">
|
|
7
|
+
<section class="container">
|
|
8
|
+
<h1>{% trans "Credential verification page" %}</h1>
|
|
9
|
+
<form id="credentialVerificationForm" method="post">
|
|
10
|
+
{% csrf_token %}
|
|
11
|
+
<div class="form-group">
|
|
12
|
+
<label for="credentialID">{% trans 'Credential ID' %}</label>
|
|
13
|
+
<input type="text" id="credentialID" class="form-control" name="credentialID" required>
|
|
14
|
+
<div id="formError" class="text-danger mt-2" style="display: none;"></div>
|
|
15
|
+
</div>
|
|
16
|
+
<button type="submit" class="btn btn-primary">
|
|
17
|
+
{% trans 'Verify' %}
|
|
18
|
+
</button>
|
|
19
|
+
</form>
|
|
20
|
+
|
|
21
|
+
<div id="verificationResults" class="p-0 border-0 mt-5"></div>
|
|
22
|
+
</section>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<script>
|
|
26
|
+
document.getElementById('credentialVerificationForm').onsubmit = async function(event) {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
let formError = document.getElementById('formError');
|
|
29
|
+
let verificationResults = document.getElementById('verificationResults');
|
|
30
|
+
|
|
31
|
+
let credentialId = document.getElementById('credentialID').value;
|
|
32
|
+
var url = '/api/learning_credentials/v1/metadata/' + encodeURIComponent(credentialId);
|
|
33
|
+
|
|
34
|
+
// Hide previous error messages.
|
|
35
|
+
formError.style.display = 'none';
|
|
36
|
+
|
|
37
|
+
await fetch(url)
|
|
38
|
+
.then(response => response.json())
|
|
39
|
+
.then(data => {
|
|
40
|
+
const table = document.createElement('table');
|
|
41
|
+
table.className = 'table table-striped table-bordered';
|
|
42
|
+
const tbody = document.createElement('tbody');
|
|
43
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
44
|
+
if (!!value) {
|
|
45
|
+
const row = tbody.insertRow();
|
|
46
|
+
const cellKey = row.insertCell();
|
|
47
|
+
const cellValue = row.insertCell();
|
|
48
|
+
cellKey.textContent = key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
49
|
+
cellValue.textContent = value;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
table.appendChild(tbody);
|
|
53
|
+
verificationResults.textContent = '';
|
|
54
|
+
verificationResults.appendChild(table);
|
|
55
|
+
})
|
|
56
|
+
.catch((error) => {
|
|
57
|
+
console.error('Error:', error);
|
|
58
|
+
verificationResults.textContent = '';
|
|
59
|
+
formError.innerHTML = "An error occurred during verification. Please check the Credential ID and try again.";
|
|
60
|
+
formError.style.display = 'block';
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
$(document).ready(function(){
|
|
66
|
+
// Fill the credential ID field with URL 'credential' query param.
|
|
67
|
+
let urlParams = new URLSearchParams(window.location.search);
|
|
68
|
+
const uuid = urlParams.get('credential');
|
|
69
|
+
|
|
70
|
+
if (uuid) {
|
|
71
|
+
$('#credentialID').val(uuid);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If the field is filled, submit the form automatically.
|
|
75
|
+
if ($('#credentialID').val()) {
|
|
76
|
+
$('#credentialVerificationForm').submit();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Focus the field for user convenience.
|
|
80
|
+
$('#credentialID').focus();
|
|
81
|
+
});
|
|
82
|
+
</script>
|
|
83
|
+
{% endblock %}
|
learning_credentials/urls.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""URLs for learning_credentials."""
|
|
2
2
|
|
|
3
3
|
from django.urls import include, path
|
|
4
|
+
from django.views.generic import TemplateView
|
|
4
5
|
|
|
5
6
|
from .api import urls as api_urls
|
|
6
7
|
|
|
7
8
|
urlpatterns = [
|
|
8
9
|
path('api/learning_credentials/', include(api_urls)),
|
|
10
|
+
path('learning_credentials/verify/', TemplateView.as_view(template_name="learning_credentials/verify.html")),
|
|
9
11
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.0rc1
|
|
4
4
|
Summary: A pluggable service for preparing Open edX credentials.
|
|
5
5
|
Author-email: OpenCraft <help@opencraft.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -11,7 +11,6 @@ Keywords: Python,edx,credentials,django
|
|
|
11
11
|
Classifier: Development Status :: 5 - Production/Stable
|
|
12
12
|
Classifier: Framework :: Django
|
|
13
13
|
Classifier: Framework :: Django :: 4.2
|
|
14
|
-
Classifier: Framework :: Django :: 5.2
|
|
15
14
|
Classifier: Intended Audience :: Developers
|
|
16
15
|
Classifier: Natural Language :: English
|
|
17
16
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -177,38 +176,14 @@ Unreleased
|
|
|
177
176
|
|
|
178
177
|
*
|
|
179
178
|
|
|
180
|
-
0.4.0 -
|
|
179
|
+
0.4.0 - 2025-11-03
|
|
181
180
|
******************
|
|
182
181
|
|
|
183
182
|
Added
|
|
184
183
|
=====
|
|
185
184
|
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* Global ``defaults`` configuration for font, color, and character spacing.
|
|
189
|
-
|
|
190
|
-
Modified
|
|
191
|
-
========
|
|
192
|
-
|
|
193
|
-
* Migrated generator options from flat format (``name_y``, ``context_name_color``, etc.) to structured ``text_elements`` format.
|
|
194
|
-
|
|
195
|
-
0.3.1 - 2025-12-15
|
|
196
|
-
******************
|
|
197
|
-
|
|
198
|
-
Added
|
|
199
|
-
=====
|
|
200
|
-
|
|
201
|
-
* Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings).
|
|
202
|
-
* Support for specifying individual fonts for PDF text elements.
|
|
203
|
-
* Support for \n in learning context names in PDF certificates.
|
|
204
|
-
* Options for uppercase name and issue date in PDF certificates.
|
|
205
|
-
* Option for defining character spacing for issue date in PDF certificates.
|
|
206
|
-
* Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
|
|
207
|
-
|
|
208
|
-
Modified
|
|
209
|
-
========
|
|
210
|
-
|
|
211
|
-
* Replaced ``template_two_lines`` with ``template_multiline``.
|
|
185
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
186
|
+
* Option to invalidate issued credentials.
|
|
212
187
|
|
|
213
188
|
0.3.0 - 2025-09-17
|
|
214
189
|
******************
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
learning_credentials/__init__.py,sha256=8Q0-3Hdnfmcj41EKu1GSfzEfwWcYNDlItyEEke2r9bs,62
|
|
2
|
-
learning_credentials/admin.py,sha256=
|
|
2
|
+
learning_credentials/admin.py,sha256=gLVpCn5oOHLL3u-wnx4R1yJXfar1Z32vk8zcTVjtBFY,11791
|
|
3
3
|
learning_credentials/apps.py,sha256=trdQxe-JRhUdUaOQoQWiGL1sn6I1sfDiTvdCwy8yGuw,1037
|
|
4
|
-
learning_credentials/compat.py,sha256=
|
|
4
|
+
learning_credentials/compat.py,sha256=OvgzxnhGG5A7Ij65mBv3kyaYojKKQ381RbUGsWXtyOg,4651
|
|
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=KCB166rOx-bwnm6kOc-Vz2HomGdJrQXqglv2MPVBF_Q,9075
|
|
7
|
+
learning_credentials/models.py,sha256=DncnadwVrmVcbrka9LBmKvTMqLq8os_wHXmWc3Zet6s,17949
|
|
8
8
|
learning_credentials/processors.py,sha256=LkdjmkLBnXc9qeMcksB1T8AQ5ZhYaECyQO__KfHB_aU,15212
|
|
9
9
|
learning_credentials/tasks.py,sha256=byoFEUvN_ayVaU5K5SlEiA7vu9BRPaSSmKnB9g5toec,1927
|
|
10
|
-
learning_credentials/urls.py,sha256=
|
|
10
|
+
learning_credentials/urls.py,sha256=KXZtvPXXl2X_nTREWaCFxcAgY2XET1eWRbcx2rq_6eI,348
|
|
11
11
|
learning_credentials/api/__init__.py,sha256=q8sLFfwo5RwQu8FY6BJUL_Jrt3TUojbZK-Zlw9v08EM,40
|
|
12
12
|
learning_credentials/api/urls.py,sha256=wW27hrrJ7D_h8PbFDbSxzeaneNla0R-56gjKy9zISG8,216
|
|
13
13
|
learning_credentials/api/v1/__init__.py,sha256=A7ZqENtM4QM1A7j_cAfnzw4zn0kuyfXSWtylFIE0_f8,43
|
|
14
14
|
learning_credentials/api/v1/permissions.py,sha256=TqM50TpR3JGUgZgIgKZF0-R_g1_P2V9bqKzYXgk-VvY,3436
|
|
15
|
-
learning_credentials/api/v1/
|
|
16
|
-
learning_credentials/api/v1/
|
|
15
|
+
learning_credentials/api/v1/serializers.py,sha256=H7l-vRTwLBplveCBjnNgSawJqpSVskeHTz7wpUiNB3g,417
|
|
16
|
+
learning_credentials/api/v1/urls.py,sha256=RytArViuKZQkWs46sk58VfaVCwLV-QrgTG7cQLE_NtU,408
|
|
17
|
+
learning_credentials/api/v1/views.py,sha256=CJEVPwCXs_ii463agPRpJeX6NCgyyFX9ZIBJh0BAc9I,4926
|
|
17
18
|
learning_credentials/conf/locale/config.yaml,sha256=jPen2DmckNDKK30axCKEd2Q2ha9oOG3IBxrJ63Pvznk,2280
|
|
18
19
|
learning_credentials/migrations/0001_initial.py,sha256=61EvThCv-0UAnhCE5feyQVfjRodbp-6cDaAr4CY5PMA,8435
|
|
19
20
|
learning_credentials/migrations/0002_migrate_to_learning_credentials.py,sha256=vUhcnQKDdwOsppkXsjz2zZwOGMwIJ-fkQRsaj-K7l1o,1779
|
|
@@ -21,20 +22,21 @@ learning_credentials/migrations/0003_rename_certificates_to_credentials.py,sha25
|
|
|
21
22
|
learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py,sha256=5KaXvASl69qbEaHX5_Ty_3Dr7K4WV6p8VWOx72yJnTU,1919
|
|
22
23
|
learning_credentials/migrations/0005_rename_processors_and_generators.py,sha256=5UCqjq-CBJnRo1qBAoWs91ngyEuSMN8_tQtfzsuR5SI,5271
|
|
23
24
|
learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py,sha256=aJs_gOP4TmW9J-Dmr21m94jBfLQxzjAu6-ua7x4uYLE,727
|
|
24
|
-
learning_credentials/migrations/
|
|
25
|
+
learning_credentials/migrations/0007_validation.py,sha256=3vzjwDDFlwtFWmaCQ1wYfyl7Jj-cJB5Jpfq3cGo_vuI,3391
|
|
25
26
|
learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
27
|
learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
|
|
27
28
|
learning_credentials/settings/common.py,sha256=Cck-nyFt11G1NLiz-bHfKJp8MV6sDZGqTwdbC8_1WE0,360
|
|
28
29
|
learning_credentials/settings/production.py,sha256=6P0P7JxbpWNsk4Lk8lfyxHirOWMgU4UWOb3EYKLjiVQ,542
|
|
29
30
|
learning_credentials/templates/learning_credentials/base.html,sha256=wtjBYqfHmOnyEY5tN3VGOmzYLsOD24MXdEUhTZ7OmwI,662
|
|
31
|
+
learning_credentials/templates/learning_credentials/verify.html,sha256=vXTiZMZkTLhp6cnqKBJcQnDu7qUxLvrTzN2m1KM8_9k,3414
|
|
30
32
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html,sha256=t-i1Ra9AC4pX-rPRifDJIvBBZuxCxdrFqg1NKTjHBOk,813
|
|
31
33
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt,sha256=IF_x8aF_-dORlQB-RCh0IkJDl2ktD489E8qGgLe9M3Y,677
|
|
32
34
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
|
|
33
35
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
36
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt,sha256=S7Hc5T_sZSsSBXm5_H5HBNNv16Ohl0oZn0nVqqeWL0g,132
|
|
35
|
-
learning_credentials-0.4.
|
|
36
|
-
learning_credentials-0.4.
|
|
37
|
-
learning_credentials-0.4.
|
|
38
|
-
learning_credentials-0.4.
|
|
39
|
-
learning_credentials-0.4.
|
|
40
|
-
learning_credentials-0.4.
|
|
37
|
+
learning_credentials-0.4.0rc1.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
|
|
38
|
+
learning_credentials-0.4.0rc1.dist-info/METADATA,sha256=AMzNwr8Uif8XG05ihc6hM0FmRXWQB7PVG8tdJSx8VF0,7298
|
|
39
|
+
learning_credentials-0.4.0rc1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
40
|
+
learning_credentials-0.4.0rc1.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
|
|
41
|
+
learning_credentials-0.4.0rc1.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
|
|
42
|
+
learning_credentials-0.4.0rc1.dist-info/RECORD,,
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
"""Migration to convert credential options from flat format to text_elements format."""
|
|
2
|
-
|
|
3
|
-
from django.db import migrations
|
|
4
|
-
|
|
5
|
-
# Mapping from old option names to new text_elements structure.
|
|
6
|
-
# Format: (old_key, element_key, property_name)
|
|
7
|
-
_OPTION_MAPPINGS = [
|
|
8
|
-
# Name element mappings.
|
|
9
|
-
('name_y', 'name', 'y'),
|
|
10
|
-
('name_color', 'name', 'color'),
|
|
11
|
-
('name_size', 'name', 'size'),
|
|
12
|
-
('name_font', 'name', 'font'),
|
|
13
|
-
('name_uppercase', 'name', 'uppercase'),
|
|
14
|
-
# Context element mappings.
|
|
15
|
-
('context_name', 'context', 'text'),
|
|
16
|
-
('context_name_y', 'context', 'y'),
|
|
17
|
-
('context_name_color', 'context', 'color'),
|
|
18
|
-
('context_name_size', 'context', 'size'),
|
|
19
|
-
('context_name_font', 'context', 'font'),
|
|
20
|
-
# Date element mappings.
|
|
21
|
-
('issue_date_y', 'date', 'y'),
|
|
22
|
-
('issue_date_color', 'date', 'color'),
|
|
23
|
-
('issue_date_size', 'date', 'size'),
|
|
24
|
-
('issue_date_font', 'date', 'font'),
|
|
25
|
-
('issue_date_char_space', 'date', 'char_space'),
|
|
26
|
-
('issue_date_uppercase', 'date', 'uppercase'),
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _convert_to_text_elements(options):
|
|
31
|
-
"""
|
|
32
|
-
Convert old flat options format to new text_elements format in-place.
|
|
33
|
-
|
|
34
|
-
:param options: The options dictionary to convert.
|
|
35
|
-
"""
|
|
36
|
-
if not options:
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
# If already in new format, skip conversion.
|
|
40
|
-
if 'text_elements' in options or 'defaults' in options:
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
text_elements = {}
|
|
44
|
-
|
|
45
|
-
# Handle template_two_lines -> template_multiline rename.
|
|
46
|
-
if 'template_two_lines' in options:
|
|
47
|
-
template_two_lines = options.pop('template_two_lines')
|
|
48
|
-
# Only set template_multiline if it doesn't already exist.
|
|
49
|
-
if 'template_multiline' not in options:
|
|
50
|
-
options['template_multiline'] = template_two_lines
|
|
51
|
-
|
|
52
|
-
# Handle global font -> defaults.font.
|
|
53
|
-
if 'font' in options:
|
|
54
|
-
options['defaults'] = {'font': options.pop('font')}
|
|
55
|
-
|
|
56
|
-
# Convert element-specific options by popping them from the options dict.
|
|
57
|
-
for old_key, element_key, prop_name in _OPTION_MAPPINGS:
|
|
58
|
-
if old_key in options:
|
|
59
|
-
if element_key not in text_elements:
|
|
60
|
-
text_elements[element_key] = {}
|
|
61
|
-
text_elements[element_key][prop_name] = options.pop(old_key)
|
|
62
|
-
|
|
63
|
-
# Only add text_elements if we have any.
|
|
64
|
-
if text_elements:
|
|
65
|
-
options['text_elements'] = text_elements
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _convert_to_flat_format(options):
|
|
69
|
-
"""
|
|
70
|
-
Convert new text_elements format back to old flat options format in-place.
|
|
71
|
-
|
|
72
|
-
:param options: The options dictionary to convert.
|
|
73
|
-
"""
|
|
74
|
-
if not options:
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
# If not in new format, skip conversion.
|
|
78
|
-
if 'text_elements' not in options and 'defaults' not in options:
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
# Handle template_multiline -> template_two_lines for backward compatibility.
|
|
82
|
-
if 'template_multiline' in options:
|
|
83
|
-
options['template_two_lines'] = options.pop('template_multiline')
|
|
84
|
-
|
|
85
|
-
# Handle defaults.font -> font.
|
|
86
|
-
defaults = options.pop('defaults', {})
|
|
87
|
-
if 'font' in defaults:
|
|
88
|
-
options['font'] = defaults['font']
|
|
89
|
-
|
|
90
|
-
# Convert text_elements back to flat format.
|
|
91
|
-
text_elements = options.pop('text_elements', {})
|
|
92
|
-
|
|
93
|
-
for old_key, element_key, prop_name in _OPTION_MAPPINGS:
|
|
94
|
-
element_config = text_elements.get(element_key, {})
|
|
95
|
-
if isinstance(element_config, dict) and prop_name in element_config:
|
|
96
|
-
options[old_key] = element_config[prop_name]
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _migrate_all_options(apps, convert_func):
|
|
100
|
-
"""
|
|
101
|
-
Apply a conversion function to all credential configurations.
|
|
102
|
-
|
|
103
|
-
:param apps: Django apps registry.
|
|
104
|
-
:param convert_func: Function to apply to each custom_options dict.
|
|
105
|
-
"""
|
|
106
|
-
CredentialType = apps.get_model('learning_credentials', 'CredentialType')
|
|
107
|
-
CredentialConfiguration = apps.get_model('learning_credentials', 'CredentialConfiguration')
|
|
108
|
-
|
|
109
|
-
for credential_type in CredentialType.objects.all():
|
|
110
|
-
if credential_type.custom_options:
|
|
111
|
-
convert_func(credential_type.custom_options)
|
|
112
|
-
credential_type.save()
|
|
113
|
-
|
|
114
|
-
for config in CredentialConfiguration.objects.all():
|
|
115
|
-
if config.custom_options:
|
|
116
|
-
convert_func(config.custom_options)
|
|
117
|
-
config.save()
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def _migrate_forward(apps, schema_editor):
|
|
121
|
-
"""Convert all credential configurations to the new text_elements format."""
|
|
122
|
-
_migrate_all_options(apps, _convert_to_text_elements)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _migrate_backward(apps, schema_editor):
|
|
126
|
-
"""Convert all credential configurations back to the old flat format."""
|
|
127
|
-
_migrate_all_options(apps, _convert_to_flat_format)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
class Migration(migrations.Migration):
|
|
131
|
-
|
|
132
|
-
dependencies = [
|
|
133
|
-
('learning_credentials', '0006_cleanup_openedx_certificates_tables'),
|
|
134
|
-
]
|
|
135
|
-
|
|
136
|
-
operations = [
|
|
137
|
-
migrations.RunPython(_migrate_forward, _migrate_backward),
|
|
138
|
-
]
|
{learning_credentials-0.4.0.dist-info → learning_credentials-0.4.0rc1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{learning_credentials-0.4.0.dist-info → learning_credentials-0.4.0rc1.dist-info}/top_level.txt
RENAMED
|
File without changes
|