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,378 @@
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 with step-specific options (if available), and returns the intersection
49
+ of eligible users across all courses.
50
+
51
+ Args:
52
+ learning_context_key: A course key or learning path key to process
53
+ course_processor: A function that processes a single course and returns eligible user IDs
54
+ options: Options to pass to the processor. For learning paths, may contain a "steps" key
55
+ with step-specific options in the format: {"steps": {"<course_key>": {...}}}
56
+
57
+ Returns:
58
+ A list of eligible user IDs
59
+ """
60
+ if learning_context_key.is_course:
61
+ return course_processor(learning_context_key, options)
62
+
63
+ learning_path = LearningPath.objects.get(key=learning_context_key)
64
+
65
+ results = None
66
+ for course in learning_path.steps.all():
67
+ course_options = options.get("steps", {}).get(str(course.course_key), options)
68
+ course_results = set(course_processor(course.course_key, course_options))
69
+
70
+ if results is None:
71
+ results = course_results
72
+ else:
73
+ results &= course_results
74
+
75
+ # Filter out users who are not enrolled in the Learning Path.
76
+ results &= set(
77
+ learning_path.enrolled_users.filter(learningpathenrollment__is_active=True).values_list('id', flat=True),
78
+ )
79
+
80
+ return list(results) if results else []
81
+
82
+
83
+ def _get_category_weights(course_id: CourseKey) -> dict[str, float]:
84
+ """
85
+ Retrieve the course grading policy and return the weight of each category.
86
+
87
+ :param course_id: The course ID to get the grading policy for.
88
+ :returns: A dictionary with the weight of each category.
89
+ """
90
+ log.debug('Getting the course grading policy.')
91
+ grading_policy = get_course_grading_policy(course_id)
92
+ log.debug('Finished getting the course grading policy.')
93
+
94
+ # Calculate the total weight of the non-exam categories
95
+ log.debug(grading_policy)
96
+
97
+ category_weight_ratios = {category['type'].lower(): category['weight'] for category in grading_policy}
98
+
99
+ log.debug(category_weight_ratios)
100
+ return category_weight_ratios
101
+
102
+
103
+ def _get_grades_by_format(course_id: CourseKey, users: list[User]) -> dict[int, dict[str, int]]:
104
+ """
105
+ Get the grades for each user, categorized by assignment types.
106
+
107
+ :param course_id: The course ID.
108
+ :param users: The users to get the grades for.
109
+ :returns: A dictionary with the grades for each user, categorized by assignment types.
110
+ """
111
+ log.debug('Getting the grades for each user.')
112
+
113
+ grades = {}
114
+
115
+ with prefetch_course_grades(course_id, users):
116
+ for user in users:
117
+ grades[user.id] = {}
118
+ course_grade = get_course_grade(user, course_id)
119
+ for assignment_type, subsections in course_grade.graded_subsections_by_format().items():
120
+ assignment_earned = 0
121
+ assignment_possible = 0
122
+ log.debug(subsections)
123
+ for subsection in subsections.values():
124
+ assignment_earned += subsection.graded_total.earned
125
+ assignment_possible += subsection.graded_total.possible
126
+ grade = (assignment_earned / assignment_possible) * 100 if assignment_possible > 0 else 0
127
+ grades[user.id][assignment_type.lower()] = grade
128
+
129
+ log.debug('Finished getting the grades for each user.')
130
+ return grades
131
+
132
+
133
+ def _are_grades_passing_criteria(
134
+ user_grades: dict[str, float],
135
+ required_grades: dict[str, float],
136
+ category_weights: dict[str, float],
137
+ ) -> bool:
138
+ """
139
+ Determine whether the user received passing grades in all required categories.
140
+
141
+ :param user_grades: The grades of the user, divided by category.
142
+ :param required_grades: The required grades for each category.
143
+ :param category_weights: The weight of each category.
144
+ :returns: Whether the user received passing grades in all required categories.
145
+ :raises ValueError: If a category weight is not found.
146
+ """
147
+ # If user does not have a grade for a category (except for the "total" category), it means that they did not
148
+ # attempt it. Therefore, they should not be eligible for the credential.
149
+ if not all(category in user_grades for category in required_grades if category != 'total'):
150
+ return False
151
+
152
+ total_score = 0
153
+ for category, score in user_grades.items():
154
+ if score < required_grades.get(category, 0):
155
+ return False
156
+
157
+ if category not in category_weights:
158
+ msg = "Category weight '%s' was not found in the course grading policy."
159
+ raise ValueError(msg, category)
160
+ total_score += score * category_weights[category]
161
+
162
+ return total_score >= required_grades.get('total', 0)
163
+
164
+
165
+ def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
166
+ """Implementation for retrieving course grades."""
167
+ required_grades: dict[str, int] = options['required_grades']
168
+ required_grades = {key.lower(): value * 100 for key, value in required_grades.items()}
169
+
170
+ users = get_course_enrollments(course_id)
171
+ grades = _get_grades_by_format(course_id, users)
172
+ log.debug(grades)
173
+ weights = _get_category_weights(course_id)
174
+
175
+ eligible_users = []
176
+ for user_id, user_grades in grades.items():
177
+ if _are_grades_passing_criteria(user_grades, required_grades, weights):
178
+ eligible_users.append(user_id)
179
+
180
+ return eligible_users
181
+
182
+
183
+ def retrieve_subsection_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
184
+ """
185
+ Retrieve the users that have passing grades in all required categories.
186
+
187
+ :param learning_context_key: The learning context key (course or learning path).
188
+ :param options: The custom options for the credential.
189
+ :returns: The IDs of the users that have passing grades in all required categories.
190
+
191
+ Options:
192
+ - required_grades: A dictionary of required grades for each category, where the keys are the category names and
193
+ the values are the minimum required grades. The grades are percentages, so they should be in the range [0, 1].
194
+ See the following example::
195
+
196
+ {
197
+ "required_grades": {
198
+ "Homework": 0.4,
199
+ "Exam": 0.9,
200
+ "Total": 0.8
201
+ }
202
+ }
203
+
204
+ It means that the user must receive at least 40% in the Homework category and 90% in the Exam category.
205
+ The "Total" key is a special value used to specify the minimum required grade for all categories in the course.
206
+ Let's assume that we have the following grading policy (the percentages are the weights of each category):
207
+ 1. Homework: 20%
208
+ 2. Lab: 10%
209
+ 3. Exam: 70%
210
+ The grades for the Total category will be calculated as follows:
211
+ total_grade = (homework_grade * 0.2) + (lab_grade * 0.1) + (exam_grade * 0.7)
212
+ - steps: For learning paths only. A dictionary with step-specific options in the format
213
+ {"&lt;course_key&gt;": {...}}. If provided, each course in the learning path will use its specific
214
+ options instead of the global options. Example::
215
+
216
+ {
217
+ "required_grades": {
218
+ "Total": 0.8
219
+ },
220
+ "steps": {
221
+ "course-v1:edX+DemoX+Demo_Course": {
222
+ "required_grades": {
223
+ "Total": 0.9
224
+ }
225
+ },
226
+ "course-v1:edX+CS101+2023": {
227
+ "required_grades": {
228
+ "Homework": 0.5,
229
+ "Total": 0.7
230
+ }
231
+ }
232
+ }
233
+ }
234
+ """
235
+ return _process_learning_context(learning_context_key, _retrieve_course_subsection_grades, options)
236
+
237
+
238
+ def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params: dict, url: str) -> APIView:
239
+ """
240
+ Prepare a request to the Completion Aggregator API.
241
+
242
+ :param course_id: The course ID.
243
+ :param query_params: The query parameters to use in the request.
244
+ :param url: The URL to use in the request.
245
+ :returns: The view with the prepared request.
246
+ """
247
+ log.debug('Preparing the request for retrieving the completion.')
248
+
249
+ # The URL does not matter, as we do not retrieve any data from the path.
250
+ django_request = APIRequestFactory().get(url, query_params)
251
+ django_request.course_id = course_id
252
+ drf_request = Request(django_request) # convert django.core.handlers.wsgi.WSGIRequest to DRF request
253
+
254
+ view = CompletionDetailView()
255
+ view.request = drf_request
256
+
257
+ # HACK: Bypass the API permissions.
258
+ staff_user = get_user_model().objects.filter(is_staff=True).first()
259
+ view._effective_user = staff_user # noqa: SLF001
260
+
261
+ log.debug('Finished preparing the request for retrieving the completion.')
262
+ return view
263
+
264
+
265
+ def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
266
+ """Implementation for retrieving course completions."""
267
+ # If it turns out to be too slow, we can:
268
+ # 1. Modify the Completion Aggregator to emit a signal/event when a user achieves a certain completion threshold.
269
+ # 2. Get this data from the `Aggregator` model. Filter by `aggregation name == 'course'`, `course_key`, `percent`.
270
+
271
+ required_completion = options.get('required_completion', 0.9)
272
+
273
+ url = f'/completion-aggregator/v1/course/{course_id}/'
274
+ # The API supports up to 10k results per page, but we limit it to 1k to avoid performance issues.
275
+ query_params = {'page_size': 1000, 'page': 1}
276
+
277
+ # TODO: Extract the logic of this view into an API. The current approach is very hacky.
278
+ view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url)
279
+ completions = []
280
+
281
+ while True:
282
+ # noinspection PyUnresolvedReferences
283
+ response = view.get(view.request, str(course_id))
284
+ log.debug(response.data)
285
+ completions.extend(
286
+ res['username'] for res in response.data['results'] if res['completion']['percent'] >= required_completion
287
+ )
288
+ if not response.data['pagination']['next']:
289
+ break
290
+ query_params['page'] += 1
291
+ view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url)
292
+
293
+ return list(get_user_model().objects.filter(username__in=completions).values_list('id', flat=True))
294
+
295
+
296
+ def retrieve_completions(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
297
+ """
298
+ Retrieve the course completions for all users through the Completion Aggregator API.
299
+
300
+ :param learning_context_key: The learning context key (course or learning path).
301
+ :param options: The custom options for the credential.
302
+ :returns: The IDs of the users that have achieved the required completion percentage.
303
+
304
+ Options:
305
+ - required_completion: The minimum required completion percentage. The default value is 0.9.
306
+ - steps: For learning paths only. A dictionary with step-specific options in the format
307
+ {"&lt;course_key&gt;": {...}}. If provided, each course in the learning path will use its specific
308
+ options instead of the global options. Example::
309
+
310
+ {
311
+ "required_completion": 0.8,
312
+ "steps": {
313
+ "course-v1:edX+DemoX+Demo_Course": {
314
+ "required_completion": 0.9
315
+ },
316
+ "course-v1:edX+CS101+2023": {
317
+ "required_completion": 0.7
318
+ }
319
+ }
320
+ }
321
+ """
322
+ return _process_learning_context(learning_context_key, _retrieve_course_completions, options)
323
+
324
+
325
+ def retrieve_completions_and_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
326
+ """
327
+ Retrieve the users that meet both completion and grade criteria.
328
+
329
+ This processor combines the functionality of retrieve_course_completions and retrieve_subsection_grades.
330
+ To be eligible, learners must satisfy both sets of criteria.
331
+
332
+ :param learning_context_key: The learning context key (course or learning path).
333
+ :param options: The custom options for the credential.
334
+ :returns: The IDs of the users that meet both sets of criteria.
335
+
336
+ Options:
337
+ - required_completion: The minimum required completion percentage (default: 0.9)
338
+ - required_grades: A dictionary of required grades for each category, where the keys are the category names and
339
+ the values are the minimum required grades. The grades are percentages in the range [0, 1]. Example::
340
+
341
+ {
342
+ "required_grades": {
343
+ "Homework": 0.4,
344
+ "Exam": 0.9,
345
+ "Total": 0.8
346
+ }
347
+ }
348
+
349
+ - steps: For learning paths only. A dictionary with step-specific options in the format
350
+ {"&lt;course_key&gt;": {...}}. If provided, each course in the learning path will use its specific
351
+ options instead of the global options. Example::
352
+
353
+ {
354
+ "required_completion": 0.8,
355
+ "required_grades": {
356
+ "Total": 0.7
357
+ },
358
+ "steps": {
359
+ "course-v1:edX+DemoX+Demo_Course": {
360
+ "required_completion": 0.9,
361
+ "required_grades": {
362
+ "Total": 0.8
363
+ }
364
+ },
365
+ "course-v1:edX+CS101+2023": {
366
+ "required_completion": 0.7,
367
+ "required_grades": {
368
+ "Homework": 0.5,
369
+ "Total": 0.6
370
+ }
371
+ }
372
+ }
373
+ }
374
+ """
375
+ completion_eligible_users = set(retrieve_completions(learning_context_key, options))
376
+ grades_eligible_users = set(retrieve_subsection_grades(learning_context_key, options))
377
+
378
+ return list(completion_eligible_users & grades_eligible_users)
@@ -0,0 +1 @@
1
+ """App-specific settings."""
@@ -0,0 +1,9 @@
1
+ """App-specific settings for all environments."""
2
+
3
+ from django.conf import Settings
4
+
5
+
6
+ def plugin_settings(settings: Settings):
7
+ """Add `django_celery_beat` to `INSTALLED_APPS`."""
8
+ if 'django_celery_beat' not in settings.INSTALLED_APPS:
9
+ settings.INSTALLED_APPS += ('django_celery_beat',)
@@ -0,0 +1,13 @@
1
+ """App-specific settings for production environments."""
2
+
3
+ from django.conf import Settings
4
+
5
+
6
+ def plugin_settings(settings: Settings):
7
+ """
8
+ Use the database scheduler for Celery Beat.
9
+
10
+ The default scheduler is celery.beat.PersistentScheduler, which stores the schedule in a local file. It does not
11
+ work in a multi-server environment, so we use the database scheduler instead.
12
+ """
13
+ settings.CELERYBEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
@@ -0,0 +1,53 @@
1
+ """Asynchronous Celery tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from learning_credentials.compat import get_celery_app
8
+ from learning_credentials.models import CredentialConfiguration
9
+
10
+ app = get_celery_app()
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ @app.task
15
+ def generate_credential_for_user_task(config_id: int, user_id: int):
16
+ """
17
+ Celery task for processing a single user's credential.
18
+
19
+ :param config_id: The ID of the CredentialConfiguration object to process.
20
+ :param user_id: The ID of the user to process the credential for.
21
+ """
22
+ config = CredentialConfiguration.objects.get(id=config_id)
23
+ config.generate_credential_for_user(user_id, generate_credential_for_user_task.request.id)
24
+
25
+
26
+ @app.task
27
+ def generate_credentials_for_config_task(config_id: int):
28
+ """
29
+ Celery task for processing a single context's credentials.
30
+
31
+ :param config_id: The ID of the CredentialConfiguration object to process.
32
+ """
33
+ config = CredentialConfiguration.objects.get(id=config_id)
34
+ user_ids = config.get_eligible_user_ids()
35
+ log.info("The following users are eligible in %s: %s", config.learning_context_key, user_ids)
36
+ filtered_user_ids = config.filter_out_user_ids_with_credentials(user_ids)
37
+ log.info("The filtered users eligible in %s: %s", config.learning_context_key, filtered_user_ids)
38
+
39
+ for user_id in filtered_user_ids:
40
+ generate_credential_for_user_task.delay(config_id, user_id)
41
+
42
+
43
+ @app.task
44
+ def generate_all_credentials_task():
45
+ """
46
+ Celery task for initiating the processing of credentials for all enabled contexts.
47
+
48
+ This function fetches all enabled CredentialConfiguration objects,
49
+ and initiates a separate Celery task for each of them.
50
+ """
51
+ config_ids = CredentialConfiguration.get_enabled_configurations().values_list('id', flat=True)
52
+ for config_id in config_ids:
53
+ generate_credentials_for_config_task.delay(config_id)
@@ -0,0 +1,22 @@
1
+
2
+ {% comment %}
3
+ As the developer of this package, don't place anything here if you can help it
4
+ since this allows developers to have interoperability between your template
5
+ structure and their own.
6
+
7
+ Example: Developer melding the 2SoD pattern to fit inside with another pattern::
8
+
9
+ {% extends "base.html" %}
10
+ {% load static %}
11
+
12
+ <!-- Their site uses old school block layout -->
13
+ {% block extra_js %}
14
+
15
+ <!-- Your package using 2SoD block layout -->
16
+ {% block javascript %}
17
+ <script src="{% static 'js/ninja.js' %}" type="text/javascript"></script>
18
+ {% endblock javascript %}
19
+
20
+ {% endblock extra_js %}
21
+ {% endcomment %}
22
+
@@ -0,0 +1,22 @@
1
+ {% load i18n %}{% autoescape off %}
2
+
3
+ <p>
4
+ {% blocktrans %}Thank you for your participation in {{ course_name }} at {{ platform_name }}!{% endblocktrans %}
5
+ </p>
6
+
7
+ <p>
8
+ {% blocktrans %}We are happy to inform you that you have earned a certificate. You should feel very proud of the work you have done in this course. We congratulate you on your efforts and your learning.{% endblocktrans %}
9
+ </p>
10
+
11
+ <p>
12
+ {% trans "To view and download your certificate, please click on the following link:" %}
13
+ </p>
14
+ <p><a href="{{ certificate_link }}">View and download your certificate</a></p>
15
+
16
+ <div>
17
+ <div>
18
+ {% blocktrans %}Thank you for choosing {{ platform_name }} for your learning journey. We look forward to seeing you in more courses in the future.{% endblocktrans %}
19
+ </div>
20
+ </div>
21
+
22
+ {% endautoescape %}
@@ -0,0 +1,13 @@
1
+ {% load i18n %}{% autoescape off %}
2
+
3
+ {% blocktrans %}Thank you for your participation in {{ course_name }} at {{ platform_name }}!{% endblocktrans %}
4
+
5
+ {% blocktrans %}We are happy to inform you that you have earned a certificate. You should feel very proud of the work you have done in this course. We congratulate you on your efforts and your learning.{% endblocktrans %}
6
+
7
+ {% trans "To view and download your certificate, please click on the following link:" %}
8
+
9
+ {{ certificate_link }}
10
+
11
+ {% blocktrans %}Thank you for choosing {{ platform_name }} for your learning journey. We look forward to seeing you in more courses in the future.{% endblocktrans %}
12
+
13
+ {% endautoescape %}
@@ -0,0 +1,4 @@
1
+ {% load i18n %}
2
+ {% autoescape off %}
3
+ {% blocktrans trimmed %}{{ course_name }} - Certificate{% endblocktrans %}
4
+ {% endautoescape %}
@@ -0,0 +1,9 @@
1
+ """URLs for learning_credentials."""
2
+
3
+ # from django.urls import re_path # noqa: ERA001, RUF100
4
+ # from django.views.generic import TemplateView # noqa: ERA001, RUF100
5
+
6
+ urlpatterns = [ # pragma: no cover
7
+ # TODO: Fill in URL patterns and views here.
8
+ # re_path(r'', TemplateView.as_view(template_name="learning_credentials/base.html")), # noqa: ERA001, RUF100
9
+ ]
@@ -0,0 +1 @@
1
+ """TODO."""