learning-credentials 0.4.1rc6__py3-none-any.whl → 0.5.0rc3__py3-none-any.whl

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.
@@ -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
- 'user_id',
233
+ 'user',
231
234
  'user_full_name',
232
- 'learning_context_key',
233
- 'credential_type',
235
+ 'configuration',
234
236
  'status',
235
237
  'url',
236
238
  'created',
237
239
  'modified',
238
240
  )
239
241
  readonly_fields = (
240
- 'user_id',
242
+ 'uuid',
243
+ 'verify_uuid',
244
+ 'user',
245
+ 'configuration',
241
246
  'created',
242
247
  'modified',
243
248
  'user_full_name',
244
- 'learning_context_key',
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 = ("learning_context_key", "user_id", "user_full_name")
252
- 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")
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')
@@ -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
  ]
@@ -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)
@@ -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