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.
Files changed (34) hide show
  1. learning_credentials/__init__.py +1 -0
  2. learning_credentials/admin.py +265 -0
  3. learning_credentials/apps.py +24 -0
  4. learning_credentials/compat.py +119 -0
  5. learning_credentials/conf/locale/config.yaml +85 -0
  6. learning_credentials/exceptions.py +9 -0
  7. learning_credentials/generators.py +225 -0
  8. learning_credentials/migrations/0001_initial.py +205 -0
  9. learning_credentials/migrations/0002_migrate_to_learning_credentials.py +40 -0
  10. learning_credentials/migrations/0003_rename_certificates_to_credentials.py +128 -0
  11. learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +59 -0
  12. learning_credentials/migrations/0005_rename_processors_and_generators.py +106 -0
  13. learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +21 -0
  14. learning_credentials/migrations/__init__.py +0 -0
  15. learning_credentials/models.py +381 -0
  16. learning_credentials/processors.py +378 -0
  17. learning_credentials/settings/__init__.py +1 -0
  18. learning_credentials/settings/common.py +9 -0
  19. learning_credentials/settings/production.py +13 -0
  20. learning_credentials/tasks.py +53 -0
  21. learning_credentials/templates/learning_credentials/base.html +22 -0
  22. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +22 -0
  23. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +13 -0
  24. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +1 -0
  25. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
  26. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +4 -0
  27. learning_credentials/urls.py +9 -0
  28. learning_credentials/views.py +1 -0
  29. learning_credentials-0.2.2rc2.dist-info/METADATA +212 -0
  30. learning_credentials-0.2.2rc2.dist-info/RECORD +34 -0
  31. learning_credentials-0.2.2rc2.dist-info/WHEEL +5 -0
  32. learning_credentials-0.2.2rc2.dist-info/entry_points.txt +2 -0
  33. learning_credentials-0.2.2rc2.dist-info/licenses/LICENSE.txt +664 -0
  34. 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