learning-credentials 0.5.0rc1__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.5.0rc1/learning_credentials.egg-info → learning_credentials-0.5.0rc3}/PKG-INFO +1 -1
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/admin.py +39 -17
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/compat.py +13 -7
- {learning_credentials-0.5.0rc1 → 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/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.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/models.py +34 -25
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3/learning_credentials.egg-info}/PKG-INFO +1 -1
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/SOURCES.txt +4 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/pyproject.toml +1 -1
- learning_credentials-0.5.0rc3/tests/test_admin.py +462 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_generators.py +170 -40
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_models.py +157 -156
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_views.py +42 -1
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/CHANGELOG.rst +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/LICENSE.txt +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/MANIFEST.in +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/README.rst +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/__init__.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/__init__.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/urls.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/__init__.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/permissions.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/serializers.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/urls.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/views.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/apps.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/conf/locale/config.yaml +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/exceptions.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0001_initial.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0008_validation.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/__init__.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/processors.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/settings/__init__.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/settings/common.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/settings/production.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/tasks.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/base.html +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/verify.html +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/urls.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/dependency_links.txt +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/entry_points.txt +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/requires.txt +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/top_level.txt +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/setup.cfg +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_migrations.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_processors.py +0 -0
- {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_tasks.py +0 -0
{learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/admin.py
RENAMED
|
@@ -6,9 +6,11 @@ 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
11
|
from django.contrib import admin, messages
|
|
11
12
|
from django.core.exceptions import ValidationError
|
|
13
|
+
from django.db.models import URLField
|
|
12
14
|
from django.urls import reverse
|
|
13
15
|
from django.utils.html import format_html
|
|
14
16
|
from django_object_actions import DjangoObjectActions, action
|
|
@@ -228,10 +230,9 @@ class CredentialConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
|
|
|
228
230
|
@admin.register(Credential)
|
|
229
231
|
class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
|
|
230
232
|
list_display = (
|
|
231
|
-
'
|
|
233
|
+
'user',
|
|
232
234
|
'user_full_name',
|
|
233
|
-
'
|
|
234
|
-
'credential_type',
|
|
235
|
+
'configuration',
|
|
235
236
|
'status',
|
|
236
237
|
'url',
|
|
237
238
|
'created',
|
|
@@ -240,20 +241,26 @@ class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
|
|
|
240
241
|
readonly_fields = (
|
|
241
242
|
'uuid',
|
|
242
243
|
'verify_uuid',
|
|
243
|
-
'
|
|
244
|
+
'user',
|
|
245
|
+
'configuration',
|
|
244
246
|
'created',
|
|
245
247
|
'modified',
|
|
246
248
|
'user_full_name',
|
|
247
|
-
'learning_context_key',
|
|
248
249
|
'learning_context_name',
|
|
249
|
-
'credential_type',
|
|
250
250
|
'status',
|
|
251
251
|
'url',
|
|
252
252
|
'legacy_id',
|
|
253
253
|
'generation_task_id',
|
|
254
254
|
)
|
|
255
|
-
search_fields = (
|
|
256
|
-
|
|
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")
|
|
257
264
|
change_actions = ('reissue_credential',)
|
|
258
265
|
|
|
259
266
|
def save_model(self, request: HttpRequest, obj: Credential, _form: forms.ModelForm, _change: bool): # noqa: FBT001
|
|
@@ -281,12 +288,27 @@ class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
|
|
|
281
288
|
@action(label="Reissue credential", description="Reissue the credential for the user.")
|
|
282
289
|
def reissue_credential(self, request: HttpRequest, obj: Credential):
|
|
283
290
|
"""Reissue the credential for the user."""
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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)
|
{learning_credentials-0.5.0rc1 → 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.5.0rc1 → 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
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.db import migrations, models
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
import django.utils.timezone
|
|
5
|
+
import jsonfield.fields
|
|
6
|
+
import learning_credentials.models
|
|
7
|
+
import model_utils.fields
|
|
8
|
+
import opaque_keys.edx.django.models
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Migration(migrations.Migration):
|
|
13
|
+
replaces = [
|
|
14
|
+
('learning_credentials', '0001_initial'),
|
|
15
|
+
('learning_credentials', '0002_migrate_to_learning_credentials'),
|
|
16
|
+
('learning_credentials', '0003_rename_certificates_to_credentials'),
|
|
17
|
+
('learning_credentials', '0004_replace_course_keys_with_learning_context_keys'),
|
|
18
|
+
('learning_credentials', '0005_rename_processors_and_generators'),
|
|
19
|
+
('learning_credentials', '0006_cleanup_openedx_certificates_tables'),
|
|
20
|
+
('learning_credentials', '0007_migrate_to_text_elements_format'),
|
|
21
|
+
('learning_credentials', '0008_validation'),
|
|
22
|
+
('learning_credentials', '0009_credential_user_fk'),
|
|
23
|
+
('learning_credentials', '0010_credential_configuration_fk'),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
initial = True
|
|
27
|
+
|
|
28
|
+
dependencies = [
|
|
29
|
+
("django_celery_beat", "0019_alter_periodictasks_options"),
|
|
30
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
operations = [
|
|
34
|
+
migrations.CreateModel(
|
|
35
|
+
name="CredentialAsset",
|
|
36
|
+
fields=[
|
|
37
|
+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
38
|
+
(
|
|
39
|
+
"created",
|
|
40
|
+
model_utils.fields.AutoCreatedField(
|
|
41
|
+
default=django.utils.timezone.now, editable=False, verbose_name="created"
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
(
|
|
45
|
+
"modified",
|
|
46
|
+
model_utils.fields.AutoLastModifiedField(
|
|
47
|
+
default=django.utils.timezone.now, editable=False, verbose_name="modified"
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
("description", models.CharField(blank=True, help_text="Description of the asset.", max_length=255)),
|
|
51
|
+
(
|
|
52
|
+
"asset",
|
|
53
|
+
models.FileField(
|
|
54
|
+
help_text="Asset file. It could be a PDF template, image or font file.",
|
|
55
|
+
max_length=255,
|
|
56
|
+
upload_to=learning_credentials.models.CredentialAsset.template_assets_path,
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
"asset_slug",
|
|
61
|
+
models.SlugField(
|
|
62
|
+
help_text="Asset's unique slug. We can reference the asset in templates using this value.",
|
|
63
|
+
max_length=255,
|
|
64
|
+
unique=True,
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
],
|
|
68
|
+
options={"get_latest_by": "created"},
|
|
69
|
+
),
|
|
70
|
+
migrations.CreateModel(
|
|
71
|
+
name="CredentialType",
|
|
72
|
+
fields=[
|
|
73
|
+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
74
|
+
(
|
|
75
|
+
"created",
|
|
76
|
+
model_utils.fields.AutoCreatedField(
|
|
77
|
+
default=django.utils.timezone.now, editable=False, verbose_name="created"
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"modified",
|
|
82
|
+
model_utils.fields.AutoLastModifiedField(
|
|
83
|
+
default=django.utils.timezone.now, editable=False, verbose_name="modified"
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
("name", models.CharField(help_text="Name of the credential type.", max_length=255, unique=True)),
|
|
87
|
+
(
|
|
88
|
+
"retrieval_func",
|
|
89
|
+
models.CharField(help_text="A name of the function to retrieve eligible users.", max_length=200),
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
"generation_func",
|
|
93
|
+
models.CharField(help_text="A name of the function to generate credentials.", max_length=200),
|
|
94
|
+
),
|
|
95
|
+
(
|
|
96
|
+
"custom_options",
|
|
97
|
+
jsonfield.fields.JSONField(blank=True, default=dict, help_text="Custom options for the functions."),
|
|
98
|
+
),
|
|
99
|
+
],
|
|
100
|
+
options={"abstract": False},
|
|
101
|
+
),
|
|
102
|
+
migrations.CreateModel(
|
|
103
|
+
name="CredentialConfiguration",
|
|
104
|
+
fields=[
|
|
105
|
+
(
|
|
106
|
+
"id",
|
|
107
|
+
models.AutoField(
|
|
108
|
+
auto_created=True,
|
|
109
|
+
primary_key=True,
|
|
110
|
+
serialize=False,
|
|
111
|
+
verbose_name="ID",
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
(
|
|
115
|
+
"created",
|
|
116
|
+
model_utils.fields.AutoCreatedField(
|
|
117
|
+
default=django.utils.timezone.now,
|
|
118
|
+
editable=False,
|
|
119
|
+
verbose_name="created",
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
(
|
|
123
|
+
"modified",
|
|
124
|
+
model_utils.fields.AutoLastModifiedField(
|
|
125
|
+
default=django.utils.timezone.now,
|
|
126
|
+
editable=False,
|
|
127
|
+
verbose_name="modified",
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
"learning_context_key",
|
|
132
|
+
opaque_keys.edx.django.models.LearningContextKeyField(
|
|
133
|
+
help_text="ID of a learning context (e.g., a course or a Learning Path).",
|
|
134
|
+
max_length=255,
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
(
|
|
138
|
+
"custom_options",
|
|
139
|
+
jsonfield.fields.JSONField(
|
|
140
|
+
blank=True,
|
|
141
|
+
default=dict,
|
|
142
|
+
help_text="Custom options for the functions. If specified, they are merged with the options defined in the credential type.",
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
(
|
|
146
|
+
"credential_type",
|
|
147
|
+
models.ForeignKey(
|
|
148
|
+
help_text="Associated credential type.",
|
|
149
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
150
|
+
to="learning_credentials.credentialtype",
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
(
|
|
154
|
+
"periodic_task",
|
|
155
|
+
models.OneToOneField(
|
|
156
|
+
help_text="Associated periodic task.",
|
|
157
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
158
|
+
to="django_celery_beat.periodictask",
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
],
|
|
162
|
+
options={"unique_together": {("learning_context_key", "credential_type")}},
|
|
163
|
+
),
|
|
164
|
+
migrations.CreateModel(
|
|
165
|
+
name="Credential",
|
|
166
|
+
fields=[
|
|
167
|
+
(
|
|
168
|
+
"created",
|
|
169
|
+
model_utils.fields.AutoCreatedField(
|
|
170
|
+
default=django.utils.timezone.now, editable=False, verbose_name="created"
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
(
|
|
174
|
+
"modified",
|
|
175
|
+
model_utils.fields.AutoLastModifiedField(
|
|
176
|
+
default=django.utils.timezone.now, editable=False, verbose_name="modified"
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
(
|
|
180
|
+
"uuid",
|
|
181
|
+
models.UUIDField(
|
|
182
|
+
default=uuid.uuid4,
|
|
183
|
+
editable=False,
|
|
184
|
+
help_text="Auto-generated UUID of the credential",
|
|
185
|
+
primary_key=True,
|
|
186
|
+
serialize=False,
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
(
|
|
190
|
+
"verify_uuid",
|
|
191
|
+
models.UUIDField(
|
|
192
|
+
default=uuid.uuid4, editable=False, help_text="UUID used for verifying the credential"
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
(
|
|
196
|
+
"user_full_name",
|
|
197
|
+
models.CharField(
|
|
198
|
+
editable=False,
|
|
199
|
+
help_text="User receiving the credential. This field is used for validation purposes.",
|
|
200
|
+
max_length=255,
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
(
|
|
204
|
+
"learning_context_name",
|
|
205
|
+
models.CharField(
|
|
206
|
+
editable=False,
|
|
207
|
+
help_text="Name of the learning context for which the credential was issued. This field is used for validation purposes.",
|
|
208
|
+
max_length=255,
|
|
209
|
+
),
|
|
210
|
+
),
|
|
211
|
+
(
|
|
212
|
+
"status",
|
|
213
|
+
models.CharField(
|
|
214
|
+
choices=[
|
|
215
|
+
("generating", "Generating"),
|
|
216
|
+
("available", "Available"),
|
|
217
|
+
("error", "Error"),
|
|
218
|
+
("invalidated", "Invalidated"),
|
|
219
|
+
],
|
|
220
|
+
default="generating",
|
|
221
|
+
help_text="Status of the credential generation task",
|
|
222
|
+
max_length=32,
|
|
223
|
+
),
|
|
224
|
+
),
|
|
225
|
+
(
|
|
226
|
+
"download_url",
|
|
227
|
+
models.URLField(blank=True, help_text="URL of the generated credential PDF (e.g., to S3)"),
|
|
228
|
+
),
|
|
229
|
+
(
|
|
230
|
+
"legacy_id",
|
|
231
|
+
models.IntegerField(
|
|
232
|
+
help_text="Legacy ID of the credential imported from another system", null=True
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
("generation_task_id", models.CharField(help_text="Task ID from the Celery queue", max_length=255)),
|
|
236
|
+
(
|
|
237
|
+
"invalidated_at",
|
|
238
|
+
models.DateTimeField(
|
|
239
|
+
editable=False, help_text="Timestamp when the credential was invalidated", null=True
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
(
|
|
243
|
+
"invalidation_reason",
|
|
244
|
+
models.CharField(blank=True, help_text="Reason for invalidating the credential", max_length=255),
|
|
245
|
+
),
|
|
246
|
+
(
|
|
247
|
+
"configuration",
|
|
248
|
+
models.ForeignKey(
|
|
249
|
+
help_text="Associated credential configuration",
|
|
250
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
251
|
+
to="learning_credentials.credentialconfiguration",
|
|
252
|
+
),
|
|
253
|
+
),
|
|
254
|
+
(
|
|
255
|
+
"user",
|
|
256
|
+
models.ForeignKey(
|
|
257
|
+
help_text="User receiving the credential",
|
|
258
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
259
|
+
to=settings.AUTH_USER_MODEL,
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
],
|
|
263
|
+
options={"abstract": False},
|
|
264
|
+
),
|
|
265
|
+
]
|