learning-credentials 0.4.1rc6__tar.gz → 0.5.0rc3__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.1rc6 → learning_credentials-0.5.0rc3}/CHANGELOG.rst +10 -1
- {learning_credentials-0.4.1rc6/learning_credentials.egg-info → learning_credentials-0.5.0rc3}/PKG-INFO +12 -2
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/admin.py +59 -10
- learning_credentials-0.5.0rc3/learning_credentials/api/v1/serializers.py +13 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/urls.py +2 -1
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/views.py +61 -1
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/compat.py +13 -7
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/generators.py +81 -39
- learning_credentials-0.5.0rc3/learning_credentials/migrations/0001_squashed_0010.py +265 -0
- learning_credentials-0.5.0rc3/learning_credentials/migrations/0008_validation.py +94 -0
- learning_credentials-0.5.0rc3/learning_credentials/migrations/0009_credential_user_fk.py +63 -0
- learning_credentials-0.5.0rc3/learning_credentials/migrations/0010_credential_configuration_fk.py +79 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/models.py +84 -27
- learning_credentials-0.5.0rc3/learning_credentials/templates/learning_credentials/verify.html +83 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/urls.py +2 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3/learning_credentials.egg-info}/PKG-INFO +12 -2
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/SOURCES.txt +7 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/pyproject.toml +2 -6
- learning_credentials-0.5.0rc3/tests/test_admin.py +462 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/tests/test_generators.py +183 -58
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/tests/test_migrations.py +5 -24
- learning_credentials-0.5.0rc3/tests/test_models.py +573 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/tests/test_processors.py +1 -26
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/tests/test_views.py +65 -106
- learning_credentials-0.4.1rc6/tests/test_models.py +0 -391
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/LICENSE.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/MANIFEST.in +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/README.rst +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/api/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/api/urls.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/permissions.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/apps.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/conf/locale/config.yaml +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/exceptions.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0001_initial.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/processors.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/settings/__init__.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/settings/common.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/settings/production.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/tasks.py +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/base.html +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/dependency_links.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/entry_points.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/requires.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/top_level.txt +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/setup.cfg +0 -0
- {learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/tests/test_tasks.py +0 -0
|
@@ -16,7 +16,16 @@ Unreleased
|
|
|
16
16
|
|
|
17
17
|
*
|
|
18
18
|
|
|
19
|
-
0.
|
|
19
|
+
0.5.0 - 2026-01-29
|
|
20
|
+
******************
|
|
21
|
+
|
|
22
|
+
Added
|
|
23
|
+
=====
|
|
24
|
+
|
|
25
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
26
|
+
* Option to invalidate issued credentials.
|
|
27
|
+
|
|
28
|
+
0.4.0 - 2026-01-28
|
|
20
29
|
******************
|
|
21
30
|
|
|
22
31
|
Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0rc3
|
|
4
4
|
Summary: A pluggable service for preparing Open edX credentials.
|
|
5
5
|
Author-email: OpenCraft <help@opencraft.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -11,6 +11,7 @@ Keywords: Python,edx,credentials,django
|
|
|
11
11
|
Classifier: Development Status :: 5 - Production/Stable
|
|
12
12
|
Classifier: Framework :: Django
|
|
13
13
|
Classifier: Framework :: Django :: 4.2
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
15
|
Classifier: Intended Audience :: Developers
|
|
15
16
|
Classifier: Natural Language :: English
|
|
16
17
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -176,7 +177,16 @@ Unreleased
|
|
|
176
177
|
|
|
177
178
|
*
|
|
178
179
|
|
|
179
|
-
0.
|
|
180
|
+
0.5.0 - 2026-01-29
|
|
181
|
+
******************
|
|
182
|
+
|
|
183
|
+
Added
|
|
184
|
+
=====
|
|
185
|
+
|
|
186
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
187
|
+
* Option to invalidate issued credentials.
|
|
188
|
+
|
|
189
|
+
0.4.0 - 2026-01-28
|
|
180
190
|
******************
|
|
181
191
|
|
|
182
192
|
Added
|
{learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/admin.py
RENAMED
|
@@ -6,9 +6,12 @@ import importlib
|
|
|
6
6
|
import inspect
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
|
+
import django
|
|
9
10
|
from django import forms
|
|
10
|
-
from django.contrib import admin
|
|
11
|
+
from django.contrib import admin, messages
|
|
11
12
|
from django.core.exceptions import ValidationError
|
|
13
|
+
from django.db.models import URLField
|
|
14
|
+
from django.urls import reverse
|
|
12
15
|
from django.utils.html import format_html
|
|
13
16
|
from django_object_actions import DjangoObjectActions, action
|
|
14
17
|
from django_reverse_admin import ReverseModelAdmin
|
|
@@ -225,31 +228,49 @@ class CredentialConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
|
|
|
225
228
|
|
|
226
229
|
|
|
227
230
|
@admin.register(Credential)
|
|
228
|
-
class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
231
|
+
class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
|
|
229
232
|
list_display = (
|
|
230
|
-
'
|
|
233
|
+
'user',
|
|
231
234
|
'user_full_name',
|
|
232
|
-
'
|
|
233
|
-
'credential_type',
|
|
235
|
+
'configuration',
|
|
234
236
|
'status',
|
|
235
237
|
'url',
|
|
236
238
|
'created',
|
|
237
239
|
'modified',
|
|
238
240
|
)
|
|
239
241
|
readonly_fields = (
|
|
240
|
-
'
|
|
242
|
+
'uuid',
|
|
243
|
+
'verify_uuid',
|
|
244
|
+
'user',
|
|
245
|
+
'configuration',
|
|
241
246
|
'created',
|
|
242
247
|
'modified',
|
|
243
248
|
'user_full_name',
|
|
244
|
-
'
|
|
245
|
-
'credential_type',
|
|
249
|
+
'learning_context_name',
|
|
246
250
|
'status',
|
|
247
251
|
'url',
|
|
248
252
|
'legacy_id',
|
|
249
253
|
'generation_task_id',
|
|
250
254
|
)
|
|
251
|
-
search_fields = (
|
|
252
|
-
|
|
255
|
+
search_fields = (
|
|
256
|
+
"configuration__learning_context_key",
|
|
257
|
+
"user_full_name",
|
|
258
|
+
"user__username",
|
|
259
|
+
"user__email",
|
|
260
|
+
"uuid",
|
|
261
|
+
"verify_uuid",
|
|
262
|
+
)
|
|
263
|
+
list_filter = ("configuration__learning_context_key", "configuration__credential_type", "status")
|
|
264
|
+
change_actions = ('reissue_credential',)
|
|
265
|
+
|
|
266
|
+
def save_model(self, request: HttpRequest, obj: Credential, _form: forms.ModelForm, _change: bool): # noqa: FBT001
|
|
267
|
+
"""Display validation errors as messages in the admin interface."""
|
|
268
|
+
try:
|
|
269
|
+
obj.save()
|
|
270
|
+
except ValidationError as e:
|
|
271
|
+
self.message_user(request, e.message or "Invalid data", level=messages.ERROR)
|
|
272
|
+
# Optionally, redirect to the change form with the error message
|
|
273
|
+
return
|
|
253
274
|
|
|
254
275
|
def get_form(self, request: HttpRequest, obj: Credential | None = None, **kwargs) -> forms.ModelForm:
|
|
255
276
|
"""Hide the download_url field."""
|
|
@@ -263,3 +284,31 @@ class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
|
263
284
|
if obj.download_url:
|
|
264
285
|
return format_html("<a href='{url}'>{url}</a>", url=obj.download_url)
|
|
265
286
|
return "-"
|
|
287
|
+
|
|
288
|
+
@action(label="Reissue credential", description="Reissue the credential for the user.")
|
|
289
|
+
def reissue_credential(self, request: HttpRequest, obj: Credential):
|
|
290
|
+
"""Reissue the credential for the user."""
|
|
291
|
+
new_credential = obj.reissue()
|
|
292
|
+
admin_url = reverse('admin:learning_credentials_credential_change', args=[new_credential.pk])
|
|
293
|
+
message = format_html(
|
|
294
|
+
'The credential has been reissued as <a href="{}">{}</a>.', admin_url, new_credential.uuid
|
|
295
|
+
)
|
|
296
|
+
messages.success(request, message)
|
|
297
|
+
|
|
298
|
+
def has_add_permission(self, _request: HttpRequest) -> bool:
|
|
299
|
+
"""Hide the "Add" button in the admin interface."""
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
def has_delete_permission(self, _request: HttpRequest, _obj: Credential | None = None) -> bool:
|
|
303
|
+
"""Hide the "Delete" button in the admin interface."""
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
def formfield_for_dbfield(self, db_field, request, **kwargs): # noqa: ANN001, ANN201
|
|
307
|
+
"""
|
|
308
|
+
Assume HTTPS for scheme-less domains pasted into URLFields.
|
|
309
|
+
|
|
310
|
+
This method can be removed when support for Django versions below 5.0 is dropped.
|
|
311
|
+
"""
|
|
312
|
+
if django.VERSION[0] > 4 and isinstance(db_field, URLField): # pragma: no cover
|
|
313
|
+
kwargs["assume_scheme"] = "https"
|
|
314
|
+
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
|
@@ -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.1rc6 → learning_credentials-0.5.0rc3}/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.1rc6 → learning_credentials-0.5.0rc3}/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.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/compat.py
RENAMED
|
@@ -10,7 +10,6 @@ It also simplifies running tests outside edx-platform's environment by stubbing
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
from contextlib import contextmanager
|
|
13
|
-
from datetime import datetime
|
|
14
13
|
from typing import TYPE_CHECKING
|
|
15
14
|
|
|
16
15
|
import pytz
|
|
@@ -18,7 +17,9 @@ from celery import Celery
|
|
|
18
17
|
from django.conf import settings
|
|
19
18
|
from learning_paths.models import LearningPath
|
|
20
19
|
|
|
21
|
-
if TYPE_CHECKING:
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
|
|
22
23
|
from django.contrib.auth.models import User
|
|
23
24
|
from learning_paths.keys import LearningPathKey
|
|
24
25
|
from opaque_keys.edx.keys import CourseKey, LearningContextKey
|
|
@@ -33,7 +34,7 @@ def get_celery_app() -> Celery:
|
|
|
33
34
|
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
34
35
|
from lms import CELERY_APP
|
|
35
36
|
|
|
36
|
-
return CELERY_APP
|
|
37
|
+
return CELERY_APP
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def get_default_storage_url() -> str:
|
|
@@ -116,10 +117,15 @@ def get_course_grade(user: User, course_id: CourseKey): # noqa: ANN201
|
|
|
116
117
|
return CourseGradeFactory().read(user, course_key=course_id)
|
|
117
118
|
|
|
118
119
|
|
|
119
|
-
def get_localized_credential_date() -> str:
|
|
120
|
-
"""
|
|
120
|
+
def get_localized_credential_date(date: datetime) -> str:
|
|
121
|
+
"""
|
|
122
|
+
Get the localized date from Open edX.
|
|
123
|
+
|
|
124
|
+
:param date: The datetime to format.
|
|
125
|
+
:returns: The formatted date string.
|
|
126
|
+
"""
|
|
121
127
|
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
122
128
|
from common.djangoapps.util.date_utils import strftime_localized
|
|
123
129
|
|
|
124
|
-
|
|
125
|
-
return strftime_localized(
|
|
130
|
+
localized_date = date.astimezone(pytz.timezone(settings.TIME_ZONE))
|
|
131
|
+
return strftime_localized(localized_date, settings.CERTIFICATE_DATE_FORMAT)
|
{learning_credentials-0.4.1rc6 → learning_credentials-0.5.0rc3}/learning_credentials/generators.py
RENAMED
|
@@ -25,7 +25,7 @@ from reportlab.pdfbase.pdfmetrics import FontError, FontNotFoundError, registerF
|
|
|
25
25
|
from reportlab.pdfbase.ttfonts import TTFError, TTFont
|
|
26
26
|
from reportlab.pdfgen.canvas import Canvas
|
|
27
27
|
|
|
28
|
-
from .compat import get_default_storage_url,
|
|
28
|
+
from .compat import get_default_storage_url, get_localized_credential_date
|
|
29
29
|
from .exceptions import AssetNotFoundError
|
|
30
30
|
from .models import CredentialAsset
|
|
31
31
|
|
|
@@ -34,10 +34,10 @@ log = logging.getLogger(__name__)
|
|
|
34
34
|
if TYPE_CHECKING: # pragma: no cover
|
|
35
35
|
from uuid import UUID
|
|
36
36
|
|
|
37
|
-
from django.contrib.auth.models import User
|
|
38
|
-
from opaque_keys.edx.keys import CourseKey
|
|
39
37
|
from pypdf import PageObject
|
|
40
38
|
|
|
39
|
+
from learning_credentials.models import Credential
|
|
40
|
+
|
|
41
41
|
|
|
42
42
|
def _get_defaults() -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
43
43
|
"""
|
|
@@ -81,16 +81,6 @@ def _get_defaults() -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
|
81
81
|
return default_styling, default_text_elements
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def _get_user_name(user: User) -> str:
|
|
85
|
-
"""
|
|
86
|
-
Retrieve the user's name.
|
|
87
|
-
|
|
88
|
-
:param user: The user to generate the credential for.
|
|
89
|
-
:return: Username.
|
|
90
|
-
"""
|
|
91
|
-
return user.profile.name or f"{user.first_name} {user.last_name}"
|
|
92
|
-
|
|
93
|
-
|
|
94
84
|
def _register_font(pdf_canvas: Canvas, font_name: str) -> str:
|
|
95
85
|
"""
|
|
96
86
|
Register a custom font if not already available.
|
|
@@ -246,11 +236,12 @@ def _render_text_element(
|
|
|
246
236
|
pdf_canvas.drawString(line_x, line_y, line, charSpace=char_space)
|
|
247
237
|
|
|
248
238
|
|
|
249
|
-
def _write_text_on_template(
|
|
239
|
+
def _write_text_on_template( # noqa: PLR0913
|
|
250
240
|
template: PageObject,
|
|
251
241
|
username: str,
|
|
252
242
|
context_name: str,
|
|
253
243
|
issue_date: str,
|
|
244
|
+
verify_uuid: str,
|
|
254
245
|
options: dict[str, Any],
|
|
255
246
|
) -> Canvas:
|
|
256
247
|
"""
|
|
@@ -260,6 +251,7 @@ def _write_text_on_template(
|
|
|
260
251
|
:param username: The name of the user to generate the credential for.
|
|
261
252
|
:param context_name: The name of the learning context.
|
|
262
253
|
:param issue_date: The formatted issue date string.
|
|
254
|
+
:param verify_uuid: The verification UUID of the credential.
|
|
263
255
|
:param options: A dictionary documented in the ``generate_pdf_credential`` function.
|
|
264
256
|
:returns: A canvas with written data.
|
|
265
257
|
"""
|
|
@@ -271,6 +263,7 @@ def _write_text_on_template(
|
|
|
271
263
|
'name': username,
|
|
272
264
|
'context_name': context_name,
|
|
273
265
|
'issue_date': issue_date,
|
|
266
|
+
'verify_uuid': verify_uuid,
|
|
274
267
|
}
|
|
275
268
|
|
|
276
269
|
# Build and render text elements.
|
|
@@ -282,26 +275,73 @@ def _write_text_on_template(
|
|
|
282
275
|
return pdf_canvas
|
|
283
276
|
|
|
284
277
|
|
|
285
|
-
def
|
|
278
|
+
def _get_credential_paths(credential_uuid: UUID) -> tuple[str, str]:
|
|
279
|
+
"""
|
|
280
|
+
Get the original and archive paths for a credential.
|
|
281
|
+
|
|
282
|
+
:param credential_uuid: The UUID of the credential.
|
|
283
|
+
:returns: A tuple of (original_path, archive_path).
|
|
284
|
+
"""
|
|
285
|
+
output_dir = getattr(settings, 'LEARNING_CREDENTIALS_OUTPUT_DIR', 'learning_credentials')
|
|
286
|
+
original_path = f'{output_dir}/{credential_uuid}.pdf'
|
|
287
|
+
archive_path = f'{output_dir}_invalidated/{credential_uuid}.pdf'
|
|
288
|
+
return original_path, archive_path
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _invalidate_credential(credential_uuid: UUID) -> str | None:
|
|
292
|
+
"""
|
|
293
|
+
Invalidate a PDF credential by moving it to an archive location and restricting access.
|
|
294
|
+
|
|
295
|
+
For S3 storage: moves file to archive path and sets ACL to private.
|
|
296
|
+
For other backends: moves file to archive path.
|
|
297
|
+
|
|
298
|
+
:param credential_uuid: The UUID of the credential to invalidate.
|
|
299
|
+
:returns: The archive path if successful, None if file didn't exist.
|
|
300
|
+
"""
|
|
301
|
+
original_path, archive_path = _get_credential_paths(credential_uuid)
|
|
302
|
+
|
|
303
|
+
if not default_storage.exists(original_path):
|
|
304
|
+
log.warning("Credential file %s does not exist, nothing to invalidate", original_path)
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
with default_storage.open(original_path, 'rb') as original_file:
|
|
308
|
+
default_storage.save(archive_path, ContentFile(original_file.read()))
|
|
309
|
+
log.info("Archived credential to %s", archive_path)
|
|
310
|
+
|
|
311
|
+
default_storage.delete(original_path)
|
|
312
|
+
log.info("Deleted original credential file %s", original_path)
|
|
313
|
+
|
|
314
|
+
# Set ACL to private if S3 (django-storages S3Boto3Storage exposes the bucket).
|
|
315
|
+
if hasattr(default_storage, 'bucket'):
|
|
316
|
+
try:
|
|
317
|
+
obj = default_storage.bucket.Object(archive_path)
|
|
318
|
+
obj.Acl().put(ACL='private')
|
|
319
|
+
log.info("Set ACL to private for archived file %s", archive_path)
|
|
320
|
+
except Exception:
|
|
321
|
+
log.exception("Failed to set ACL to private for %s", archive_path)
|
|
322
|
+
|
|
323
|
+
return archive_path
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _save_credential(pdf_writer: PdfWriter, credential_uuid: UUID) -> str:
|
|
286
327
|
"""
|
|
287
328
|
Save the final PDF file to BytesIO and upload it using Django default storage.
|
|
288
329
|
|
|
289
|
-
:param
|
|
330
|
+
:param pdf_writer: The PdfWriter instance containing the credential.
|
|
290
331
|
:param credential_uuid: The UUID of the credential.
|
|
291
332
|
:returns: The URL of the saved credential.
|
|
292
333
|
"""
|
|
293
|
-
|
|
294
|
-
output_path = f'external_certificates/{credential_uuid}.pdf'
|
|
334
|
+
output_path, _ = _get_credential_paths(credential_uuid)
|
|
295
335
|
|
|
296
336
|
view_print_extract_permission = (
|
|
297
337
|
UserAccessPermissions.PRINT
|
|
298
338
|
| UserAccessPermissions.PRINT_TO_REPRESENTATION
|
|
299
339
|
| UserAccessPermissions.EXTRACT_TEXT_AND_GRAPHICS
|
|
300
340
|
)
|
|
301
|
-
|
|
341
|
+
pdf_writer.encrypt('', secrets.token_hex(32), permissions_flag=view_print_extract_permission, algorithm='AES-256')
|
|
302
342
|
|
|
303
343
|
pdf_bytes = io.BytesIO()
|
|
304
|
-
|
|
344
|
+
pdf_writer.write(pdf_bytes)
|
|
305
345
|
pdf_bytes.seek(0) # Rewind to start.
|
|
306
346
|
# Upload with Django default storage.
|
|
307
347
|
credential_file = ContentFile(pdf_bytes.read())
|
|
@@ -320,20 +360,15 @@ def _save_credential(credential: PdfWriter, credential_uuid: UUID) -> str:
|
|
|
320
360
|
return url
|
|
321
361
|
|
|
322
362
|
|
|
323
|
-
def generate_pdf_credential(
|
|
324
|
-
learning_context_key: CourseKey,
|
|
325
|
-
user: User,
|
|
326
|
-
credential_uuid: UUID,
|
|
327
|
-
options: dict[str, Any],
|
|
328
|
-
) -> str:
|
|
363
|
+
def generate_pdf_credential(credential: Credential, options: dict[str, Any], *, invalidate: bool = False) -> str:
|
|
329
364
|
r"""
|
|
330
|
-
Generate a PDF credential.
|
|
365
|
+
Generate or invalidate a PDF credential.
|
|
331
366
|
|
|
332
|
-
:param
|
|
333
|
-
:param user: The user to generate the credential for.
|
|
334
|
-
:param credential_uuid: The UUID of the credential to generate.
|
|
367
|
+
:param credential: The Credential instance to generate or invalidate the PDF for.
|
|
335
368
|
:param options: The custom options for the credential.
|
|
336
|
-
:
|
|
369
|
+
:param invalidate: If True, invalidates the credential instead of generating it.
|
|
370
|
+
The PDF is moved to an archive location and made inaccessible.
|
|
371
|
+
:returns: The URL of the saved credential, or empty string if invalidated.
|
|
337
372
|
|
|
338
373
|
Options:
|
|
339
374
|
|
|
@@ -372,12 +407,17 @@ def generate_pdf_credential(
|
|
|
372
407
|
}
|
|
373
408
|
}
|
|
374
409
|
"""
|
|
375
|
-
|
|
410
|
+
if invalidate:
|
|
411
|
+
log.info("Invalidating credential %s for user %s", credential.uuid, credential.user.id)
|
|
412
|
+
_invalidate_credential(credential.uuid)
|
|
413
|
+
return ''
|
|
414
|
+
|
|
415
|
+
log.info("Starting credential generation for user %s", credential.user.id)
|
|
376
416
|
|
|
377
|
-
username =
|
|
417
|
+
username = credential.user_full_name
|
|
378
418
|
|
|
379
419
|
# Handle multiline context name.
|
|
380
|
-
context_name =
|
|
420
|
+
context_name = credential.learning_context_name
|
|
381
421
|
custom_context_name = ''
|
|
382
422
|
custom_context_text_element = options.get('text_elements', {}).get('context', {})
|
|
383
423
|
if isinstance(custom_context_text_element, dict):
|
|
@@ -395,22 +435,24 @@ def generate_pdf_credential(
|
|
|
395
435
|
template_file = CredentialAsset.get_asset_by_slug(template_path)
|
|
396
436
|
|
|
397
437
|
# Get the issue date.
|
|
398
|
-
issue_date = get_localized_credential_date()
|
|
438
|
+
issue_date = get_localized_credential_date(credential.created)
|
|
399
439
|
|
|
400
440
|
# Load the PDF template.
|
|
401
441
|
with template_file.open('rb') as template_file:
|
|
402
442
|
template = PdfReader(template_file).pages[0]
|
|
403
443
|
|
|
404
|
-
|
|
444
|
+
pdf_writer = PdfWriter()
|
|
405
445
|
|
|
406
446
|
# Create a new canvas, prepare the page and write the data.
|
|
407
|
-
pdf_canvas = _write_text_on_template(
|
|
447
|
+
pdf_canvas = _write_text_on_template(
|
|
448
|
+
template, username, context_name, issue_date, str(credential.verify_uuid), options
|
|
449
|
+
)
|
|
408
450
|
|
|
409
451
|
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
410
452
|
template.merge_page(overlay_pdf.pages[0])
|
|
411
|
-
|
|
453
|
+
pdf_writer.add_page(template)
|
|
412
454
|
|
|
413
|
-
url = _save_credential(
|
|
455
|
+
url = _save_credential(pdf_writer, credential.uuid)
|
|
414
456
|
|
|
415
457
|
log.info("Credential saved to %s", url)
|
|
416
458
|
return url
|