learning-credentials 0.2.2rc2__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/__init__.py +1 -0
- learning_credentials/admin.py +265 -0
- learning_credentials/apps.py +24 -0
- learning_credentials/compat.py +119 -0
- learning_credentials/conf/locale/config.yaml +85 -0
- learning_credentials/exceptions.py +9 -0
- learning_credentials/generators.py +225 -0
- learning_credentials/migrations/0001_initial.py +205 -0
- learning_credentials/migrations/0002_migrate_to_learning_credentials.py +40 -0
- learning_credentials/migrations/0003_rename_certificates_to_credentials.py +128 -0
- learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +59 -0
- learning_credentials/migrations/0005_rename_processors_and_generators.py +106 -0
- learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +21 -0
- learning_credentials/migrations/__init__.py +0 -0
- learning_credentials/models.py +381 -0
- learning_credentials/processors.py +378 -0
- learning_credentials/settings/__init__.py +1 -0
- learning_credentials/settings/common.py +9 -0
- learning_credentials/settings/production.py +13 -0
- learning_credentials/tasks.py +53 -0
- learning_credentials/templates/learning_credentials/base.html +22 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +22 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +13 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +1 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +4 -0
- learning_credentials/urls.py +9 -0
- learning_credentials/views.py +1 -0
- learning_credentials-0.2.2rc2.dist-info/METADATA +212 -0
- learning_credentials-0.2.2rc2.dist-info/RECORD +34 -0
- learning_credentials-0.2.2rc2.dist-info/WHEEL +5 -0
- learning_credentials-0.2.2rc2.dist-info/entry_points.txt +2 -0
- learning_credentials-0.2.2rc2.dist-info/licenses/LICENSE.txt +664 -0
- learning_credentials-0.2.2rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from django.db import migrations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def update_function_references_and_json_keys(apps, schema_editor):
|
|
5
|
+
"""
|
|
6
|
+
Update both function references and JSON field keys:
|
|
7
|
+
1. Replace 'openedx_certificates' prefix with 'learning_credentials'.
|
|
8
|
+
2. Replace 'retrieve_course_completions' with 'retrieve_completions'.
|
|
9
|
+
3. Replace JSON keys 'course_name*' with 'context_name*'.
|
|
10
|
+
4. Replace the task path in PeriodicTask records.
|
|
11
|
+
"""
|
|
12
|
+
CredentialType = apps.get_model('learning_credentials', 'CredentialType')
|
|
13
|
+
CredentialConfiguration = apps.get_model('learning_credentials', 'CredentialConfiguration')
|
|
14
|
+
|
|
15
|
+
for credential_type in CredentialType.objects.all():
|
|
16
|
+
credential_type.retrieval_func = credential_type.retrieval_func.replace(
|
|
17
|
+
'openedx_certificates', 'learning_credentials'
|
|
18
|
+
).replace(
|
|
19
|
+
'retrieve_course_completions', 'retrieve_completions'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
credential_type.generation_func = credential_type.generation_func.replace(
|
|
23
|
+
'openedx_certificates', 'learning_credentials'
|
|
24
|
+
).replace(
|
|
25
|
+
'generate_pdf_certificate', 'generate_pdf_credential'
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if credential_type.custom_options:
|
|
29
|
+
if 'course_name' in credential_type.custom_options:
|
|
30
|
+
credential_type.custom_options['context_name'] = credential_type.custom_options.pop('course_name')
|
|
31
|
+
if 'course_name_y' in credential_type.custom_options:
|
|
32
|
+
credential_type.custom_options['context_name_y'] = credential_type.custom_options.pop('course_name_y')
|
|
33
|
+
if 'course_name_color' in credential_type.custom_options:
|
|
34
|
+
credential_type.custom_options['context_name_color'] = credential_type.custom_options.pop('course_name_color')
|
|
35
|
+
|
|
36
|
+
credential_type.save()
|
|
37
|
+
|
|
38
|
+
for config in CredentialConfiguration.objects.all():
|
|
39
|
+
if config.custom_options:
|
|
40
|
+
if 'course_name' in config.custom_options:
|
|
41
|
+
config.custom_options['context_name'] = config.custom_options.pop('course_name')
|
|
42
|
+
if 'course_name_y' in config.custom_options:
|
|
43
|
+
config.custom_options['context_name_y'] = config.custom_options.pop('course_name_y')
|
|
44
|
+
if 'course_name_color' in config.custom_options:
|
|
45
|
+
config.custom_options['context_name_color'] = config.custom_options.pop('course_name_color')
|
|
46
|
+
|
|
47
|
+
if config.periodic_task.task == 'openedx_certificates.tasks.generate_certificates_for_course_task':
|
|
48
|
+
config.periodic_task.task = 'learning_credentials.tasks.generate_credentials_for_config_task'
|
|
49
|
+
config.periodic_task.save()
|
|
50
|
+
|
|
51
|
+
config.save()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reverse_function_references_and_json_keys(apps, schema_editor):
|
|
55
|
+
"""Reverse the changes made to function references and JSON keys."""
|
|
56
|
+
CredentialType = apps.get_model('learning_credentials', 'CredentialType')
|
|
57
|
+
CredentialConfiguration = apps.get_model('learning_credentials', 'CredentialConfiguration')
|
|
58
|
+
|
|
59
|
+
for credential_type in CredentialType.objects.all():
|
|
60
|
+
credential_type.retrieval_func = credential_type.retrieval_func.replace(
|
|
61
|
+
'learning_credentials', 'openedx_certificates'
|
|
62
|
+
).replace(
|
|
63
|
+
'retrieve_completions', 'retrieve_course_completions'
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
credential_type.generation_func = credential_type.generation_func.replace(
|
|
67
|
+
'learning_credentials', 'openedx_certificates'
|
|
68
|
+
).replace(
|
|
69
|
+
'generate_pdf_credential', 'generate_pdf_certificate'
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if credential_type.custom_options:
|
|
73
|
+
if 'context_name' in credential_type.custom_options:
|
|
74
|
+
credential_type.custom_options['course_name'] = credential_type.custom_options.pop('context_name')
|
|
75
|
+
if 'context_name_y' in credential_type.custom_options:
|
|
76
|
+
credential_type.custom_options['course_name_y'] = credential_type.custom_options.pop('context_name_y')
|
|
77
|
+
if 'context_name_color' in credential_type.custom_options:
|
|
78
|
+
credential_type.custom_options['course_name_color'] = credential_type.custom_options.pop('context_name_color')
|
|
79
|
+
|
|
80
|
+
credential_type.save()
|
|
81
|
+
|
|
82
|
+
for config in CredentialConfiguration.objects.all():
|
|
83
|
+
if config.custom_options:
|
|
84
|
+
if 'context_name' in config.custom_options:
|
|
85
|
+
config.custom_options['course_name'] = config.custom_options.pop('context_name')
|
|
86
|
+
if 'context_name_y' in config.custom_options:
|
|
87
|
+
config.custom_options['course_name_y'] = config.custom_options.pop('context_name_y')
|
|
88
|
+
if 'context_name_color' in config.custom_options:
|
|
89
|
+
config.custom_options['course_name_color'] = config.custom_options.pop('context_name_color')
|
|
90
|
+
|
|
91
|
+
if config.periodic_task.task == 'learning_credentials.tasks.generate_credentials_for_config_task':
|
|
92
|
+
config.periodic_task.task = 'openedx_certificates.tasks.generate_certificates_for_course_task'
|
|
93
|
+
config.periodic_task.save()
|
|
94
|
+
|
|
95
|
+
config.save()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Migration(migrations.Migration):
|
|
99
|
+
|
|
100
|
+
dependencies = [
|
|
101
|
+
('learning_credentials', '0004_replace_course_keys_with_learning_context_keys'),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
operations = [
|
|
105
|
+
migrations.RunPython(update_function_references_and_json_keys, reverse_function_references_and_json_keys),
|
|
106
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Clean up legacy `openedx_certificates` tables.
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
dependencies = [
|
|
8
|
+
("learning_credentials", "0005_rename_processors_and_generators"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
operations = [
|
|
12
|
+
migrations.RunSQL(
|
|
13
|
+
sql=[
|
|
14
|
+
"DROP TABLE IF EXISTS openedx_certificates_externalcertificate;",
|
|
15
|
+
"DROP TABLE IF EXISTS openedx_certificates_externalcertificateasset;",
|
|
16
|
+
"DROP TABLE IF EXISTS openedx_certificates_externalcertificatecourseconfiguration;",
|
|
17
|
+
"DROP TABLE IF EXISTS openedx_certificates_externalcertificatetype;",
|
|
18
|
+
],
|
|
19
|
+
reverse_sql=migrations.RunSQL.noop,
|
|
20
|
+
),
|
|
21
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Database models for learning_credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import jsonfield
|
|
13
|
+
from django.conf import settings
|
|
14
|
+
from django.contrib.auth import get_user_model
|
|
15
|
+
from django.core.exceptions import ValidationError
|
|
16
|
+
from django.db import models
|
|
17
|
+
from django.db.models.signals import post_delete
|
|
18
|
+
from django.dispatch import receiver
|
|
19
|
+
from django.utils.translation import gettext_lazy as _
|
|
20
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
21
|
+
from edx_ace import Message, Recipient, ace
|
|
22
|
+
from model_utils.models import TimeStampedModel
|
|
23
|
+
from opaque_keys.edx.django.models import LearningContextKeyField
|
|
24
|
+
|
|
25
|
+
from learning_credentials.compat import get_learning_context_name
|
|
26
|
+
from learning_credentials.exceptions import AssetNotFoundError, CredentialGenerationError
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
29
|
+
from django.core.files import File
|
|
30
|
+
from django.db.models import QuerySet
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CredentialType(TimeStampedModel):
|
|
37
|
+
"""
|
|
38
|
+
Model to store global credential configurations for each type.
|
|
39
|
+
|
|
40
|
+
.. no_pii:
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name = models.CharField(max_length=255, unique=True, help_text=_('Name of the credential type.'))
|
|
44
|
+
retrieval_func = models.CharField(max_length=200, help_text=_('A name of the function to retrieve eligible users.'))
|
|
45
|
+
generation_func = models.CharField(max_length=200, help_text=_('A name of the function to generate credentials.'))
|
|
46
|
+
custom_options = jsonfield.JSONField(default=dict, blank=True, help_text=_('Custom options for the functions.'))
|
|
47
|
+
|
|
48
|
+
# TODO: Document how to add custom functions to the credential generation pipeline.
|
|
49
|
+
|
|
50
|
+
def __str__(self):
|
|
51
|
+
"""Get a string representation of this model's instance."""
|
|
52
|
+
return self.name
|
|
53
|
+
|
|
54
|
+
def clean(self):
|
|
55
|
+
"""Ensure that the `retrieval_func` and `generation_func` exist."""
|
|
56
|
+
for func_field in ['retrieval_func', 'generation_func']:
|
|
57
|
+
func_path = getattr(self, func_field)
|
|
58
|
+
try:
|
|
59
|
+
# TODO: Move the function retrieval to a method to avoid code duplication.
|
|
60
|
+
module_path, func_name = func_path.rsplit('.', 1)
|
|
61
|
+
module = import_module(module_path)
|
|
62
|
+
getattr(module, func_name) # Will raise AttributeError if the function does not exist.
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
raise ValidationError({func_field: "Function path must be in format 'module.function_name'."}) from exc
|
|
65
|
+
except (ImportError, AttributeError) as exc:
|
|
66
|
+
raise ValidationError(
|
|
67
|
+
{func_field: f"The function {func_path} could not be found. Please provide a valid path."},
|
|
68
|
+
) from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CredentialConfiguration(TimeStampedModel):
|
|
72
|
+
"""
|
|
73
|
+
Model to store context-specific credential configurations for each credential type.
|
|
74
|
+
|
|
75
|
+
.. no_pii:
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
learning_context_key = LearningContextKeyField(
|
|
79
|
+
max_length=255,
|
|
80
|
+
help_text=_('ID of a learning context (e.g., a course or a Learning Path).'),
|
|
81
|
+
)
|
|
82
|
+
credential_type = models.ForeignKey(
|
|
83
|
+
CredentialType,
|
|
84
|
+
on_delete=models.CASCADE,
|
|
85
|
+
help_text=_('Associated credential type.'),
|
|
86
|
+
)
|
|
87
|
+
periodic_task = models.OneToOneField(
|
|
88
|
+
PeriodicTask,
|
|
89
|
+
on_delete=models.CASCADE,
|
|
90
|
+
help_text=_('Associated periodic task.'),
|
|
91
|
+
)
|
|
92
|
+
custom_options = jsonfield.JSONField(
|
|
93
|
+
default=dict,
|
|
94
|
+
blank=True,
|
|
95
|
+
help_text=_(
|
|
96
|
+
'Custom options for the functions. If specified, they are merged with the options defined in the '
|
|
97
|
+
'credential type.',
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
class Meta: # noqa: D106
|
|
102
|
+
unique_together = (('learning_context_key', 'credential_type'),)
|
|
103
|
+
|
|
104
|
+
def __str__(self): # noqa: D105
|
|
105
|
+
return f'{self.credential_type.name} in {self.learning_context_key}'
|
|
106
|
+
|
|
107
|
+
def save(self, *args, **kwargs):
|
|
108
|
+
"""Create a new PeriodicTask every time a new CredentialConfiguration is created."""
|
|
109
|
+
from learning_credentials.tasks import generate_credentials_for_config_task as task # noqa: PLC0415
|
|
110
|
+
|
|
111
|
+
# Use __wrapped__ to get the original function, as the task is wrapped by the @app.task decorator.
|
|
112
|
+
task_path = f"{task.__wrapped__.__module__}.{task.__wrapped__.__name__}"
|
|
113
|
+
|
|
114
|
+
if self._state.adding:
|
|
115
|
+
schedule, created = IntervalSchedule.objects.get_or_create(every=10, period=IntervalSchedule.DAYS)
|
|
116
|
+
self.periodic_task = PeriodicTask.objects.create(
|
|
117
|
+
enabled=False,
|
|
118
|
+
interval=schedule,
|
|
119
|
+
name=f'{self.credential_type} in {self.learning_context_key}',
|
|
120
|
+
task=task_path,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
super().save(*args, **kwargs)
|
|
124
|
+
|
|
125
|
+
# Update the task on each save to prevent it from getting out of sync (e.g., after changing a task definition).
|
|
126
|
+
self.periodic_task.task = task_path
|
|
127
|
+
# Update the args of the PeriodicTask to include the ID of the CredentialConfiguration.
|
|
128
|
+
self.periodic_task.args = json.dumps([self.id])
|
|
129
|
+
self.periodic_task.save()
|
|
130
|
+
|
|
131
|
+
# Replace the return type with `QuerySet[Self]` after migrating to Python 3.10+.
|
|
132
|
+
@classmethod
|
|
133
|
+
def get_enabled_configurations(cls) -> QuerySet[CredentialConfiguration]:
|
|
134
|
+
"""
|
|
135
|
+
Get the list of enabled configurations.
|
|
136
|
+
|
|
137
|
+
:return: A list of CredentialConfiguration objects.
|
|
138
|
+
"""
|
|
139
|
+
return CredentialConfiguration.objects.filter(periodic_task__enabled=True)
|
|
140
|
+
|
|
141
|
+
def generate_credentials(self):
|
|
142
|
+
"""This method allows manual credential generation from the Django admin."""
|
|
143
|
+
user_ids = self.get_eligible_user_ids()
|
|
144
|
+
log.info("The following users are eligible in %s: %s", self.learning_context_key, user_ids)
|
|
145
|
+
filtered_user_ids = self.filter_out_user_ids_with_credentials(user_ids)
|
|
146
|
+
log.info("The filtered users eligible in %s: %s", self.learning_context_key, filtered_user_ids)
|
|
147
|
+
for user_id in filtered_user_ids:
|
|
148
|
+
self.generate_credential_for_user(user_id)
|
|
149
|
+
|
|
150
|
+
def filter_out_user_ids_with_credentials(self, user_ids: list[int]) -> list[int]:
|
|
151
|
+
"""
|
|
152
|
+
Filter out user IDs that already have a credential for this course and credential type.
|
|
153
|
+
|
|
154
|
+
:param user_ids: A list of user IDs to filter.
|
|
155
|
+
:return: A list of user IDs that either:
|
|
156
|
+
1. Do not have a credential for this course and credential type.
|
|
157
|
+
2. Have such a credential with an error status.
|
|
158
|
+
"""
|
|
159
|
+
users_ids_with_credentials = Credential.objects.filter(
|
|
160
|
+
models.Q(learning_context_key=self.learning_context_key),
|
|
161
|
+
models.Q(credential_type=self.credential_type),
|
|
162
|
+
~(models.Q(status=Credential.Status.ERROR)),
|
|
163
|
+
).values_list('user_id', flat=True)
|
|
164
|
+
|
|
165
|
+
filtered_user_ids_set = set(user_ids) - set(users_ids_with_credentials)
|
|
166
|
+
return list(filtered_user_ids_set)
|
|
167
|
+
|
|
168
|
+
def get_eligible_user_ids(self) -> list[int]:
|
|
169
|
+
"""
|
|
170
|
+
Get the list of eligible learners for the given course.
|
|
171
|
+
|
|
172
|
+
:return: A list of user IDs.
|
|
173
|
+
"""
|
|
174
|
+
func_path = self.credential_type.retrieval_func
|
|
175
|
+
module_path, func_name = func_path.rsplit('.', 1)
|
|
176
|
+
module = import_module(module_path)
|
|
177
|
+
func = getattr(module, func_name)
|
|
178
|
+
|
|
179
|
+
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
180
|
+
return func(self.learning_context_key, custom_options)
|
|
181
|
+
|
|
182
|
+
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
|
|
183
|
+
"""
|
|
184
|
+
Celery task for processing a single user's credential.
|
|
185
|
+
|
|
186
|
+
This function retrieves an CredentialConfiguration object based on context ID and credential type,
|
|
187
|
+
retrieves the data using the retrieval_func specified in the associated CredentialType object,
|
|
188
|
+
and passes this data to the function specified in the generation_func field.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
user_id: The ID of the user to process the credential for.
|
|
192
|
+
celery_task_id (optional): The ID of the Celery task that is running this function.
|
|
193
|
+
"""
|
|
194
|
+
user = get_user_model().objects.get(id=user_id)
|
|
195
|
+
# Use the name from the profile if it is not empty. Otherwise, use the first and last name.
|
|
196
|
+
# We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
|
|
197
|
+
user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
|
|
198
|
+
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
199
|
+
|
|
200
|
+
credential, _ = Credential.objects.update_or_create(
|
|
201
|
+
user_id=user_id,
|
|
202
|
+
learning_context_key=self.learning_context_key,
|
|
203
|
+
credential_type=self.credential_type.name,
|
|
204
|
+
defaults={
|
|
205
|
+
'user_full_name': user_full_name,
|
|
206
|
+
'status': Credential.Status.GENERATING,
|
|
207
|
+
'generation_task_id': celery_task_id,
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
generation_module_name, generation_func_name = self.credential_type.generation_func.rsplit('.', 1)
|
|
213
|
+
generation_module = import_module(generation_module_name)
|
|
214
|
+
generation_func = getattr(generation_module, generation_func_name)
|
|
215
|
+
|
|
216
|
+
# Run the functions. We do not validate them here, as they are validated in the model's clean() method.
|
|
217
|
+
credential.download_url = generation_func(self.learning_context_key, user, credential.uuid, custom_options)
|
|
218
|
+
credential.status = Credential.Status.AVAILABLE
|
|
219
|
+
credential.save()
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
credential.status = Credential.Status.ERROR
|
|
222
|
+
credential.save()
|
|
223
|
+
msg = f'Failed to generate the {credential.uuid=} for {user_id=} with {self.id=}.'
|
|
224
|
+
raise CredentialGenerationError(msg) from exc
|
|
225
|
+
else:
|
|
226
|
+
# TODO: In the future, we want to check this before generating the credential.
|
|
227
|
+
# Perhaps we could even include this in a processor to optimize it.
|
|
228
|
+
if user.is_active and user.has_usable_password():
|
|
229
|
+
credential.send_email()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# noinspection PyUnusedLocal
|
|
233
|
+
@receiver(post_delete, sender=CredentialConfiguration)
|
|
234
|
+
def post_delete_periodic_task(sender, instance, *_args, **_kwargs): # noqa: ANN001, ARG001
|
|
235
|
+
"""Delete the associated periodic task when the object is deleted."""
|
|
236
|
+
if instance.periodic_task:
|
|
237
|
+
instance.periodic_task.delete()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Credential(TimeStampedModel):
|
|
241
|
+
"""
|
|
242
|
+
Model to represent each credential awarded to a user for a course.
|
|
243
|
+
|
|
244
|
+
This model contains information about the related course, the user who earned the credential,
|
|
245
|
+
the download URL for the credential PDF, and the associated credential generation task.
|
|
246
|
+
|
|
247
|
+
.. note:: Credentials are identified by UUIDs rather than integer keys to prevent enumeration attacks
|
|
248
|
+
or privacy leaks.
|
|
249
|
+
|
|
250
|
+
.. pii: The User's name is stored in this model.
|
|
251
|
+
.. pii_types: id, name
|
|
252
|
+
.. pii_retirement: retained
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
class Status(models.TextChoices):
|
|
256
|
+
"""Status of the credential generation task."""
|
|
257
|
+
|
|
258
|
+
GENERATING = 'generating', _('Generating')
|
|
259
|
+
AVAILABLE = 'available', _('Available')
|
|
260
|
+
ERROR = 'error', _('Error')
|
|
261
|
+
INVALIDATED = 'invalidated', _('Invalidated')
|
|
262
|
+
|
|
263
|
+
uuid = models.UUIDField(
|
|
264
|
+
primary_key=True,
|
|
265
|
+
default=uuid.uuid4,
|
|
266
|
+
editable=False,
|
|
267
|
+
help_text=_('Auto-generated UUID of the credential'),
|
|
268
|
+
)
|
|
269
|
+
user_id = models.IntegerField(help_text=_('ID of the user receiving the credential'))
|
|
270
|
+
user_full_name = models.CharField(max_length=255, help_text=_('User receiving the credential'))
|
|
271
|
+
learning_context_key = LearningContextKeyField(
|
|
272
|
+
max_length=255,
|
|
273
|
+
help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
|
|
274
|
+
)
|
|
275
|
+
credential_type = models.CharField(max_length=255, help_text=_('Type of the credential'))
|
|
276
|
+
status = models.CharField(
|
|
277
|
+
max_length=32,
|
|
278
|
+
choices=Status.choices,
|
|
279
|
+
default=Status.GENERATING,
|
|
280
|
+
help_text=_('Status of the credential generation task'),
|
|
281
|
+
)
|
|
282
|
+
download_url = models.URLField(blank=True, help_text=_('URL of the generated credential PDF (e.g., to S3)'))
|
|
283
|
+
legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the credential imported from another system'))
|
|
284
|
+
generation_task_id = models.CharField(max_length=255, help_text=_('Task ID from the Celery queue'))
|
|
285
|
+
|
|
286
|
+
class Meta: # noqa: D106
|
|
287
|
+
unique_together = (('user_id', 'learning_context_key', 'credential_type'),)
|
|
288
|
+
|
|
289
|
+
def __str__(self): # noqa: D105
|
|
290
|
+
return f"{self.credential_type} for {self.user_full_name} in {self.learning_context_key}"
|
|
291
|
+
|
|
292
|
+
def send_email(self):
|
|
293
|
+
"""Send a credential link to the student."""
|
|
294
|
+
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
295
|
+
user = get_user_model().objects.get(id=self.user_id)
|
|
296
|
+
msg = Message(
|
|
297
|
+
name="certificate_generated",
|
|
298
|
+
app_label="learning_credentials",
|
|
299
|
+
recipient=Recipient(lms_user_id=user.id, email_address=user.email),
|
|
300
|
+
language='en',
|
|
301
|
+
context={
|
|
302
|
+
'certificate_link': self.download_url,
|
|
303
|
+
'course_name': learning_context_name,
|
|
304
|
+
'platform_name': settings.PLATFORM_NAME,
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
ace.send(msg)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class CredentialAsset(TimeStampedModel):
|
|
311
|
+
"""
|
|
312
|
+
A set of assets to be used in custom credential templates.
|
|
313
|
+
|
|
314
|
+
This model stores assets used during credential generation process, such as PDF templates, images, fonts.
|
|
315
|
+
|
|
316
|
+
.. no_pii:
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
def template_assets_path(self, filename: str) -> str:
|
|
320
|
+
"""
|
|
321
|
+
Delete the file if it already exists and returns the credential template asset file path.
|
|
322
|
+
|
|
323
|
+
:param filename: File to upload.
|
|
324
|
+
:return path: Path of asset file e.g. `credential_template_assets/1/filename`.
|
|
325
|
+
"""
|
|
326
|
+
name = Path('learning_credentials_template_assets') / str(self.id) / filename
|
|
327
|
+
fullname = Path(settings.MEDIA_ROOT) / name
|
|
328
|
+
if fullname.exists():
|
|
329
|
+
fullname.unlink()
|
|
330
|
+
return str(name)
|
|
331
|
+
|
|
332
|
+
description = models.CharField(
|
|
333
|
+
max_length=255,
|
|
334
|
+
null=False,
|
|
335
|
+
blank=True,
|
|
336
|
+
help_text=_('Description of the asset.'),
|
|
337
|
+
)
|
|
338
|
+
asset = models.FileField(
|
|
339
|
+
max_length=255,
|
|
340
|
+
upload_to=template_assets_path,
|
|
341
|
+
help_text=_('Asset file. It could be a PDF template, image or font file.'),
|
|
342
|
+
)
|
|
343
|
+
asset_slug = models.SlugField(
|
|
344
|
+
max_length=255,
|
|
345
|
+
unique=True,
|
|
346
|
+
null=False,
|
|
347
|
+
help_text=_('Asset\'s unique slug. We can reference the asset in templates using this value.'),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
class Meta: # noqa: D106
|
|
351
|
+
get_latest_by = 'created'
|
|
352
|
+
|
|
353
|
+
def __str__(self): # noqa: D105
|
|
354
|
+
return f'{self.asset.url}'
|
|
355
|
+
|
|
356
|
+
def save(self, *args, **kwargs):
|
|
357
|
+
"""If the object is being created, save the asset first, then save the object."""
|
|
358
|
+
if self._state.adding:
|
|
359
|
+
asset_image = self.asset
|
|
360
|
+
self.asset = None
|
|
361
|
+
super().save(*args, **kwargs)
|
|
362
|
+
self.asset = asset_image
|
|
363
|
+
|
|
364
|
+
super().save(*args, **kwargs)
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def get_asset_by_slug(cls, asset_slug: str) -> File:
|
|
368
|
+
"""
|
|
369
|
+
Fetch a credential template asset by its slug from the database.
|
|
370
|
+
|
|
371
|
+
:param asset_slug: The slug of the asset to be retrieved.
|
|
372
|
+
:returns: The file associated with the asset slug.
|
|
373
|
+
:raises AssetNotFound: If no asset exists with the provided slug in the CredentialAsset database model.
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
template_asset = cls.objects.get(asset_slug=asset_slug)
|
|
377
|
+
asset = template_asset.asset
|
|
378
|
+
except cls.DoesNotExist as exc:
|
|
379
|
+
msg = f'Asset with slug {asset_slug} does not exist.'
|
|
380
|
+
raise AssetNotFoundError(msg) from exc
|
|
381
|
+
return asset
|