learning-credentials 0.2.0rc1__py2.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 +3 -0
- learning_credentials/admin.py +263 -0
- learning_credentials/apps.py +24 -0
- learning_credentials/compat.py +117 -0
- learning_credentials/exceptions.py +9 -0
- learning_credentials/generators.py +224 -0
- learning_credentials/migrations/0001_initial.py +205 -0
- learning_credentials/migrations/0002_migrate_to_learning_credentials.py +32 -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/__init__.py +0 -0
- learning_credentials/models.py +381 -0
- learning_credentials/processors.py +306 -0
- learning_credentials/settings/__init__.py +1 -0
- learning_credentials/settings/common.py +12 -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/head.html +0 -0
- learning_credentials/urls.py +9 -0
- learning_credentials/views.py +1 -0
- learning_credentials-0.2.0rc1.dist-info/METADATA +204 -0
- learning_credentials-0.2.0rc1.dist-info/RECORD +34 -0
- learning_credentials-0.2.0rc1.dist-info/WHEEL +6 -0
- learning_credentials-0.2.0rc1.dist-info/entry_points.txt +3 -0
- learning_credentials-0.2.0rc1.dist-info/licenses/LICENSE.txt +664 -0
- learning_credentials-0.2.0rc1.dist-info/top_level.txt +2 -0
- openedx_certificates/__init__.py +1 -0
- openedx_certificates/apps.py +11 -0
- openedx_certificates/migrations/0001_initial.py +206 -0
- openedx_certificates/migrations/__init__.py +0 -0
- openedx_certificates/models.py +38 -0
|
@@ -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 # Avoid circular imports.
|
|
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
|
+
course_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="credential_generated",
|
|
298
|
+
app_label="learning_credentials",
|
|
299
|
+
recipient=Recipient(lms_user_id=user.id, email_address=user.email),
|
|
300
|
+
language='en',
|
|
301
|
+
context={
|
|
302
|
+
'credential_link': self.download_url,
|
|
303
|
+
'course_name': course_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
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains processors for credential criteria.
|
|
3
|
+
|
|
4
|
+
The functions prefixed with `retrieve_` are automatically detected by the admin page and are used to retrieve the
|
|
5
|
+
IDs of the users that meet the criteria for the credential type.
|
|
6
|
+
|
|
7
|
+
We will move this module to an external repository (a plugin).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from completion_aggregator.api.v1.views import CompletionDetailView
|
|
16
|
+
from django.contrib.auth import get_user_model
|
|
17
|
+
from learning_paths.models import LearningPath
|
|
18
|
+
from rest_framework.request import Request
|
|
19
|
+
from rest_framework.test import APIRequestFactory
|
|
20
|
+
|
|
21
|
+
from learning_credentials.compat import (
|
|
22
|
+
get_course_enrollments,
|
|
23
|
+
get_course_grade,
|
|
24
|
+
get_course_grading_policy,
|
|
25
|
+
prefetch_course_grades,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
|
|
31
|
+
from django.contrib.auth.models import User
|
|
32
|
+
from opaque_keys.edx.keys import CourseKey, LearningContextKey
|
|
33
|
+
from rest_framework.views import APIView
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _process_learning_context(
|
|
40
|
+
learning_context_key: LearningContextKey,
|
|
41
|
+
course_processor: Callable[[CourseKey, dict[str, Any]], list[int]],
|
|
42
|
+
options: dict[str, Any],
|
|
43
|
+
) -> list[int]:
|
|
44
|
+
"""
|
|
45
|
+
Process a learning context (course or learning path) using the given course processor function.
|
|
46
|
+
|
|
47
|
+
For courses, runs the processor directly. For learning paths, runs the processor on each
|
|
48
|
+
course in the path and returns the intersection of eligible users across all courses.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
learning_context_key: A course key or learning path key to process
|
|
52
|
+
course_processor: A function that processes a single course and returns eligible user IDs
|
|
53
|
+
options: Options to pass to the processor
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A list of eligible user IDs
|
|
57
|
+
"""
|
|
58
|
+
if learning_context_key.is_course:
|
|
59
|
+
return course_processor(learning_context_key, options)
|
|
60
|
+
|
|
61
|
+
learning_path = LearningPath.objects.get(key=learning_context_key)
|
|
62
|
+
|
|
63
|
+
results = None
|
|
64
|
+
for course in learning_path.steps.all():
|
|
65
|
+
course_results = set(course_processor(course.course_key, options))
|
|
66
|
+
if results is None:
|
|
67
|
+
results = course_results
|
|
68
|
+
else:
|
|
69
|
+
results &= course_results
|
|
70
|
+
|
|
71
|
+
return list(results) if results else []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_category_weights(course_id: CourseKey) -> dict[str, float]:
|
|
75
|
+
"""
|
|
76
|
+
Retrieve the course grading policy and return the weight of each category.
|
|
77
|
+
|
|
78
|
+
:param course_id: The course ID to get the grading policy for.
|
|
79
|
+
:returns: A dictionary with the weight of each category.
|
|
80
|
+
"""
|
|
81
|
+
log.debug('Getting the course grading policy.')
|
|
82
|
+
grading_policy = get_course_grading_policy(course_id)
|
|
83
|
+
log.debug('Finished getting the course grading policy.')
|
|
84
|
+
|
|
85
|
+
# Calculate the total weight of the non-exam categories
|
|
86
|
+
log.debug(grading_policy)
|
|
87
|
+
|
|
88
|
+
category_weight_ratios = {category['type'].lower(): category['weight'] for category in grading_policy}
|
|
89
|
+
|
|
90
|
+
log.debug(category_weight_ratios)
|
|
91
|
+
return category_weight_ratios
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_grades_by_format(course_id: CourseKey, users: list[User]) -> dict[int, dict[str, int]]:
|
|
95
|
+
"""
|
|
96
|
+
Get the grades for each user, categorized by assignment types.
|
|
97
|
+
|
|
98
|
+
:param course_id: The course ID.
|
|
99
|
+
:param users: The users to get the grades for.
|
|
100
|
+
:returns: A dictionary with the grades for each user, categorized by assignment types.
|
|
101
|
+
"""
|
|
102
|
+
log.debug('Getting the grades for each user.')
|
|
103
|
+
|
|
104
|
+
grades = {}
|
|
105
|
+
|
|
106
|
+
with prefetch_course_grades(course_id, users):
|
|
107
|
+
for user in users:
|
|
108
|
+
grades[user.id] = {}
|
|
109
|
+
course_grade = get_course_grade(user, course_id)
|
|
110
|
+
for assignment_type, subsections in course_grade.graded_subsections_by_format().items():
|
|
111
|
+
assignment_earned = 0
|
|
112
|
+
assignment_possible = 0
|
|
113
|
+
log.debug(subsections)
|
|
114
|
+
for subsection in subsections.values():
|
|
115
|
+
assignment_earned += subsection.graded_total.earned
|
|
116
|
+
assignment_possible += subsection.graded_total.possible
|
|
117
|
+
grade = (assignment_earned / assignment_possible) * 100 if assignment_possible > 0 else 0
|
|
118
|
+
grades[user.id][assignment_type.lower()] = grade
|
|
119
|
+
|
|
120
|
+
log.debug('Finished getting the grades for each user.')
|
|
121
|
+
return grades
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _are_grades_passing_criteria(
|
|
125
|
+
user_grades: dict[str, float],
|
|
126
|
+
required_grades: dict[str, float],
|
|
127
|
+
category_weights: dict[str, float],
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Determine whether the user received passing grades in all required categories.
|
|
131
|
+
|
|
132
|
+
:param user_grades: The grades of the user, divided by category.
|
|
133
|
+
:param required_grades: The required grades for each category.
|
|
134
|
+
:param category_weights: The weight of each category.
|
|
135
|
+
:returns: Whether the user received passing grades in all required categories.
|
|
136
|
+
:raises ValueError: If a category weight is not found.
|
|
137
|
+
"""
|
|
138
|
+
# If user does not have a grade for a category (except for the "total" category), it means that they did not
|
|
139
|
+
# attempt it. Therefore, they should not be eligible for the credential.
|
|
140
|
+
if not all(category in user_grades for category in required_grades if category != 'total'):
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
total_score = 0
|
|
144
|
+
for category, score in user_grades.items():
|
|
145
|
+
if score < required_grades.get(category, 0):
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
if category not in category_weights:
|
|
149
|
+
msg = "Category weight '%s' was not found in the course grading policy."
|
|
150
|
+
raise ValueError(msg, category)
|
|
151
|
+
total_score += score * category_weights[category]
|
|
152
|
+
|
|
153
|
+
return total_score >= required_grades.get('total', 0)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
|
|
157
|
+
"""Implementation for retrieving course grades."""
|
|
158
|
+
required_grades: dict[str, int] = options['required_grades']
|
|
159
|
+
required_grades = {key.lower(): value * 100 for key, value in required_grades.items()}
|
|
160
|
+
|
|
161
|
+
users = get_course_enrollments(course_id)
|
|
162
|
+
grades = _get_grades_by_format(course_id, users)
|
|
163
|
+
log.debug(grades)
|
|
164
|
+
weights = _get_category_weights(course_id)
|
|
165
|
+
|
|
166
|
+
eligible_users = []
|
|
167
|
+
for user_id, user_grades in grades.items():
|
|
168
|
+
if _are_grades_passing_criteria(user_grades, required_grades, weights):
|
|
169
|
+
eligible_users.append(user_id)
|
|
170
|
+
|
|
171
|
+
return eligible_users
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def retrieve_subsection_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
|
|
175
|
+
"""
|
|
176
|
+
Retrieve the users that have passing grades in all required categories.
|
|
177
|
+
|
|
178
|
+
:param learning_context_key: The learning context key (course or learning path).
|
|
179
|
+
:param options: The custom options for the credential.
|
|
180
|
+
:returns: The IDs of the users that have passing grades in all required categories.
|
|
181
|
+
|
|
182
|
+
Options:
|
|
183
|
+
- required_grades: A dictionary of required grades for each category, where the keys are the category names and
|
|
184
|
+
the values are the minimum required grades. The grades are percentages, so they should be in the range [0, 1].
|
|
185
|
+
See the following example::
|
|
186
|
+
|
|
187
|
+
{
|
|
188
|
+
"required_grades": {
|
|
189
|
+
"Homework": 0.4,
|
|
190
|
+
"Exam": 0.9,
|
|
191
|
+
"Total": 0.8
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
It means that the user must receive at least 40% in the Homework category and 90% in the Exam category.
|
|
196
|
+
The "Total" key is a special value used to specify the minimum required grade for all categories in the course.
|
|
197
|
+
Let's assume that we have the following grading policy (the percentages are the weights of each category):
|
|
198
|
+
1. Homework: 20%
|
|
199
|
+
2. Lab: 10%
|
|
200
|
+
3. Exam: 70%
|
|
201
|
+
The grades for the Total category will be calculated as follows:
|
|
202
|
+
total_grade = (homework_grade * 0.2) + (lab_grade * 0.1) + (exam_grade * 0.7)
|
|
203
|
+
"""
|
|
204
|
+
return _process_learning_context(learning_context_key, _retrieve_course_subsection_grades, options)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params: dict, url: str) -> APIView:
|
|
208
|
+
"""
|
|
209
|
+
Prepare a request to the Completion Aggregator API.
|
|
210
|
+
|
|
211
|
+
:param course_id: The course ID.
|
|
212
|
+
:param query_params: The query parameters to use in the request.
|
|
213
|
+
:param url: The URL to use in the request.
|
|
214
|
+
:returns: The view with the prepared request.
|
|
215
|
+
"""
|
|
216
|
+
log.debug('Preparing the request for retrieving the completion.')
|
|
217
|
+
|
|
218
|
+
# The URL does not matter, as we do not retrieve any data from the path.
|
|
219
|
+
django_request = APIRequestFactory().get(url, query_params)
|
|
220
|
+
django_request.course_id = course_id
|
|
221
|
+
drf_request = Request(django_request) # convert django.core.handlers.wsgi.WSGIRequest to DRF request
|
|
222
|
+
|
|
223
|
+
view = CompletionDetailView()
|
|
224
|
+
view.request = drf_request
|
|
225
|
+
|
|
226
|
+
# HACK: Bypass the API permissions.
|
|
227
|
+
staff_user = get_user_model().objects.filter(is_staff=True).first()
|
|
228
|
+
view._effective_user = staff_user # noqa: SLF001
|
|
229
|
+
|
|
230
|
+
log.debug('Finished preparing the request for retrieving the completion.')
|
|
231
|
+
return view
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
|
|
235
|
+
"""Implementation for retrieving course completions."""
|
|
236
|
+
# If it turns out to be too slow, we can:
|
|
237
|
+
# 1. Modify the Completion Aggregator to emit a signal/event when a user achieves a certain completion threshold.
|
|
238
|
+
# 2. Get this data from the `Aggregator` model. Filter by `aggregation name == 'course'`, `course_key`, `percent`.
|
|
239
|
+
|
|
240
|
+
required_completion = options.get('required_completion', 0.9)
|
|
241
|
+
|
|
242
|
+
url = f'/completion-aggregator/v1/course/{course_id}/'
|
|
243
|
+
query_params = {'page_size': 1000, 'page': 1}
|
|
244
|
+
|
|
245
|
+
# TODO: Extract the logic of this view into an API. The current approach is very hacky.
|
|
246
|
+
view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url)
|
|
247
|
+
completions = []
|
|
248
|
+
|
|
249
|
+
while True:
|
|
250
|
+
# noinspection PyUnresolvedReferences
|
|
251
|
+
response = view.get(view.request, str(course_id))
|
|
252
|
+
log.debug(response.data)
|
|
253
|
+
completions.extend(
|
|
254
|
+
res['username'] for res in response.data['results'] if res['completion']['percent'] >= required_completion
|
|
255
|
+
)
|
|
256
|
+
if not response.data['pagination']['next']:
|
|
257
|
+
break
|
|
258
|
+
query_params['page'] += 1
|
|
259
|
+
view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url)
|
|
260
|
+
|
|
261
|
+
return list(get_user_model().objects.filter(username__in=completions).values_list('id', flat=True))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def retrieve_completions(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
|
|
265
|
+
"""
|
|
266
|
+
Retrieve the course completions for all users through the Completion Aggregator API.
|
|
267
|
+
|
|
268
|
+
:param learning_context_key: The learning context key (course or learning path).
|
|
269
|
+
:param options: The custom options for the credential.
|
|
270
|
+
:returns: The IDs of the users that have achieved the required completion percentage.
|
|
271
|
+
|
|
272
|
+
Options:
|
|
273
|
+
- required_completion: The minimum required completion percentage. The default value is 0.9.
|
|
274
|
+
"""
|
|
275
|
+
return _process_learning_context(learning_context_key, _retrieve_course_completions, options)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def retrieve_completions_and_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
|
|
279
|
+
"""
|
|
280
|
+
Retrieve the users that meet both completion and grade criteria.
|
|
281
|
+
|
|
282
|
+
This processor combines the functionality of retrieve_course_completions and retrieve_subsection_grades.
|
|
283
|
+
To be eligible, learners must satisfy both sets of criteria.
|
|
284
|
+
|
|
285
|
+
:param learning_context_key: The learning context key (course or learning path).
|
|
286
|
+
:param options: The custom options for the credential.
|
|
287
|
+
:returns: The IDs of the users that meet both sets of criteria.
|
|
288
|
+
|
|
289
|
+
Options:
|
|
290
|
+
- required_completion: The minimum required completion percentage (default: 0.9)
|
|
291
|
+
- required_grades: A dictionary of required grades for each category, where the keys are the category names and
|
|
292
|
+
the values are the minimum required grades. The grades are percentages in the range [0, 1].
|
|
293
|
+
Example::
|
|
294
|
+
|
|
295
|
+
{
|
|
296
|
+
"required_grades": {
|
|
297
|
+
"Homework": 0.4,
|
|
298
|
+
"Exam": 0.9,
|
|
299
|
+
"Total": 0.8
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
"""
|
|
303
|
+
completion_eligible_users = set(retrieve_completions(learning_context_key, options))
|
|
304
|
+
grades_eligible_users = set(retrieve_subsection_grades(learning_context_key, options))
|
|
305
|
+
|
|
306
|
+
return list(completion_eligible_users & grades_eligible_users)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""App-specific settings."""
|