learning-credentials 0.3.0rc4__py3-none-any.whl → 0.3.1rc1__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/api/urls.py +3 -6
- learning_credentials/api/v1/permissions.py +42 -24
- learning_credentials/api/v1/urls.py +2 -12
- learning_credentials/api/v1/views.py +19 -367
- learning_credentials/apps.py +7 -10
- learning_credentials/compat.py +7 -10
- learning_credentials/generators.py +66 -31
- learning_credentials/models.py +11 -45
- learning_credentials/processors.py +40 -148
- learning_credentials/settings/common.py +5 -2
- learning_credentials/settings/production.py +5 -2
- learning_credentials/urls.py +3 -1
- {learning_credentials-0.3.0rc4.dist-info → learning_credentials-0.3.1rc1.dist-info}/METADATA +36 -3
- {learning_credentials-0.3.0rc4.dist-info → learning_credentials-0.3.1rc1.dist-info}/RECORD +18 -24
- {learning_credentials-0.3.0rc4.dist-info → learning_credentials-0.3.1rc1.dist-info}/entry_points.txt +0 -3
- learning_credentials/api/v1/serializers.py +0 -74
- learning_credentials/core_api.py +0 -77
- learning_credentials/public/css/credentials_xblock.css +0 -7
- learning_credentials/public/html/credentials_xblock.html +0 -48
- learning_credentials/public/js/credentials_xblock.js +0 -23
- learning_credentials/xblocks.py +0 -85
- {learning_credentials-0.3.0rc4.dist-info → learning_credentials-0.3.1rc1.dist-info}/WHEEL +0 -0
- {learning_credentials-0.3.0rc4.dist-info → learning_credentials-0.3.1rc1.dist-info}/licenses/LICENSE.txt +0 -0
- {learning_credentials-0.3.0rc4.dist-info → learning_credentials-0.3.1rc1.dist-info}/top_level.txt +0 -0
|
@@ -19,9 +19,9 @@ from django.core.files.base import ContentFile
|
|
|
19
19
|
from django.core.files.storage import FileSystemStorage, default_storage
|
|
20
20
|
from pypdf import PdfReader, PdfWriter
|
|
21
21
|
from pypdf.constants import UserAccessPermissions
|
|
22
|
-
from reportlab.pdfbase import
|
|
23
|
-
from reportlab.pdfbase.ttfonts import TTFont
|
|
24
|
-
from reportlab.pdfgen import
|
|
22
|
+
from reportlab.pdfbase.pdfmetrics import FontError, FontNotFoundError, registerFont
|
|
23
|
+
from reportlab.pdfbase.ttfonts import TTFError, TTFont
|
|
24
|
+
from reportlab.pdfgen.canvas import Canvas
|
|
25
25
|
|
|
26
26
|
from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
|
|
27
27
|
from .models import CredentialAsset
|
|
@@ -33,6 +33,7 @@ if TYPE_CHECKING: # pragma: no cover
|
|
|
33
33
|
|
|
34
34
|
from django.contrib.auth.models import User
|
|
35
35
|
from opaque_keys.edx.keys import CourseKey
|
|
36
|
+
from pypdf import PageObject
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
def _get_user_name(user: User) -> str:
|
|
@@ -45,25 +46,29 @@ def _get_user_name(user: User) -> str:
|
|
|
45
46
|
return user.profile.name or f"{user.first_name} {user.last_name}"
|
|
46
47
|
|
|
47
48
|
|
|
48
|
-
def _register_font(
|
|
49
|
+
def _register_font(font_name: Any) -> str | None: # noqa: ANN401
|
|
49
50
|
"""
|
|
50
51
|
Register a custom font if specified in options. If not specified, use the default font (Helvetica).
|
|
51
52
|
|
|
52
|
-
:param
|
|
53
|
-
:returns: The font name.
|
|
53
|
+
:param font_name: The name of the font to register.
|
|
54
|
+
:returns: The font name if registered successfully, otherwise None.
|
|
54
55
|
"""
|
|
55
|
-
if
|
|
56
|
-
|
|
56
|
+
if not font_name:
|
|
57
|
+
return None
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
try:
|
|
60
|
+
registerFont(TTFont(font_name, CredentialAsset.get_asset_by_slug(font_name)))
|
|
61
|
+
except (FontError, FontNotFoundError, TTFError):
|
|
62
|
+
log.exception("Error registering font %s", font_name)
|
|
63
|
+
else:
|
|
64
|
+
return font_name
|
|
59
65
|
|
|
60
66
|
|
|
61
|
-
def _write_text_on_template(template:
|
|
67
|
+
def _write_text_on_template(template: PageObject, username: str, context_name: str, options: dict[str, Any]) -> Canvas:
|
|
62
68
|
"""
|
|
63
69
|
Prepare a new canvas and write the user and course name onto it.
|
|
64
70
|
|
|
65
71
|
:param template: Pdf template.
|
|
66
|
-
:param font: Font name.
|
|
67
72
|
:param username: The name of the user to generate the credential for.
|
|
68
73
|
:param context_name: The name of the learning context.
|
|
69
74
|
:param options: A dictionary documented in the `generate_pdf_credential` function.
|
|
@@ -86,20 +91,26 @@ def _write_text_on_template(template: any, font: str, username: str, context_nam
|
|
|
86
91
|
return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
|
|
87
92
|
|
|
88
93
|
template_width, template_height = template.mediabox[2:]
|
|
89
|
-
pdf_canvas =
|
|
94
|
+
pdf_canvas = Canvas(io.BytesIO(), pagesize=(template_width, template_height))
|
|
95
|
+
font = _register_font(options.get('font')) or 'Helvetica'
|
|
90
96
|
|
|
91
97
|
# Write the learner name.
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
if options.get('name_uppercase', getattr(settings, 'LEARNING_CREDENTIALS_NAME_UPPERCASE', False)):
|
|
99
|
+
username = username.upper()
|
|
100
|
+
|
|
101
|
+
name_font = _register_font(options.get('name_font')) or font
|
|
102
|
+
pdf_canvas.setFont(name_font, options.get('name_size', 32))
|
|
94
103
|
name_color = options.get('name_color', '#000')
|
|
95
104
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
|
|
96
105
|
|
|
97
106
|
name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
|
|
98
107
|
name_y = options.get('name_y', 290)
|
|
108
|
+
|
|
99
109
|
pdf_canvas.drawString(name_x, name_y, username)
|
|
100
110
|
|
|
101
111
|
# Write the learning context name.
|
|
102
|
-
|
|
112
|
+
context_name_font = _register_font(options.get('context_name_font')) or font
|
|
113
|
+
pdf_canvas.setFont(context_name_font, options.get('context_name_size', 28))
|
|
103
114
|
context_name_color = options.get('context_name_color', '#000')
|
|
104
115
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
|
|
105
116
|
|
|
@@ -114,13 +125,22 @@ def _write_text_on_template(template: any, font: str, username: str, context_nam
|
|
|
114
125
|
|
|
115
126
|
# Write the issue date.
|
|
116
127
|
issue_date = get_localized_credential_date()
|
|
117
|
-
|
|
128
|
+
if options.get('issue_date_uppercase', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_UPPERCASE', False)):
|
|
129
|
+
issue_date = issue_date.upper()
|
|
130
|
+
|
|
131
|
+
issue_date_font = _register_font(options.get('issue_date_font')) or font
|
|
132
|
+
pdf_canvas.setFont(issue_date_font, options.get('issue_date_size', 12))
|
|
118
133
|
issue_date_color = options.get('issue_date_color', '#000')
|
|
119
134
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
|
|
120
135
|
|
|
121
136
|
issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
|
|
122
137
|
issue_date_y = options.get('issue_date_y', 120)
|
|
123
|
-
|
|
138
|
+
|
|
139
|
+
issue_date_char_space = options.get(
|
|
140
|
+
'issue_date_char_space', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_CHAR_SPACE', 0)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date, charSpace=issue_date_char_space)
|
|
124
144
|
|
|
125
145
|
return pdf_canvas
|
|
126
146
|
|
|
@@ -169,7 +189,7 @@ def generate_pdf_credential(
|
|
|
169
189
|
credential_uuid: UUID,
|
|
170
190
|
options: dict[str, Any],
|
|
171
191
|
) -> str:
|
|
172
|
-
"""
|
|
192
|
+
r"""
|
|
173
193
|
Generate a PDF credential.
|
|
174
194
|
|
|
175
195
|
:param learning_context_key: The ID of the course or learning path the credential is for.
|
|
@@ -180,33 +200,48 @@ def generate_pdf_credential(
|
|
|
180
200
|
|
|
181
201
|
Options:
|
|
182
202
|
- template: The path to the PDF template file.
|
|
183
|
-
-
|
|
184
|
-
A
|
|
185
|
-
- font: The name of the font to use.
|
|
203
|
+
- template_multiline: The path to the PDF template file for multiline context names.
|
|
204
|
+
A multiline context name is specified by using '\n' or ';' as a separator.
|
|
205
|
+
- font: The name of the font to use. The default font is Helvetica.
|
|
186
206
|
- name_y: The Y coordinate of the name on the credential (vertical position on the template).
|
|
187
207
|
- name_color: The color of the name on the credential (hexadecimal color code).
|
|
188
208
|
- name_size: The font size of the name on the credential. The default value is 32.
|
|
189
|
-
-
|
|
209
|
+
- name_font: The font of the name on the credential. It overrides the `font` option.
|
|
210
|
+
- name_uppercase: If set to true (without quotes), the name will be converted to uppercase.
|
|
211
|
+
The default value is False, unless specified otherwise in the instance settings.
|
|
212
|
+
- context_name: Specify the custom course or Learning Path name. If not provided, it will be retrieved
|
|
213
|
+
automatically from the "cert_name_long" or "display_name" fields for courses, or from the Learning Path model.
|
|
190
214
|
- context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
|
|
191
215
|
- context_name_color: The color of the context name on the credential (hexadecimal color code).
|
|
192
216
|
- context_name_size: The font size of the context name on the credential. The default value is 28.
|
|
217
|
+
- context_name_font: The font of the context name on the credential. It overrides the `font` option.
|
|
193
218
|
- issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
|
|
194
219
|
- issue_date_color: The color of the issue date on the credential (hexadecimal color code).
|
|
220
|
+
- issue_date_size: The font size of the issue date on the credential. The default value is 12.
|
|
221
|
+
- issue_date_font: The font of the issue date on the credential. It overrides the `font` option.
|
|
222
|
+
- issue_date_char_space: The character spacing of the issue date on the credential
|
|
223
|
+
(default is 0.0, unless specified otherwise in the instance settings).
|
|
224
|
+
- issue_date_uppercase: If set to true (without quotes), the issue date will be converted to uppercase.
|
|
225
|
+
The default value is False, unless specified otherwise in the instance settings.
|
|
195
226
|
"""
|
|
196
227
|
log.info("Starting credential generation for user %s", user.id)
|
|
197
228
|
|
|
198
229
|
username = _get_user_name(user)
|
|
199
230
|
context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
|
|
231
|
+
template_path = options.get('template')
|
|
200
232
|
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
if '
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
233
|
+
# Handle multiline context name (we support semicolon as a separator to preserve backward compatibility).
|
|
234
|
+
context_name = context_name.replace(';', '\n').replace(r'\n', '\n')
|
|
235
|
+
if '\n' in context_name:
|
|
236
|
+
# `template_two_lines` is kept for backward compatibility.
|
|
237
|
+
template_path = options.get('template_multiline', options.get('template_two_lines', template_path))
|
|
238
|
+
|
|
239
|
+
if not template_path:
|
|
240
|
+
msg = "Template path must be specified in options."
|
|
241
|
+
raise ValueError(msg)
|
|
208
242
|
|
|
209
|
-
|
|
243
|
+
# Get template from the CredentialAsset.
|
|
244
|
+
template_file = CredentialAsset.get_asset_by_slug(template_path)
|
|
210
245
|
|
|
211
246
|
# Load the PDF template.
|
|
212
247
|
with template_file.open('rb') as template_file:
|
|
@@ -215,7 +250,7 @@ def generate_pdf_credential(
|
|
|
215
250
|
credential = PdfWriter()
|
|
216
251
|
|
|
217
252
|
# Create a new canvas, prepare the page and write the data
|
|
218
|
-
pdf_canvas = _write_text_on_template(template,
|
|
253
|
+
pdf_canvas = _write_text_on_template(template, username, context_name, options)
|
|
219
254
|
|
|
220
255
|
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
221
256
|
template.merge_page(overlay_pdf.pages[0])
|
learning_credentials/models.py
CHANGED
|
@@ -7,7 +7,7 @@ import logging
|
|
|
7
7
|
import uuid
|
|
8
8
|
from importlib import import_module
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import TYPE_CHECKING
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
11
|
|
|
12
12
|
import jsonfield
|
|
13
13
|
from django.conf import settings
|
|
@@ -112,7 +112,7 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
112
112
|
task_path = f"{task.__wrapped__.__module__}.{task.__wrapped__.__name__}"
|
|
113
113
|
|
|
114
114
|
if self._state.adding:
|
|
115
|
-
schedule,
|
|
115
|
+
schedule, _created = IntervalSchedule.objects.get_or_create(every=10, period=IntervalSchedule.DAYS)
|
|
116
116
|
self.periodic_task = PeriodicTask.objects.create(
|
|
117
117
|
enabled=False,
|
|
118
118
|
interval=schedule,
|
|
@@ -165,12 +165,11 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
165
165
|
filtered_user_ids_set = set(user_ids) - set(users_ids_with_credentials)
|
|
166
166
|
return list(filtered_user_ids_set)
|
|
167
167
|
|
|
168
|
-
def
|
|
168
|
+
def get_eligible_user_ids(self) -> list[int]:
|
|
169
169
|
"""
|
|
170
|
-
|
|
170
|
+
Get the list of eligible learners for the given course.
|
|
171
171
|
|
|
172
|
-
:
|
|
173
|
-
:return: Raw result from the retrieval function - list of user IDs or user details dict.
|
|
172
|
+
:return: A list of user IDs.
|
|
174
173
|
"""
|
|
175
174
|
func_path = self.credential_type.retrieval_func
|
|
176
175
|
module_path, func_name = func_path.rsplit('.', 1)
|
|
@@ -178,40 +177,7 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
178
177
|
func = getattr(module, func_name)
|
|
179
178
|
|
|
180
179
|
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
181
|
-
return func(self.learning_context_key, custom_options
|
|
182
|
-
|
|
183
|
-
def get_eligible_user_ids(self, user_id: int | None = None) -> list[int]:
|
|
184
|
-
"""
|
|
185
|
-
Get the list of eligible learners for the given course.
|
|
186
|
-
|
|
187
|
-
:param user_id: Optional. If provided, will check eligibility for the specific user.
|
|
188
|
-
:return: A list of user IDs.
|
|
189
|
-
"""
|
|
190
|
-
result = self._call_retrieval_func(user_id)
|
|
191
|
-
|
|
192
|
-
if user_id is not None:
|
|
193
|
-
# Single user case: return list with user ID if eligible
|
|
194
|
-
if isinstance(result, dict) and result.get('is_eligible', False):
|
|
195
|
-
return [user_id]
|
|
196
|
-
return []
|
|
197
|
-
|
|
198
|
-
# Multiple users case: result should already be a list of user IDs
|
|
199
|
-
return result if isinstance(result, list) else []
|
|
200
|
-
|
|
201
|
-
def get_user_eligibility_details(self, user_id: int) -> dict[str, Any]:
|
|
202
|
-
"""
|
|
203
|
-
Get detailed eligibility information for a specific user.
|
|
204
|
-
|
|
205
|
-
:param user_id: The user ID to check eligibility for.
|
|
206
|
-
:return: Dictionary containing eligibility details and progress information.
|
|
207
|
-
"""
|
|
208
|
-
result = self._call_retrieval_func(user_id)
|
|
209
|
-
|
|
210
|
-
if isinstance(result, dict):
|
|
211
|
-
return result
|
|
212
|
-
|
|
213
|
-
# Fallback for processors that don't support detailed results
|
|
214
|
-
return {'is_eligible': False}
|
|
180
|
+
return func(self.learning_context_key, custom_options)
|
|
215
181
|
|
|
216
182
|
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
|
|
217
183
|
"""
|
|
@@ -328,15 +294,15 @@ class Credential(TimeStampedModel):
|
|
|
328
294
|
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
329
295
|
user = get_user_model().objects.get(id=self.user_id)
|
|
330
296
|
msg = Message(
|
|
331
|
-
name="certificate_generated",
|
|
332
|
-
app_label="learning_credentials",
|
|
333
|
-
recipient=Recipient(lms_user_id=user.id, email_address=user.email),
|
|
334
|
-
language='en',
|
|
297
|
+
name="certificate_generated",
|
|
298
|
+
app_label="learning_credentials",
|
|
299
|
+
recipient=Recipient(lms_user_id=user.id, email_address=user.email),
|
|
300
|
+
language='en',
|
|
335
301
|
context={
|
|
336
302
|
'certificate_link': self.download_url,
|
|
337
303
|
'course_name': learning_context_name,
|
|
338
304
|
'platform_name': settings.PLATFORM_NAME,
|
|
339
|
-
},
|
|
305
|
+
},
|
|
340
306
|
)
|
|
341
307
|
ace.send(msg)
|
|
342
308
|
|
|
@@ -38,63 +38,46 @@ log = logging.getLogger(__name__)
|
|
|
38
38
|
|
|
39
39
|
def _process_learning_context(
|
|
40
40
|
learning_context_key: LearningContextKey,
|
|
41
|
-
course_processor: Callable[[CourseKey, dict[str, Any]
|
|
41
|
+
course_processor: Callable[[CourseKey, dict[str, Any]], list[int]],
|
|
42
42
|
options: dict[str, Any],
|
|
43
|
-
|
|
44
|
-
) -> dict[int, dict[str, Any]]:
|
|
43
|
+
) -> list[int]:
|
|
45
44
|
"""
|
|
46
45
|
Process a learning context (course or learning path) using the given course processor function.
|
|
47
46
|
|
|
48
47
|
For courses, runs the processor directly. For learning paths, runs the processor on each
|
|
49
|
-
course in the path with step-specific options (if available), and returns
|
|
50
|
-
|
|
48
|
+
course in the path with step-specific options (if available), and returns the intersection
|
|
49
|
+
of eligible users across all courses.
|
|
51
50
|
|
|
52
51
|
Args:
|
|
53
52
|
learning_context_key: A course key or learning path key to process
|
|
54
|
-
course_processor: A function that processes a single course and returns
|
|
53
|
+
course_processor: A function that processes a single course and returns eligible user IDs
|
|
55
54
|
options: Options to pass to the processor. For learning paths, may contain a "steps" key
|
|
56
55
|
with step-specific options in the format: {"steps": {"<course_key>": {...}}}
|
|
57
|
-
user_id: Optional. If provided, will filter to the specific user.
|
|
58
56
|
|
|
59
57
|
Returns:
|
|
60
|
-
A
|
|
58
|
+
A list of eligible user IDs
|
|
61
59
|
"""
|
|
62
60
|
if learning_context_key.is_course:
|
|
63
|
-
return course_processor(learning_context_key, options
|
|
61
|
+
return course_processor(learning_context_key, options)
|
|
64
62
|
|
|
65
63
|
learning_path = LearningPath.objects.get(key=learning_context_key)
|
|
66
64
|
|
|
67
|
-
|
|
68
|
-
|
|
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
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
step_results_by_course[str(step.course_key)] = step_results
|
|
76
|
-
all_user_ids.update(step_results.keys())
|
|
70
|
+
if results is None:
|
|
71
|
+
results = course_results
|
|
72
|
+
else:
|
|
73
|
+
results &= course_results
|
|
77
74
|
|
|
78
75
|
# Filter out users who are not enrolled in the Learning Path.
|
|
79
|
-
|
|
80
|
-
learning_path.enrolled_users.filter(learningpathenrollment__is_active=True).values_list('id', flat=True)
|
|
76
|
+
results &= set(
|
|
77
|
+
learning_path.enrolled_users.filter(learningpathenrollment__is_active=True).values_list('id', flat=True),
|
|
81
78
|
)
|
|
82
79
|
|
|
83
|
-
|
|
84
|
-
for uid in all_user_ids:
|
|
85
|
-
overall_eligible = True
|
|
86
|
-
user_step_results = {}
|
|
87
|
-
|
|
88
|
-
for course_key, step_results in step_results_by_course.items():
|
|
89
|
-
if uid in step_results:
|
|
90
|
-
user_step_results[course_key] = step_results[uid]
|
|
91
|
-
overall_eligible = overall_eligible and step_results[uid].get('is_eligible', False)
|
|
92
|
-
else:
|
|
93
|
-
overall_eligible = False
|
|
94
|
-
|
|
95
|
-
final_results[uid] = {'is_eligible': overall_eligible, 'steps': user_step_results}
|
|
96
|
-
|
|
97
|
-
return final_results
|
|
80
|
+
return list(results) if results else []
|
|
98
81
|
|
|
99
82
|
|
|
100
83
|
def _get_category_weights(course_id: CourseKey) -> dict[str, float]:
|
|
@@ -117,7 +100,7 @@ def _get_category_weights(course_id: CourseKey) -> dict[str, float]:
|
|
|
117
100
|
return category_weight_ratios
|
|
118
101
|
|
|
119
102
|
|
|
120
|
-
def _get_grades_by_format(course_id: CourseKey, users: list[User]) -> dict[int, dict[str,
|
|
103
|
+
def _get_grades_by_format(course_id: CourseKey, users: list[User]) -> dict[int, dict[str, int]]:
|
|
121
104
|
"""
|
|
122
105
|
Get the grades for each user, categorized by assignment types.
|
|
123
106
|
|
|
@@ -179,65 +162,30 @@ def _are_grades_passing_criteria(
|
|
|
179
162
|
return total_score >= required_grades.get('total', 0)
|
|
180
163
|
|
|
181
164
|
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
required_grades: dict[str, float],
|
|
185
|
-
category_weights: dict[str, float],
|
|
186
|
-
) -> dict[str, Any]:
|
|
187
|
-
"""
|
|
188
|
-
Calculate detailed progress information for grade-based criteria.
|
|
189
|
-
|
|
190
|
-
:param user_grades: The grades of the user, divided by category.
|
|
191
|
-
:param required_grades: The required grades for each category.
|
|
192
|
-
:param category_weights: The weight of each category.
|
|
193
|
-
:returns: Dict with is_eligible, current_grades, and required_grades.
|
|
194
|
-
"""
|
|
195
|
-
# Calculate total score
|
|
196
|
-
total_score = 0
|
|
197
|
-
for category, score in user_grades.items():
|
|
198
|
-
if category in category_weights:
|
|
199
|
-
total_score += score * category_weights[category]
|
|
200
|
-
|
|
201
|
-
# Add total to user grades
|
|
202
|
-
user_grades_with_total = {**user_grades, 'total': total_score}
|
|
203
|
-
|
|
204
|
-
is_eligible = _are_grades_passing_criteria(user_grades, required_grades, category_weights)
|
|
205
|
-
|
|
206
|
-
return {
|
|
207
|
-
'is_eligible': is_eligible,
|
|
208
|
-
'current_grades': user_grades_with_total,
|
|
209
|
-
'required_grades': required_grades,
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def _retrieve_course_subsection_grades(
|
|
214
|
-
course_id: CourseKey, options: dict[str, Any], user_id: int | None = None
|
|
215
|
-
) -> dict[int, dict[str, Any]]:
|
|
216
|
-
"""Implementation for retrieving course grades. Always returns detailed progress for all users."""
|
|
165
|
+
def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
|
|
166
|
+
"""Implementation for retrieving course grades."""
|
|
217
167
|
required_grades: dict[str, int] = options['required_grades']
|
|
218
168
|
required_grades = {key.lower(): value * 100 for key, value in required_grades.items()}
|
|
219
169
|
|
|
220
|
-
users = get_course_enrollments(course_id
|
|
170
|
+
users = get_course_enrollments(course_id)
|
|
221
171
|
grades = _get_grades_by_format(course_id, users)
|
|
222
172
|
log.debug(grades)
|
|
223
173
|
weights = _get_category_weights(course_id)
|
|
224
174
|
|
|
225
|
-
|
|
226
|
-
for
|
|
227
|
-
|
|
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)
|
|
228
179
|
|
|
229
|
-
return
|
|
180
|
+
return eligible_users
|
|
230
181
|
|
|
231
182
|
|
|
232
|
-
def retrieve_subsection_grades(
|
|
233
|
-
learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None
|
|
234
|
-
) -> list[int] | dict[str, Any]:
|
|
183
|
+
def retrieve_subsection_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
|
|
235
184
|
"""
|
|
236
185
|
Retrieve the users that have passing grades in all required categories.
|
|
237
186
|
|
|
238
187
|
:param learning_context_key: The learning context key (course or learning path).
|
|
239
188
|
:param options: The custom options for the credential.
|
|
240
|
-
:param user_id: Optional. If provided, will check eligibility for the specific user.
|
|
241
189
|
:returns: The IDs of the users that have passing grades in all required categories.
|
|
242
190
|
|
|
243
191
|
Options:
|
|
@@ -284,16 +232,7 @@ def retrieve_subsection_grades(
|
|
|
284
232
|
}
|
|
285
233
|
}
|
|
286
234
|
"""
|
|
287
|
-
|
|
288
|
-
learning_context_key, _retrieve_course_subsection_grades, options, user_id
|
|
289
|
-
)
|
|
290
|
-
|
|
291
|
-
if user_id is not None:
|
|
292
|
-
if user_id in detailed_results:
|
|
293
|
-
return detailed_results[user_id]
|
|
294
|
-
return {'is_eligible': False, 'current_grades': {}, 'required_grades': {}}
|
|
295
|
-
|
|
296
|
-
return [uid for uid, result in detailed_results.items() if result.get('is_eligible', False)]
|
|
235
|
+
return _process_learning_context(learning_context_key, _retrieve_course_subsection_grades, options)
|
|
297
236
|
|
|
298
237
|
|
|
299
238
|
def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params: dict, url: str) -> APIView:
|
|
@@ -313,7 +252,7 @@ def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params
|
|
|
313
252
|
drf_request = Request(django_request) # convert django.core.handlers.wsgi.WSGIRequest to DRF request
|
|
314
253
|
|
|
315
254
|
view = CompletionDetailView()
|
|
316
|
-
view.request = drf_request
|
|
255
|
+
view.request = drf_request
|
|
317
256
|
|
|
318
257
|
# HACK: Bypass the API permissions.
|
|
319
258
|
staff_user = get_user_model().objects.filter(is_staff=True).first()
|
|
@@ -323,10 +262,8 @@ def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params
|
|
|
323
262
|
return view
|
|
324
263
|
|
|
325
264
|
|
|
326
|
-
def _retrieve_course_completions(
|
|
327
|
-
|
|
328
|
-
) -> dict[int, dict[str, Any]]:
|
|
329
|
-
"""Implementation for retrieving course completions. Always returns detailed progress for all users."""
|
|
265
|
+
def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
|
|
266
|
+
"""Implementation for retrieving course completions."""
|
|
330
267
|
# If it turns out to be too slow, we can:
|
|
331
268
|
# 1. Modify the Completion Aggregator to emit a signal/event when a user achieves a certain completion threshold.
|
|
332
269
|
# 2. Get this data from the `Aggregator` model. Filter by `aggregation name == 'course'`, `course_key`, `percent`.
|
|
@@ -339,47 +276,29 @@ def _retrieve_course_completions(
|
|
|
339
276
|
|
|
340
277
|
# TODO: Extract the logic of this view into an API. The current approach is very hacky.
|
|
341
278
|
view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url)
|
|
342
|
-
completions =
|
|
279
|
+
completions = []
|
|
343
280
|
|
|
344
281
|
while True:
|
|
345
282
|
# noinspection PyUnresolvedReferences
|
|
346
283
|
response = view.get(view.request, str(course_id))
|
|
347
284
|
log.debug(response.data)
|
|
348
|
-
|
|
349
|
-
|
|
285
|
+
completions.extend(
|
|
286
|
+
res['username'] for res in response.data['results'] if res['completion']['percent'] >= required_completion
|
|
287
|
+
)
|
|
350
288
|
if not response.data['pagination']['next']:
|
|
351
289
|
break
|
|
352
290
|
query_params['page'] += 1
|
|
353
291
|
view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url)
|
|
354
292
|
|
|
355
|
-
|
|
356
|
-
users = get_course_enrollments(course_id, user_id)
|
|
357
|
-
username_to_id = {user.username: user.id for user in users} # type: ignore[unresolved-attribute]
|
|
358
|
-
|
|
359
|
-
# Always return detailed progress for all users as dict
|
|
360
|
-
detailed_results = {}
|
|
361
|
-
for username, current_completion in completions.items():
|
|
362
|
-
if username in username_to_id:
|
|
363
|
-
user_id_for_result = username_to_id[username]
|
|
364
|
-
progress = {
|
|
365
|
-
'is_eligible': current_completion >= required_completion,
|
|
366
|
-
'current_completion': current_completion,
|
|
367
|
-
'required_completion': required_completion,
|
|
368
|
-
}
|
|
369
|
-
detailed_results[user_id_for_result] = progress
|
|
293
|
+
return list(get_user_model().objects.filter(username__in=completions).values_list('id', flat=True))
|
|
370
294
|
|
|
371
|
-
return detailed_results
|
|
372
295
|
|
|
373
|
-
|
|
374
|
-
def retrieve_completions(
|
|
375
|
-
learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None
|
|
376
|
-
) -> list[int] | dict[str, Any]:
|
|
296
|
+
def retrieve_completions(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
|
|
377
297
|
"""
|
|
378
298
|
Retrieve the course completions for all users through the Completion Aggregator API.
|
|
379
299
|
|
|
380
300
|
:param learning_context_key: The learning context key (course or learning path).
|
|
381
301
|
:param options: The custom options for the credential.
|
|
382
|
-
:param user_id: Optional. If provided, will check eligibility for the specific user.
|
|
383
302
|
:returns: The IDs of the users that have achieved the required completion percentage.
|
|
384
303
|
|
|
385
304
|
Options:
|
|
@@ -400,19 +319,10 @@ def retrieve_completions(
|
|
|
400
319
|
}
|
|
401
320
|
}
|
|
402
321
|
"""
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if user_id is not None:
|
|
406
|
-
if user_id in detailed_results:
|
|
407
|
-
return detailed_results[user_id]
|
|
408
|
-
return {'is_eligible': False, 'current_completion': 0.0, 'required_completion': 0.9}
|
|
322
|
+
return _process_learning_context(learning_context_key, _retrieve_course_completions, options)
|
|
409
323
|
|
|
410
|
-
return [uid for uid, result in detailed_results.items() if result.get('is_eligible', False)]
|
|
411
324
|
|
|
412
|
-
|
|
413
|
-
def retrieve_completions_and_grades(
|
|
414
|
-
learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None
|
|
415
|
-
) -> list[int] | dict[str, Any]:
|
|
325
|
+
def retrieve_completions_and_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
|
|
416
326
|
"""
|
|
417
327
|
Retrieve the users that meet both completion and grade criteria.
|
|
418
328
|
|
|
@@ -421,7 +331,6 @@ def retrieve_completions_and_grades(
|
|
|
421
331
|
|
|
422
332
|
:param learning_context_key: The learning context key (course or learning path).
|
|
423
333
|
:param options: The custom options for the credential.
|
|
424
|
-
:param user_id: Optional. If provided, will check eligibility for the specific user.
|
|
425
334
|
:returns: The IDs of the users that meet both sets of criteria.
|
|
426
335
|
|
|
427
336
|
Options:
|
|
@@ -463,23 +372,6 @@ def retrieve_completions_and_grades(
|
|
|
463
372
|
}
|
|
464
373
|
}
|
|
465
374
|
"""
|
|
466
|
-
if user_id is not None:
|
|
467
|
-
completion_result = retrieve_completions(learning_context_key, options, user_id)
|
|
468
|
-
grades_result = retrieve_subsection_grades(learning_context_key, options, user_id)
|
|
469
|
-
|
|
470
|
-
if type(grades_result) is not dict or type(completion_result) is not dict:
|
|
471
|
-
msg = 'Both results must be dictionaries when user_id is provided.'
|
|
472
|
-
raise ValueError(msg)
|
|
473
|
-
|
|
474
|
-
completion_eligible = completion_result.get('is_eligible', False)
|
|
475
|
-
grades_eligible = grades_result.get('is_eligible', False)
|
|
476
|
-
|
|
477
|
-
return {
|
|
478
|
-
**completion_result,
|
|
479
|
-
**grades_result,
|
|
480
|
-
'is_eligible': completion_eligible and grades_eligible,
|
|
481
|
-
}
|
|
482
|
-
|
|
483
375
|
completion_eligible_users = set(retrieve_completions(learning_context_key, options))
|
|
484
376
|
grades_eligible_users = set(retrieve_subsection_grades(learning_context_key, options))
|
|
485
377
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""App-specific settings for all environments."""
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from django.conf import Settings
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
|
|
9
|
+
def plugin_settings(settings: 'Settings'):
|
|
7
10
|
"""Add `django_celery_beat` to `INSTALLED_APPS`."""
|
|
8
11
|
if 'django_celery_beat' not in settings.INSTALLED_APPS:
|
|
9
12
|
settings.INSTALLED_APPS += ('django_celery_beat',)
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""App-specific settings for production environments."""
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from django.conf import Settings
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
|
|
9
|
+
def plugin_settings(settings: 'Settings'):
|
|
7
10
|
"""
|
|
8
11
|
Use the database scheduler for Celery Beat.
|
|
9
12
|
|
learning_credentials/urls.py
CHANGED