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.
Files changed (60) hide show
  1. {learning_credentials-0.5.0rc1/learning_credentials.egg-info → learning_credentials-0.5.0rc3}/PKG-INFO +1 -1
  2. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/admin.py +39 -17
  3. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/compat.py +13 -7
  4. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/generators.py +81 -39
  5. learning_credentials-0.5.0rc3/learning_credentials/migrations/0001_squashed_0010.py +265 -0
  6. learning_credentials-0.5.0rc3/learning_credentials/migrations/0009_credential_user_fk.py +63 -0
  7. learning_credentials-0.5.0rc3/learning_credentials/migrations/0010_credential_configuration_fk.py +79 -0
  8. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/models.py +34 -25
  9. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3/learning_credentials.egg-info}/PKG-INFO +1 -1
  10. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/SOURCES.txt +4 -0
  11. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/pyproject.toml +1 -1
  12. learning_credentials-0.5.0rc3/tests/test_admin.py +462 -0
  13. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_generators.py +170 -40
  14. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_models.py +157 -156
  15. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_views.py +42 -1
  16. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/CHANGELOG.rst +0 -0
  17. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/LICENSE.txt +0 -0
  18. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/MANIFEST.in +0 -0
  19. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/README.rst +0 -0
  20. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/__init__.py +0 -0
  21. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/__init__.py +0 -0
  22. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/urls.py +0 -0
  23. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/__init__.py +0 -0
  24. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/permissions.py +0 -0
  25. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/serializers.py +0 -0
  26. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/urls.py +0 -0
  27. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/api/v1/views.py +0 -0
  28. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/apps.py +0 -0
  29. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/conf/locale/config.yaml +0 -0
  30. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/exceptions.py +0 -0
  31. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0001_initial.py +0 -0
  32. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
  33. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
  34. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
  35. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
  36. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
  37. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +0 -0
  38. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/0008_validation.py +0 -0
  39. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/migrations/__init__.py +0 -0
  40. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/processors.py +0 -0
  41. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/settings/__init__.py +0 -0
  42. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/settings/common.py +0 -0
  43. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/settings/production.py +0 -0
  44. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/tasks.py +0 -0
  45. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/base.html +0 -0
  46. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
  47. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
  48. {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
  49. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
  50. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
  51. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/templates/learning_credentials/verify.html +0 -0
  52. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials/urls.py +0 -0
  53. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/dependency_links.txt +0 -0
  54. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/entry_points.txt +0 -0
  55. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/requires.txt +0 -0
  56. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/learning_credentials.egg-info/top_level.txt +0 -0
  57. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/setup.cfg +0 -0
  58. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_migrations.py +0 -0
  59. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_processors.py +0 -0
  60. {learning_credentials-0.5.0rc1 → learning_credentials-0.5.0rc3}/tests/test_tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.5.0rc1
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
@@ -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
- 'user_id',
233
+ 'user',
232
234
  'user_full_name',
233
- 'learning_context_key',
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
- 'user_id',
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 = ("learning_context_key", "user_id", "user_full_name", "uuid")
256
- list_filter = ("learning_context_key", "credential_type", "status")
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
- 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.")
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)
@@ -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: # pragma: no cover
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 # pragma: no cover
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
- """Get the localized date from Open edX."""
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
- date = datetime.now(pytz.timezone(settings.TIME_ZONE))
125
- return strftime_localized(date, settings.CERTIFICATE_DATE_FORMAT)
130
+ localized_date = date.astimezone(pytz.timezone(settings.TIME_ZONE))
131
+ return strftime_localized(localized_date, settings.CERTIFICATE_DATE_FORMAT)
@@ -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, get_learning_context_name, get_localized_credential_date
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 _save_credential(credential: PdfWriter, credential_uuid: UUID) -> str:
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 credential: Pdf credential.
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
- # Save the final PDF file to BytesIO.
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
- credential.encrypt('', secrets.token_hex(32), permissions_flag=view_print_extract_permission, algorithm='AES-256')
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
- credential.write(pdf_bytes)
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 learning_context_key: The ID of the course or learning path the credential is for.
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
- :returns: The URL of the saved credential.
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
- log.info("Starting credential generation for user %s", user.id)
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 = _get_user_name(user)
417
+ username = credential.user_full_name
378
418
 
379
419
  # Handle multiline context name.
380
- context_name = get_learning_context_name(learning_context_key)
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
- credential = PdfWriter()
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(template, username, context_name, issue_date, options)
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
- credential.add_page(template)
453
+ pdf_writer.add_page(template)
412
454
 
413
- url = _save_credential(credential, credential_uuid)
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
+ ]