learning-credentials 0.4.0__tar.gz → 0.4.0rc2__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.4.0 → learning_credentials-0.4.0rc2}/CHANGELOG.rst +4 -26
- {learning_credentials-0.4.0/learning_credentials.egg-info → learning_credentials-0.4.0rc2}/PKG-INFO +5 -28
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/admin.py +30 -3
- learning_credentials-0.4.0rc2/learning_credentials/api/v1/serializers.py +13 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/api/v1/urls.py +2 -1
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/api/v1/views.py +61 -1
- learning_credentials-0.4.0rc2/learning_credentials/generators.py +238 -0
- learning_credentials-0.4.0rc2/learning_credentials/migrations/0007_validation.py +94 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/models.py +63 -32
- learning_credentials-0.4.0rc2/learning_credentials/templates/learning_credentials/verify.html +83 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/urls.py +2 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2/learning_credentials.egg-info}/PKG-INFO +5 -28
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials.egg-info/SOURCES.txt +3 -2
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/pyproject.toml +9 -6
- learning_credentials-0.4.0rc2/tests/test_generators.py +294 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/tests/test_models.py +22 -274
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/tests/test_processors.py +27 -2
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/tests/test_views.py +106 -24
- learning_credentials-0.4.0/learning_credentials/generators.py +0 -416
- learning_credentials-0.4.0/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +0 -138
- learning_credentials-0.4.0/tests/test_generators.py +0 -571
- learning_credentials-0.4.0/tests/test_migrations.py +0 -250
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/LICENSE.txt +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/MANIFEST.in +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/README.rst +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/__init__.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/api/__init__.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/api/urls.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/api/v1/__init__.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/api/v1/permissions.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/apps.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/compat.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/conf/locale/config.yaml +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/exceptions.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/migrations/0001_initial.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/migrations/__init__.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/processors.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/settings/__init__.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/settings/common.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/settings/production.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/tasks.py +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/templates/learning_credentials/base.html +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials.egg-info/dependency_links.txt +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials.egg-info/entry_points.txt +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials.egg-info/requires.txt +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/learning_credentials.egg-info/top_level.txt +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/setup.cfg +0 -0
- {learning_credentials-0.4.0 → learning_credentials-0.4.0rc2}/tests/test_tasks.py +0 -0
|
@@ -16,38 +16,16 @@ Unreleased
|
|
|
16
16
|
|
|
17
17
|
*
|
|
18
18
|
|
|
19
|
-
0.4.0 -
|
|
19
|
+
0.4.0 - 2025-11-03
|
|
20
20
|
******************
|
|
21
21
|
|
|
22
22
|
Added
|
|
23
23
|
=====
|
|
24
24
|
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
Modified
|
|
30
|
-
========
|
|
31
|
-
|
|
32
|
-
* Migrated generator options from flat format (``name_y``, ``context_name_color``, etc.) to structured ``text_elements`` format.
|
|
33
|
-
|
|
34
|
-
0.3.1 - 2025-12-15
|
|
35
|
-
******************
|
|
36
|
-
|
|
37
|
-
Added
|
|
38
|
-
=====
|
|
39
|
-
|
|
40
|
-
* Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings).
|
|
25
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
26
|
+
* Option to invalidate issued credentials.
|
|
27
|
+
* Support for defining the course name using the `cert_name_long` field (in Studio's Advanced Settings).
|
|
41
28
|
* Support for specifying individual fonts for PDF text elements.
|
|
42
|
-
* Support for \n in learning context names in PDF certificates.
|
|
43
|
-
* Options for uppercase name and issue date in PDF certificates.
|
|
44
|
-
* Option for defining character spacing for issue date in PDF certificates.
|
|
45
|
-
* Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
|
|
46
|
-
|
|
47
|
-
Modified
|
|
48
|
-
========
|
|
49
|
-
|
|
50
|
-
* Replaced ``template_two_lines`` with ``template_multiline``.
|
|
51
29
|
|
|
52
30
|
0.3.0 - 2025-09-17
|
|
53
31
|
******************
|
{learning_credentials-0.4.0/learning_credentials.egg-info → learning_credentials-0.4.0rc2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.0rc2
|
|
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,16 @@ 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
|
-
*
|
|
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).
|
|
185
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
186
|
+
* Option to invalidate issued credentials.
|
|
187
|
+
* Support for defining the course name using the `cert_name_long` field (in Studio's Advanced Settings).
|
|
202
188
|
* 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``.
|
|
212
189
|
|
|
213
190
|
0.3.0 - 2025-09-17
|
|
214
191
|
******************
|
|
@@ -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.4.0 → learning_credentials-0.4.0rc2}/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.4.0 → learning_credentials-0.4.0rc2}/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)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides functions to generate credentials.
|
|
3
|
+
|
|
4
|
+
The functions prefixed with `generate_` are automatically detected by the admin page and are used to generate the
|
|
5
|
+
credentials for the users.
|
|
6
|
+
|
|
7
|
+
We will move this module to an external repository (a plugin).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import logging
|
|
14
|
+
import secrets
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from django.conf import settings
|
|
18
|
+
from django.core.files.base import ContentFile
|
|
19
|
+
from django.core.files.storage import FileSystemStorage, default_storage
|
|
20
|
+
from pypdf import PdfReader, PdfWriter
|
|
21
|
+
from pypdf.constants import UserAccessPermissions
|
|
22
|
+
from reportlab.pdfbase.pdfmetrics import FontError, FontNotFoundError, registerFont
|
|
23
|
+
from reportlab.pdfbase.ttfonts import TTFError, TTFont
|
|
24
|
+
from reportlab.pdfgen.canvas import Canvas
|
|
25
|
+
|
|
26
|
+
from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
|
|
27
|
+
from .models import CredentialAsset
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
32
|
+
from uuid import UUID
|
|
33
|
+
|
|
34
|
+
from django.contrib.auth.models import User
|
|
35
|
+
from opaque_keys.edx.keys import CourseKey
|
|
36
|
+
from pypdf import PageObject
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_user_name(user: User) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Retrieve the user's name.
|
|
42
|
+
|
|
43
|
+
:param user: The user to generate the credential for.
|
|
44
|
+
:return: Username.
|
|
45
|
+
"""
|
|
46
|
+
return user.profile.name or f"{user.first_name} {user.last_name}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _register_font(font_name: Any) -> str | None: # noqa: ANN401
|
|
50
|
+
"""
|
|
51
|
+
Register a custom font if specified in options. If not specified, use the default font (Helvetica).
|
|
52
|
+
|
|
53
|
+
:param font_name: The name of the font to register.
|
|
54
|
+
:returns: The font name if registered successfully, otherwise None.
|
|
55
|
+
"""
|
|
56
|
+
if not font_name:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
try:
|
|
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
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_text_on_template(template: PageObject, username: str, context_name: str, options: dict[str, Any]) -> Canvas:
|
|
68
|
+
"""
|
|
69
|
+
Prepare a new canvas and write the user and course name onto it.
|
|
70
|
+
|
|
71
|
+
:param template: Pdf template.
|
|
72
|
+
:param username: The name of the user to generate the credential for.
|
|
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.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
|
|
79
|
+
"""
|
|
80
|
+
Convert a hexadecimal color code to an RGB tuple with floating-point values.
|
|
81
|
+
|
|
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
|
+
|
|
90
|
+
# noinspection PyTypeChecker
|
|
91
|
+
return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
|
|
92
|
+
|
|
93
|
+
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'
|
|
96
|
+
|
|
97
|
+
# Write the learner name.
|
|
98
|
+
name_font = _register_font(options.get('name_font')) or font
|
|
99
|
+
pdf_canvas.setFont(name_font, options.get('name_size', 32))
|
|
100
|
+
name_color = options.get('name_color', '#000')
|
|
101
|
+
pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
|
|
102
|
+
|
|
103
|
+
name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
|
|
104
|
+
name_y = options.get('name_y', 290)
|
|
105
|
+
pdf_canvas.drawString(name_x, name_y, username)
|
|
106
|
+
|
|
107
|
+
# Write the learning context name.
|
|
108
|
+
context_name_font = _register_font(options.get('context_name_font')) or font
|
|
109
|
+
pdf_canvas.setFont(context_name_font, options.get('context_name_size', 28))
|
|
110
|
+
context_name_color = options.get('context_name_color', '#000')
|
|
111
|
+
pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
|
|
112
|
+
|
|
113
|
+
context_name_y = options.get('context_name_y', 220)
|
|
114
|
+
context_name_line_height = 28 * 1.1
|
|
115
|
+
|
|
116
|
+
# Split the learning context name into lines and write each of them in the center of the template.
|
|
117
|
+
for line_number, line in enumerate(context_name.split('\n')):
|
|
118
|
+
line_x = (template_width - pdf_canvas.stringWidth(line)) / 2
|
|
119
|
+
line_y = context_name_y - (line_number * context_name_line_height)
|
|
120
|
+
pdf_canvas.drawString(line_x, line_y, line)
|
|
121
|
+
|
|
122
|
+
# Write the issue date.
|
|
123
|
+
issue_date = get_localized_credential_date()
|
|
124
|
+
issue_date_font = _register_font(options.get('issue_date_font')) or font
|
|
125
|
+
pdf_canvas.setFont(issue_date_font, options.get('issue_date_size', 12))
|
|
126
|
+
issue_date_color = options.get('issue_date_color', '#000')
|
|
127
|
+
pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
|
|
128
|
+
|
|
129
|
+
issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
|
|
130
|
+
issue_date_y = options.get('issue_date_y', 120)
|
|
131
|
+
pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
|
|
132
|
+
|
|
133
|
+
return pdf_canvas
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _save_credential(credential: PdfWriter, credential_uuid: UUID) -> str:
|
|
137
|
+
"""
|
|
138
|
+
Save the final PDF file to BytesIO and upload it using Django default storage.
|
|
139
|
+
|
|
140
|
+
:param credential: Pdf credential.
|
|
141
|
+
:param credential_uuid: The UUID of the credential.
|
|
142
|
+
:returns: The URL of the saved credential.
|
|
143
|
+
"""
|
|
144
|
+
# Save the final PDF file to BytesIO.
|
|
145
|
+
output_path = f'external_certificates/{credential_uuid}.pdf'
|
|
146
|
+
|
|
147
|
+
view_print_extract_permission = (
|
|
148
|
+
UserAccessPermissions.PRINT
|
|
149
|
+
| UserAccessPermissions.PRINT_TO_REPRESENTATION
|
|
150
|
+
| UserAccessPermissions.EXTRACT_TEXT_AND_GRAPHICS
|
|
151
|
+
)
|
|
152
|
+
credential.encrypt('', secrets.token_hex(32), permissions_flag=view_print_extract_permission, algorithm='AES-256')
|
|
153
|
+
|
|
154
|
+
pdf_bytes = io.BytesIO()
|
|
155
|
+
credential.write(pdf_bytes)
|
|
156
|
+
pdf_bytes.seek(0) # Rewind to start.
|
|
157
|
+
# Upload with Django default storage.
|
|
158
|
+
credential_file = ContentFile(pdf_bytes.read())
|
|
159
|
+
# Delete the file if it already exists.
|
|
160
|
+
if default_storage.exists(output_path):
|
|
161
|
+
default_storage.delete(output_path)
|
|
162
|
+
default_storage.save(output_path, credential_file)
|
|
163
|
+
if isinstance(default_storage, FileSystemStorage):
|
|
164
|
+
url = f"{get_default_storage_url()}{output_path}"
|
|
165
|
+
else:
|
|
166
|
+
url = default_storage.url(output_path)
|
|
167
|
+
|
|
168
|
+
if custom_domain := getattr(settings, 'LEARNING_CREDENTIALS_CUSTOM_DOMAIN', None):
|
|
169
|
+
url = f"{custom_domain}/{credential_uuid}.pdf"
|
|
170
|
+
|
|
171
|
+
return url
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def generate_pdf_credential(
|
|
175
|
+
learning_context_key: CourseKey,
|
|
176
|
+
user: User,
|
|
177
|
+
credential_uuid: UUID,
|
|
178
|
+
options: dict[str, Any],
|
|
179
|
+
) -> str:
|
|
180
|
+
"""
|
|
181
|
+
Generate a PDF credential.
|
|
182
|
+
|
|
183
|
+
:param learning_context_key: The ID of the course or learning path the credential is for.
|
|
184
|
+
:param user: The user to generate the credential for.
|
|
185
|
+
:param credential_uuid: The UUID of the credential to generate.
|
|
186
|
+
:param options: The custom options for the credential.
|
|
187
|
+
:returns: The URL of the saved credential.
|
|
188
|
+
|
|
189
|
+
Options:
|
|
190
|
+
- template: The path to the PDF template file.
|
|
191
|
+
- template_two_lines: The path to the PDF template file for two-line context names.
|
|
192
|
+
A two-line context name is specified by using a semicolon as a separator.
|
|
193
|
+
- font: The name of the font to use. The default font is Helvetica.
|
|
194
|
+
- name_y: The Y coordinate of the name on the credential (vertical position on the template).
|
|
195
|
+
- name_color: The color of the name on the credential (hexadecimal color code).
|
|
196
|
+
- name_size: The font size of the name on the credential. The default value is 32.
|
|
197
|
+
- name_font: The font of the name on the credential. It overrides the `font` option.
|
|
198
|
+
- context_name: Specify the custom course or Learning Path name. If not provided, it will be retrieved
|
|
199
|
+
automatically from the "cert_name_long" or "display_name" fields for courses, or from the Learning Path model.
|
|
200
|
+
- context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
|
|
201
|
+
- context_name_color: The color of the context name on the credential (hexadecimal color code).
|
|
202
|
+
- context_name_size: The font size of the context name on the credential. The default value is 28.
|
|
203
|
+
- context_name_font: The font of the context name on the credential. It overrides the `font` option.
|
|
204
|
+
- issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
|
|
205
|
+
- issue_date_color: The color of the issue date on the credential (hexadecimal color code).
|
|
206
|
+
- issue_date_size: The font size of the issue date on the credential. The default value is 12.
|
|
207
|
+
- issue_date_font: The font of the issue date on the credential. It overrides the `font` option.
|
|
208
|
+
"""
|
|
209
|
+
log.info("Starting credential generation for user %s", user.id)
|
|
210
|
+
|
|
211
|
+
username = _get_user_name(user)
|
|
212
|
+
context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
|
|
213
|
+
|
|
214
|
+
# Get template from the CredentialAsset.
|
|
215
|
+
# HACK: We support two-line strings by using a semicolon as a separator.
|
|
216
|
+
if ';' in context_name and (template_path := options.get('template_two_lines')):
|
|
217
|
+
template_file = CredentialAsset.get_asset_by_slug(template_path)
|
|
218
|
+
context_name = context_name.replace(';', '\n')
|
|
219
|
+
else:
|
|
220
|
+
template_file = CredentialAsset.get_asset_by_slug(options['template'])
|
|
221
|
+
|
|
222
|
+
# Load the PDF template.
|
|
223
|
+
with template_file.open('rb') as template_file:
|
|
224
|
+
template = PdfReader(template_file).pages[0]
|
|
225
|
+
|
|
226
|
+
credential = PdfWriter()
|
|
227
|
+
|
|
228
|
+
# Create a new canvas, prepare the page and write the data
|
|
229
|
+
pdf_canvas = _write_text_on_template(template, username, context_name, options)
|
|
230
|
+
|
|
231
|
+
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
232
|
+
template.merge_page(overlay_pdf.pages[0])
|
|
233
|
+
credential.add_page(template)
|
|
234
|
+
|
|
235
|
+
url = _save_credential(credential, credential_uuid)
|
|
236
|
+
|
|
237
|
+
log.info("Credential saved to %s", url)
|
|
238
|
+
return url
|
|
@@ -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
|
+
]
|