learning-credentials 0.5.0rc1__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.
- learning_credentials/admin.py +39 -17
- learning_credentials/compat.py +13 -7
- learning_credentials/generators.py +81 -39
- learning_credentials/migrations/0001_squashed_0010.py +265 -0
- learning_credentials/migrations/0009_credential_user_fk.py +63 -0
- learning_credentials/migrations/0010_credential_configuration_fk.py +79 -0
- learning_credentials/models.py +34 -25
- {learning_credentials-0.5.0rc1.dist-info → learning_credentials-0.5.0rc3.dist-info}/METADATA +1 -1
- {learning_credentials-0.5.0rc1.dist-info → learning_credentials-0.5.0rc3.dist-info}/RECORD +13 -10
- {learning_credentials-0.5.0rc1.dist-info → learning_credentials-0.5.0rc3.dist-info}/WHEEL +0 -0
- {learning_credentials-0.5.0rc1.dist-info → learning_credentials-0.5.0rc3.dist-info}/entry_points.txt +0 -0
- {learning_credentials-0.5.0rc1.dist-info → learning_credentials-0.5.0rc3.dist-info}/licenses/LICENSE.txt +0 -0
- {learning_credentials-0.5.0rc1.dist-info → learning_credentials-0.5.0rc3.dist-info}/top_level.txt +0 -0
learning_credentials/admin.py
CHANGED
|
@@ -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/compat.py
CHANGED
|
@@ -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)
|
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.db import migrations, models
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def populate_user_fk(apps, schema_editor):
|
|
7
|
+
"""Copy user_id values to the new user FK field."""
|
|
8
|
+
Credential = apps.get_model("learning_credentials", "Credential")
|
|
9
|
+
for credential in Credential.objects.all():
|
|
10
|
+
credential.user_id = credential.user_id_old
|
|
11
|
+
credential.save(update_fields=["user_id"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def reverse_populate_user_fk(apps, schema_editor):
|
|
15
|
+
"""Copy user FK values back to user_id_old."""
|
|
16
|
+
Credential = apps.get_model("learning_credentials", "Credential")
|
|
17
|
+
for credential in Credential.objects.all():
|
|
18
|
+
credential.user_id_old = credential.user_id
|
|
19
|
+
credential.save(update_fields=["user_id_old"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Migration(migrations.Migration):
|
|
23
|
+
dependencies = [
|
|
24
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
25
|
+
("learning_credentials", "0008_validation"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
operations = [
|
|
29
|
+
# Rename the old user_id field to user_id_old.
|
|
30
|
+
migrations.RenameField(
|
|
31
|
+
model_name="credential",
|
|
32
|
+
old_name="user_id",
|
|
33
|
+
new_name="user_id_old",
|
|
34
|
+
),
|
|
35
|
+
# Add the new user FK field (nullable initially).
|
|
36
|
+
migrations.AddField(
|
|
37
|
+
model_name="credential",
|
|
38
|
+
name="user",
|
|
39
|
+
field=models.ForeignKey(
|
|
40
|
+
help_text="User receiving the credential",
|
|
41
|
+
null=True,
|
|
42
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
43
|
+
to=settings.AUTH_USER_MODEL,
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
# Populate the user FK from user_id_old.
|
|
47
|
+
migrations.RunPython(populate_user_fk, reverse_code=reverse_populate_user_fk),
|
|
48
|
+
# Make the user FK non-nullable.
|
|
49
|
+
migrations.AlterField(
|
|
50
|
+
model_name="credential",
|
|
51
|
+
name="user",
|
|
52
|
+
field=models.ForeignKey(
|
|
53
|
+
help_text="User receiving the credential",
|
|
54
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
55
|
+
to=settings.AUTH_USER_MODEL,
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
# Remove the old user_id_old field.
|
|
59
|
+
migrations.RemoveField(
|
|
60
|
+
model_name="credential",
|
|
61
|
+
name="user_id_old",
|
|
62
|
+
),
|
|
63
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
import django.db.models.deletion
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def populate_configuration_fk(apps, schema_editor):
|
|
6
|
+
"""
|
|
7
|
+
Populate the configuration FK by finding the matching CredentialConfiguration.
|
|
8
|
+
"""
|
|
9
|
+
Credential = apps.get_model("learning_credentials", "Credential")
|
|
10
|
+
CredentialConfiguration = apps.get_model("learning_credentials", "CredentialConfiguration")
|
|
11
|
+
|
|
12
|
+
for credential in Credential.objects.all():
|
|
13
|
+
try:
|
|
14
|
+
config = CredentialConfiguration.objects.get(
|
|
15
|
+
learning_context_key=credential.learning_context_key,
|
|
16
|
+
credential_type__name=credential.credential_type,
|
|
17
|
+
)
|
|
18
|
+
credential.configuration = config
|
|
19
|
+
credential.save(update_fields=["configuration"])
|
|
20
|
+
except CredentialConfiguration.DoesNotExist:
|
|
21
|
+
# If no matching configuration exists, we cannot migrate this credential.
|
|
22
|
+
# This should not happen in normal circumstances.
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"No CredentialConfiguration found for Credential {credential.uuid} "
|
|
25
|
+
f"with learning_context_key={credential.learning_context_key} "
|
|
26
|
+
f"and credential_type={credential.credential_type}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def reverse_populate_configuration_fk(apps, schema_editor):
|
|
31
|
+
"""Reverse migration: populate credential_type and learning_context_key from configuration."""
|
|
32
|
+
Credential = apps.get_model("learning_credentials", "Credential")
|
|
33
|
+
|
|
34
|
+
for credential in Credential.objects.select_related("configuration__credential_type").all():
|
|
35
|
+
credential.credential_type = credential.configuration.credential_type.name
|
|
36
|
+
credential.learning_context_key = credential.configuration.learning_context_key
|
|
37
|
+
credential.save(update_fields=["credential_type", "learning_context_key"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Migration(migrations.Migration):
|
|
41
|
+
dependencies = [
|
|
42
|
+
("learning_credentials", "0009_credential_user_fk"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
operations = [
|
|
46
|
+
# Add the new configuration FK field (nullable initially).
|
|
47
|
+
migrations.AddField(
|
|
48
|
+
model_name="credential",
|
|
49
|
+
name="configuration",
|
|
50
|
+
field=models.ForeignKey(
|
|
51
|
+
help_text="Associated credential configuration",
|
|
52
|
+
null=True,
|
|
53
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
54
|
+
to="learning_credentials.credentialconfiguration",
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
# Populate the configuration FK from credential_type and learning_context_key.
|
|
58
|
+
migrations.RunPython(populate_configuration_fk, reverse_code=reverse_populate_configuration_fk),
|
|
59
|
+
# Make the configuration FK non-nullable
|
|
60
|
+
migrations.AlterField(
|
|
61
|
+
model_name="credential",
|
|
62
|
+
name="configuration",
|
|
63
|
+
field=models.ForeignKey(
|
|
64
|
+
help_text="Associated credential configuration",
|
|
65
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
66
|
+
to="learning_credentials.credentialconfiguration",
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
# Remove the old credential_type field.
|
|
70
|
+
migrations.RemoveField(
|
|
71
|
+
model_name="credential",
|
|
72
|
+
name="credential_type",
|
|
73
|
+
),
|
|
74
|
+
# Remove the redundant learning_context_key field (now accessible via configuration).
|
|
75
|
+
migrations.RemoveField(
|
|
76
|
+
model_name="credential",
|
|
77
|
+
name="learning_context_key",
|
|
78
|
+
),
|
|
79
|
+
]
|
learning_credentials/models.py
CHANGED
|
@@ -176,8 +176,7 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
176
176
|
2. Have such a credential with an error status.
|
|
177
177
|
"""
|
|
178
178
|
users_ids_with_credentials = Credential.objects.filter(
|
|
179
|
-
models.Q(
|
|
180
|
-
models.Q(credential_type=self.credential_type),
|
|
179
|
+
models.Q(configuration=self),
|
|
181
180
|
~(models.Q(status=Credential.Status.ERROR)),
|
|
182
181
|
).values_list('user_id', flat=True)
|
|
183
182
|
|
|
@@ -221,9 +220,8 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
221
220
|
custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
|
|
222
221
|
|
|
223
222
|
credential, _ = Credential.objects.exclude(status=Credential.Status.INVALIDATED).update_or_create(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
credential_type=self.credential_type.name,
|
|
223
|
+
user=user,
|
|
224
|
+
configuration=self,
|
|
227
225
|
defaults={
|
|
228
226
|
'user_full_name': user_full_name,
|
|
229
227
|
'learning_context_name': learning_context_name,
|
|
@@ -238,13 +236,13 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
238
236
|
generation_func = getattr(generation_module, generation_func_name)
|
|
239
237
|
|
|
240
238
|
# Run the functions. We do not validate them here, as they are validated in the model's clean() method.
|
|
241
|
-
credential.download_url = generation_func(
|
|
239
|
+
credential.download_url = generation_func(credential, custom_options)
|
|
242
240
|
credential.status = Credential.Status.AVAILABLE
|
|
243
241
|
credential.save()
|
|
244
242
|
except Exception as exc:
|
|
245
243
|
credential.status = Credential.Status.ERROR
|
|
246
244
|
credential.save()
|
|
247
|
-
msg = f'Failed to generate the {credential.uuid=} for {user_id=} with {self.id=}
|
|
245
|
+
msg = f'Failed to generate the {credential.uuid=} for {user_id=} with {self.id=}.\nReason: {exc}'
|
|
248
246
|
raise CredentialGenerationError(msg) from exc
|
|
249
247
|
else:
|
|
250
248
|
# TODO: In the future, we want to check this before generating the credential.
|
|
@@ -297,16 +295,16 @@ class Credential(TimeStampedModel):
|
|
|
297
295
|
editable=False,
|
|
298
296
|
help_text=_('UUID used for verifying the credential'),
|
|
299
297
|
)
|
|
300
|
-
|
|
298
|
+
user = models.ForeignKey(
|
|
299
|
+
settings.AUTH_USER_MODEL,
|
|
300
|
+
on_delete=models.CASCADE,
|
|
301
|
+
help_text=_('User receiving the credential'),
|
|
302
|
+
)
|
|
301
303
|
user_full_name = models.CharField(
|
|
302
304
|
max_length=255,
|
|
303
305
|
editable=False,
|
|
304
306
|
help_text=_('User receiving the credential. This field is used for validation purposes.'),
|
|
305
307
|
)
|
|
306
|
-
learning_context_key = LearningContextKeyField(
|
|
307
|
-
max_length=255,
|
|
308
|
-
help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
|
|
309
|
-
)
|
|
310
308
|
learning_context_name = models.CharField(
|
|
311
309
|
max_length=255,
|
|
312
310
|
editable=False,
|
|
@@ -315,7 +313,11 @@ class Credential(TimeStampedModel):
|
|
|
315
313
|
'This field is used for validation purposes.'
|
|
316
314
|
),
|
|
317
315
|
)
|
|
318
|
-
|
|
316
|
+
configuration = models.ForeignKey(
|
|
317
|
+
'CredentialConfiguration',
|
|
318
|
+
on_delete=models.PROTECT,
|
|
319
|
+
help_text=_('Associated credential configuration'),
|
|
320
|
+
)
|
|
319
321
|
status = models.CharField(
|
|
320
322
|
max_length=32,
|
|
321
323
|
choices=Status.choices,
|
|
@@ -332,24 +334,36 @@ class Credential(TimeStampedModel):
|
|
|
332
334
|
max_length=255, blank=True, help_text=_('Reason for invalidating the credential')
|
|
333
335
|
)
|
|
334
336
|
|
|
335
|
-
def __str__(self):
|
|
336
|
-
|
|
337
|
+
def __str__(self):
|
|
338
|
+
"""Get a string representation of this model's instance."""
|
|
339
|
+
return (
|
|
340
|
+
f"{self.configuration.credential_type.name} for {self.user_full_name} "
|
|
341
|
+
f"in {self.configuration.learning_context_key}"
|
|
342
|
+
)
|
|
337
343
|
|
|
338
344
|
def save(self, *args, **kwargs):
|
|
339
|
-
"""If the invalidation reason is set,
|
|
345
|
+
"""If the invalidation reason is set, trigger the invalidation and update the timestamp."""
|
|
340
346
|
if self.invalidation_reason and self.status != Credential.Status.INVALIDATED:
|
|
341
|
-
self.
|
|
347
|
+
self._invalidate()
|
|
342
348
|
if self.status == Credential.Status.INVALIDATED and not self.invalidated_at:
|
|
343
349
|
self.invalidated_at = timezone.now()
|
|
344
350
|
super().save(*args, **kwargs)
|
|
345
351
|
|
|
352
|
+
def _invalidate(self):
|
|
353
|
+
"""Trigger the invalidation process for the credential."""
|
|
354
|
+
generation_module_name, generation_func_name = self.configuration.credential_type.generation_func.rsplit('.', 1)
|
|
355
|
+
generation_module = import_module(generation_module_name)
|
|
356
|
+
generation_func = getattr(generation_module, generation_func_name)
|
|
357
|
+
|
|
358
|
+
self.download_url = generation_func(self, {}, invalidate=True)
|
|
359
|
+
self.status = Credential.Status.INVALIDATED
|
|
360
|
+
|
|
346
361
|
def send_email(self):
|
|
347
362
|
"""Send a credential link to the student."""
|
|
348
|
-
user = get_user_model().objects.get(id=self.user_id)
|
|
349
363
|
msg = Message(
|
|
350
364
|
name="certificate_generated",
|
|
351
365
|
app_label="learning_credentials",
|
|
352
|
-
recipient=Recipient(lms_user_id=user.id, email_address=user.email),
|
|
366
|
+
recipient=Recipient(lms_user_id=self.user.id, email_address=self.user.email),
|
|
353
367
|
language='en',
|
|
354
368
|
context={
|
|
355
369
|
'certificate_link': self.download_url,
|
|
@@ -361,17 +375,12 @@ class Credential(TimeStampedModel):
|
|
|
361
375
|
|
|
362
376
|
def reissue(self) -> Self:
|
|
363
377
|
"""Invalidate the current credential and create a new one."""
|
|
364
|
-
config = CredentialConfiguration.objects.get(
|
|
365
|
-
learning_context_key=self.learning_context_key,
|
|
366
|
-
credential_type__name=self.credential_type,
|
|
367
|
-
)
|
|
368
|
-
|
|
369
378
|
if self.invalidation_reason:
|
|
370
379
|
self.invalidation_reason += '\n'
|
|
371
380
|
self.invalidation_reason += 'Reissued'
|
|
372
381
|
self.save()
|
|
373
382
|
|
|
374
|
-
return
|
|
383
|
+
return self.configuration.generate_credential_for_user(self.user.id)
|
|
375
384
|
|
|
376
385
|
|
|
377
386
|
class CredentialAsset(TimeStampedModel):
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
learning_credentials/__init__.py,sha256=8Q0-3Hdnfmcj41EKu1GSfzEfwWcYNDlItyEEke2r9bs,62
|
|
2
|
-
learning_credentials/admin.py,sha256=
|
|
2
|
+
learning_credentials/admin.py,sha256=AkbLqeFyGEmEsv-EGU4tD3KeCOd6ldvopg2uj_sRUAA,12558
|
|
3
3
|
learning_credentials/apps.py,sha256=trdQxe-JRhUdUaOQoQWiGL1sn6I1sfDiTvdCwy8yGuw,1037
|
|
4
|
-
learning_credentials/compat.py,sha256=
|
|
4
|
+
learning_credentials/compat.py,sha256=EVUxlybL3ugzquPgL0sst7yAQ9qpyAplloTzS52jF5o,4826
|
|
5
5
|
learning_credentials/exceptions.py,sha256=UaqBVXFMWR2Iob7_LMb3j4NNVmWQFAgLi_MNMRUvGsI,290
|
|
6
|
-
learning_credentials/generators.py,sha256=
|
|
7
|
-
learning_credentials/models.py,sha256=
|
|
6
|
+
learning_credentials/generators.py,sha256=2tAeqmEaK5zaygloLXQNHjDXMAalYlwJprx1KmL1pUY,16969
|
|
7
|
+
learning_credentials/models.py,sha256=JF2xtZWDCA7WRY0kBopzxgazPngpqHAM67wdPwdis1M,18602
|
|
8
8
|
learning_credentials/processors.py,sha256=LkdjmkLBnXc9qeMcksB1T8AQ5ZhYaECyQO__KfHB_aU,15212
|
|
9
9
|
learning_credentials/tasks.py,sha256=byoFEUvN_ayVaU5K5SlEiA7vu9BRPaSSmKnB9g5toec,1927
|
|
10
10
|
learning_credentials/urls.py,sha256=KXZtvPXXl2X_nTREWaCFxcAgY2XET1eWRbcx2rq_6eI,348
|
|
@@ -17,6 +17,7 @@ learning_credentials/api/v1/urls.py,sha256=RytArViuKZQkWs46sk58VfaVCwLV-QrgTG7cQ
|
|
|
17
17
|
learning_credentials/api/v1/views.py,sha256=CJEVPwCXs_ii463agPRpJeX6NCgyyFX9ZIBJh0BAc9I,4926
|
|
18
18
|
learning_credentials/conf/locale/config.yaml,sha256=jPen2DmckNDKK30axCKEd2Q2ha9oOG3IBxrJ63Pvznk,2280
|
|
19
19
|
learning_credentials/migrations/0001_initial.py,sha256=61EvThCv-0UAnhCE5feyQVfjRodbp-6cDaAr4CY5PMA,8435
|
|
20
|
+
learning_credentials/migrations/0001_squashed_0010.py,sha256=STQ4MRVfW7F-Dfqb8DrdiezQavn0wlwWfjGpGFmDcqc,10944
|
|
20
21
|
learning_credentials/migrations/0002_migrate_to_learning_credentials.py,sha256=vUhcnQKDdwOsppkXsjz2zZwOGMwIJ-fkQRsaj-K7l1o,1779
|
|
21
22
|
learning_credentials/migrations/0003_rename_certificates_to_credentials.py,sha256=YqSaHTB60VNc9k245um2GYVDH6J0l9BrN3ak6WKljjk,4677
|
|
22
23
|
learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py,sha256=5KaXvASl69qbEaHX5_Ty_3Dr7K4WV6p8VWOx72yJnTU,1919
|
|
@@ -24,6 +25,8 @@ learning_credentials/migrations/0005_rename_processors_and_generators.py,sha256=
|
|
|
24
25
|
learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py,sha256=aJs_gOP4TmW9J-Dmr21m94jBfLQxzjAu6-ua7x4uYLE,727
|
|
25
26
|
learning_credentials/migrations/0007_migrate_to_text_elements_format.py,sha256=_olkaxPPuRys2c2X5fnyQIFVvqEfdoYu-JlApmXuHEM,4758
|
|
26
27
|
learning_credentials/migrations/0008_validation.py,sha256=jcTg4Lnlmcyp1Czc9b-52gPJ_s8W7Dwodvi_LggpVjw,3387
|
|
28
|
+
learning_credentials/migrations/0009_credential_user_fk.py,sha256=yT1YdE1ptZ8ZT7bWVQyTpgxs--UifGxK3V10ehHZ5Ig,2233
|
|
29
|
+
learning_credentials/migrations/0010_credential_configuration_fk.py,sha256=z3OZCgs_AJzxxyBvWVvvUSTXKPjYvBN8xd8VajwYBZA,3415
|
|
27
30
|
learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
31
|
learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
|
|
29
32
|
learning_credentials/settings/common.py,sha256=Cck-nyFt11G1NLiz-bHfKJp8MV6sDZGqTwdbC8_1WE0,360
|
|
@@ -35,9 +38,9 @@ learning_credentials/templates/learning_credentials/edx_ace/certificate_generate
|
|
|
35
38
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
|
|
36
39
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
37
40
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt,sha256=S7Hc5T_sZSsSBXm5_H5HBNNv16Ohl0oZn0nVqqeWL0g,132
|
|
38
|
-
learning_credentials-0.5.
|
|
39
|
-
learning_credentials-0.5.
|
|
40
|
-
learning_credentials-0.5.
|
|
41
|
-
learning_credentials-0.5.
|
|
42
|
-
learning_credentials-0.5.
|
|
43
|
-
learning_credentials-0.5.
|
|
41
|
+
learning_credentials-0.5.0rc3.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
|
|
42
|
+
learning_credentials-0.5.0rc3.dist-info/METADATA,sha256=eOcIS9drEVJVDExK5zGolz5e3apUQWBEnfIzeTa8-vo,8461
|
|
43
|
+
learning_credentials-0.5.0rc3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
44
|
+
learning_credentials-0.5.0rc3.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
|
|
45
|
+
learning_credentials-0.5.0rc3.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
|
|
46
|
+
learning_credentials-0.5.0rc3.dist-info/RECORD,,
|
|
File without changes
|
{learning_credentials-0.5.0rc1.dist-info → learning_credentials-0.5.0rc3.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{learning_credentials-0.5.0rc1.dist-info → learning_credentials-0.5.0rc3.dist-info}/top_level.txt
RENAMED
|
File without changes
|