learning-credentials 0.3.1rc2__tar.gz → 0.4.0rc1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/CHANGELOG.rst +3 -12
- {learning_credentials-0.3.1rc2/learning_credentials.egg-info → learning_credentials-0.4.0rc1}/PKG-INFO +4 -13
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/admin.py +30 -3
- learning_credentials-0.4.0rc1/learning_credentials/api/v1/serializers.py +13 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/api/v1/urls.py +2 -1
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/api/v1/views.py +61 -1
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/compat.py +3 -6
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/generators.py +30 -69
- learning_credentials-0.4.0rc1/learning_credentials/migrations/0007_validation.py +94 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/models.py +60 -11
- learning_credentials-0.4.0rc1/learning_credentials/templates/learning_credentials/verify.html +83 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/urls.py +2 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1/learning_credentials.egg-info}/PKG-INFO +4 -13
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials.egg-info/SOURCES.txt +3 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/pyproject.toml +4 -4
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/tests/test_generators.py +24 -137
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/tests/test_models.py +11 -21
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/tests/test_processors.py +1 -1
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/LICENSE.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/MANIFEST.in +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/README.rst +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/api/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/api/urls.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/api/v1/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/api/v1/permissions.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/apps.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/conf/locale/config.yaml +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/exceptions.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/migrations/0001_initial.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/migrations/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/processors.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/settings/__init__.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/settings/common.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/settings/production.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/tasks.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/templates/learning_credentials/base.html +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials.egg-info/dependency_links.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials.egg-info/entry_points.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials.egg-info/requires.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials.egg-info/top_level.txt +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/setup.cfg +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/tests/test_tasks.py +0 -0
- {learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/tests/test_views.py +0 -0
|
@@ -16,23 +16,14 @@ Unreleased
|
|
|
16
16
|
|
|
17
17
|
*
|
|
18
18
|
|
|
19
|
-
0.
|
|
19
|
+
0.4.0 - 2025-11-03
|
|
20
20
|
******************
|
|
21
21
|
|
|
22
22
|
Added
|
|
23
23
|
=====
|
|
24
24
|
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Support for \n in learning context names in PDF certificates.
|
|
28
|
-
* Options for uppercase name and issue date in PDF certificates.
|
|
29
|
-
* Option for defining character spacing for issue date in PDF certificates.
|
|
30
|
-
* Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
|
|
31
|
-
|
|
32
|
-
Modified
|
|
33
|
-
========
|
|
34
|
-
|
|
35
|
-
* Replaced ``template_two_lines`` with ``template_multiline``.
|
|
25
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
26
|
+
* Option to invalidate issued credentials.
|
|
36
27
|
|
|
37
28
|
0.3.0 - 2025-09-17
|
|
38
29
|
******************
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -176,23 +176,14 @@ Unreleased
|
|
|
176
176
|
|
|
177
177
|
*
|
|
178
178
|
|
|
179
|
-
0.
|
|
179
|
+
0.4.0 - 2025-11-03
|
|
180
180
|
******************
|
|
181
181
|
|
|
182
182
|
Added
|
|
183
183
|
=====
|
|
184
184
|
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* Support for \n in learning context names in PDF certificates.
|
|
188
|
-
* Options for uppercase name and issue date in PDF certificates.
|
|
189
|
-
* Option for defining character spacing for issue date in PDF certificates.
|
|
190
|
-
* Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
|
|
191
|
-
|
|
192
|
-
Modified
|
|
193
|
-
========
|
|
194
|
-
|
|
195
|
-
* Replaced ``template_two_lines`` with ``template_multiline``.
|
|
185
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
186
|
+
* Option to invalidate issued credentials.
|
|
196
187
|
|
|
197
188
|
0.3.0 - 2025-09-17
|
|
198
189
|
******************
|
{learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/admin.py
RENAMED
|
@@ -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')
|
{learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/api/v1/urls.py
RENAMED
|
@@ -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
|
]
|
{learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/api/v1/views.py
RENAMED
|
@@ -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-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/compat.py
RENAMED
|
@@ -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:
|
{learning_credentials-0.3.1rc2 → learning_credentials-0.4.0rc1}/learning_credentials/generators.py
RENAMED
|
@@ -19,9 +19,9 @@ from django.core.files.base import ContentFile
|
|
|
19
19
|
from django.core.files.storage import FileSystemStorage, default_storage
|
|
20
20
|
from pypdf import PdfReader, PdfWriter
|
|
21
21
|
from pypdf.constants import UserAccessPermissions
|
|
22
|
-
from reportlab.pdfbase
|
|
23
|
-
from reportlab.pdfbase.ttfonts import
|
|
24
|
-
from reportlab.pdfgen
|
|
22
|
+
from reportlab.pdfbase import pdfmetrics
|
|
23
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
24
|
+
from reportlab.pdfgen import canvas
|
|
25
25
|
|
|
26
26
|
from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
|
|
27
27
|
from .models import CredentialAsset
|
|
@@ -33,7 +33,6 @@ if TYPE_CHECKING: # pragma: no cover
|
|
|
33
33
|
|
|
34
34
|
from django.contrib.auth.models import User
|
|
35
35
|
from opaque_keys.edx.keys import CourseKey
|
|
36
|
-
from pypdf import PageObject
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
def _get_user_name(user: User) -> str:
|
|
@@ -46,29 +45,25 @@ def _get_user_name(user: User) -> str:
|
|
|
46
45
|
return user.profile.name or f"{user.first_name} {user.last_name}"
|
|
47
46
|
|
|
48
47
|
|
|
49
|
-
def _register_font(
|
|
48
|
+
def _register_font(options: dict[str, Any]) -> str:
|
|
50
49
|
"""
|
|
51
50
|
Register a custom font if specified in options. If not specified, use the default font (Helvetica).
|
|
52
51
|
|
|
53
|
-
:param
|
|
54
|
-
:returns: The font name
|
|
52
|
+
:param options: A dictionary containing the font.
|
|
53
|
+
:returns: The font name.
|
|
55
54
|
"""
|
|
56
|
-
if
|
|
57
|
-
|
|
55
|
+
if font := options.get('font'):
|
|
56
|
+
pdfmetrics.registerFont(TTFont(font, CredentialAsset.get_asset_by_slug(font)))
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
registerFont(TTFont(font_name, CredentialAsset.get_asset_by_slug(font_name)))
|
|
61
|
-
except (FontError, FontNotFoundError, TTFError):
|
|
62
|
-
log.exception("Error registering font %s", font_name)
|
|
63
|
-
else:
|
|
64
|
-
return font_name
|
|
58
|
+
return font or 'Helvetica'
|
|
65
59
|
|
|
66
60
|
|
|
67
|
-
def _write_text_on_template(template:
|
|
61
|
+
def _write_text_on_template(template: any, font: str, username: str, context_name: str, options: dict[str, Any]) -> any:
|
|
68
62
|
"""
|
|
69
63
|
Prepare a new canvas and write the user and course name onto it.
|
|
70
64
|
|
|
71
65
|
:param template: Pdf template.
|
|
66
|
+
:param font: Font name.
|
|
72
67
|
:param username: The name of the user to generate the credential for.
|
|
73
68
|
:param context_name: The name of the learning context.
|
|
74
69
|
:param options: A dictionary documented in the `generate_pdf_credential` function.
|
|
@@ -91,26 +86,19 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
|
|
|
91
86
|
return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
|
|
92
87
|
|
|
93
88
|
template_width, template_height = template.mediabox[2:]
|
|
94
|
-
pdf_canvas = Canvas(io.BytesIO(), pagesize=(template_width, template_height))
|
|
95
|
-
font = _register_font(options.get('font')) or 'Helvetica'
|
|
89
|
+
pdf_canvas = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height))
|
|
96
90
|
|
|
97
91
|
# Write the learner name.
|
|
98
|
-
|
|
99
|
-
username = username.upper()
|
|
100
|
-
|
|
101
|
-
name_font = _register_font(options.get('name_font')) or font
|
|
102
|
-
pdf_canvas.setFont(name_font, options.get('name_size', 32))
|
|
92
|
+
pdf_canvas.setFont(font, options.get('name_size', 32))
|
|
103
93
|
name_color = options.get('name_color', '#000')
|
|
104
94
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
|
|
105
95
|
|
|
106
96
|
name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
|
|
107
97
|
name_y = options.get('name_y', 290)
|
|
108
|
-
|
|
109
98
|
pdf_canvas.drawString(name_x, name_y, username)
|
|
110
99
|
|
|
111
100
|
# Write the learning context name.
|
|
112
|
-
|
|
113
|
-
pdf_canvas.setFont(context_name_font, options.get('context_name_size', 28))
|
|
101
|
+
pdf_canvas.setFont(font, options.get('context_name_size', 28))
|
|
114
102
|
context_name_color = options.get('context_name_color', '#000')
|
|
115
103
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
|
|
116
104
|
|
|
@@ -125,23 +113,13 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
|
|
|
125
113
|
|
|
126
114
|
# Write the issue date.
|
|
127
115
|
issue_date = get_localized_credential_date()
|
|
128
|
-
|
|
129
|
-
issue_date = issue_date.upper()
|
|
130
|
-
|
|
131
|
-
issue_date_font = _register_font(options.get('issue_date_font')) or font
|
|
132
|
-
pdf_canvas.setFont(issue_date_font, options.get('issue_date_size', 12))
|
|
116
|
+
pdf_canvas.setFont(font, 12)
|
|
133
117
|
issue_date_color = options.get('issue_date_color', '#000')
|
|
134
118
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
|
|
135
119
|
|
|
136
120
|
issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
|
|
137
|
-
issue_date_x += options.get('issue_date_x', 0)
|
|
138
121
|
issue_date_y = options.get('issue_date_y', 120)
|
|
139
|
-
|
|
140
|
-
issue_date_char_space = options.get(
|
|
141
|
-
'issue_date_char_space', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_CHAR_SPACE', 0)
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date, charSpace=issue_date_char_space)
|
|
122
|
+
pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
|
|
145
123
|
|
|
146
124
|
return pdf_canvas
|
|
147
125
|
|
|
@@ -190,7 +168,7 @@ def generate_pdf_credential(
|
|
|
190
168
|
credential_uuid: UUID,
|
|
191
169
|
options: dict[str, Any],
|
|
192
170
|
) -> str:
|
|
193
|
-
|
|
171
|
+
"""
|
|
194
172
|
Generate a PDF credential.
|
|
195
173
|
|
|
196
174
|
:param learning_context_key: The ID of the course or learning path the credential is for.
|
|
@@ -201,50 +179,33 @@ def generate_pdf_credential(
|
|
|
201
179
|
|
|
202
180
|
Options:
|
|
203
181
|
- template: The path to the PDF template file.
|
|
204
|
-
-
|
|
205
|
-
A
|
|
206
|
-
- font: The name of the font to use.
|
|
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.
|
|
207
185
|
- name_y: The Y coordinate of the name on the credential (vertical position on the template).
|
|
208
186
|
- name_color: The color of the name on the credential (hexadecimal color code).
|
|
209
187
|
- name_size: The font size of the name on the credential. The default value is 32.
|
|
210
|
-
-
|
|
211
|
-
- name_uppercase: If set to true (without quotes), the name will be converted to uppercase.
|
|
212
|
-
The default value is False, unless specified otherwise in the instance settings.
|
|
213
|
-
- context_name: Specify the custom course or Learning Path name. If not provided, it will be retrieved
|
|
214
|
-
automatically from the "cert_name_long" or "display_name" fields for courses, or from the Learning Path model.
|
|
188
|
+
- context_name: Specify the custom course or Learning Path name.
|
|
215
189
|
- context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
|
|
216
190
|
- context_name_color: The color of the context name on the credential (hexadecimal color code).
|
|
217
191
|
- context_name_size: The font size of the context name on the credential. The default value is 28.
|
|
218
|
-
- context_name_font: The font of the context name on the credential. It overrides the `font` option.
|
|
219
|
-
- issue_date_x: The horizontal offset for the issue date from its centered position
|
|
220
|
-
(positive values move right, negative values move left; default is 0).
|
|
221
192
|
- issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
|
|
222
193
|
- issue_date_color: The color of the issue date on the credential (hexadecimal color code).
|
|
223
|
-
- issue_date_size: The font size of the issue date on the credential. The default value is 12.
|
|
224
|
-
- issue_date_font: The font of the issue date on the credential. It overrides the `font` option.
|
|
225
|
-
- issue_date_char_space: The character spacing of the issue date on the credential
|
|
226
|
-
(default is 0.0, unless specified otherwise in the instance settings).
|
|
227
|
-
- issue_date_uppercase: If set to true (without quotes), the issue date will be converted to uppercase.
|
|
228
|
-
The default value is False, unless specified otherwise in the instance settings.
|
|
229
194
|
"""
|
|
230
195
|
log.info("Starting credential generation for user %s", user.id)
|
|
231
196
|
|
|
232
197
|
username = _get_user_name(user)
|
|
233
198
|
context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
|
|
234
|
-
template_path = options.get('template')
|
|
235
|
-
|
|
236
|
-
# Handle multiline context name (we support semicolon as a separator to preserve backward compatibility).
|
|
237
|
-
context_name = context_name.replace(';', '\n').replace(r'\n', '\n')
|
|
238
|
-
if '\n' in context_name:
|
|
239
|
-
# `template_two_lines` is kept for backward compatibility.
|
|
240
|
-
template_path = options.get('template_multiline', options.get('template_two_lines', template_path))
|
|
241
|
-
|
|
242
|
-
if not template_path:
|
|
243
|
-
msg = "Template path must be specified in options."
|
|
244
|
-
raise ValueError(msg)
|
|
245
199
|
|
|
246
200
|
# Get template from the CredentialAsset.
|
|
247
|
-
|
|
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'])
|
|
207
|
+
|
|
208
|
+
font = _register_font(options)
|
|
248
209
|
|
|
249
210
|
# Load the PDF template.
|
|
250
211
|
with template_file.open('rb') as template_file:
|
|
@@ -253,7 +214,7 @@ def generate_pdf_credential(
|
|
|
253
214
|
credential = PdfWriter()
|
|
254
215
|
|
|
255
216
|
# Create a new canvas, prepare the page and write the data
|
|
256
|
-
pdf_canvas = _write_text_on_template(template, username, context_name, options)
|
|
217
|
+
pdf_canvas = _write_text_on_template(template, font, username, context_name, options)
|
|
257
218
|
|
|
258
219
|
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
259
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
|
+
]
|