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.
@@ -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 pdfmetrics
23
- from reportlab.pdfbase.ttfonts import TTFont
24
- from reportlab.pdfgen import canvas
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(options: dict[str, Any]) -> str:
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 options: A dictionary containing the font.
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 font := options.get('font'):
56
- pdfmetrics.registerFont(TTFont(font, CredentialAsset.get_asset_by_slug(font)))
56
+ if not font_name:
57
+ return None
57
58
 
58
- return font or 'Helvetica'
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: any, font: str, username: str, context_name: str, options: dict[str, Any]) -> any:
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 = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height))
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
- # TODO: Add tests.
93
- pdf_canvas.setFont(font, options.get('name_size', 32))
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
- pdf_canvas.setFont(font, options.get('context_name_size', 28))
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
- pdf_canvas.setFont(font, 12)
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
- pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
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
- - template_two_lines: The path to the PDF template file for two-line context names.
184
- A two-line context name is specified by using a semicolon as a separator.
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
- - context_name: Specify the custom course or Learning Path name.
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
- # Get template from the CredentialAsset.
202
- # HACK: We support two-line strings by using a semicolon as a separator.
203
- if ';' in context_name and (template_path := options.get('template_two_lines')):
204
- template_file = CredentialAsset.get_asset_by_slug(template_path)
205
- context_name = context_name.replace(';', '\n')
206
- else:
207
- template_file = CredentialAsset.get_asset_by_slug(options['template'])
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
- font = _register_font(options)
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, font, username, context_name, options)
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])
@@ -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, Any
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, created = IntervalSchedule.objects.get_or_create(every=10, period=IntervalSchedule.DAYS)
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 _call_retrieval_func(self, user_id: int | None = None) -> list[int] | dict[str, Any]:
168
+ def get_eligible_user_ids(self) -> list[int]:
169
169
  """
170
- Call the retrieval function with the given parameters.
170
+ Get the list of eligible learners for the given course.
171
171
 
172
- :param user_id: Optional. If provided, will check eligibility for the specific user.
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, user_id=user_id)
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", # type: ignore[unknown-argument]
332
- app_label="learning_credentials", # type: ignore[unknown-argument]
333
- recipient=Recipient(lms_user_id=user.id, email_address=user.email), # type: ignore[unknown-argument]
334
- language='en', # type: ignore[unknown-argument]
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
- }, # type: ignore[unknown-argument]
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], int | None], dict[int, dict[str, Any]]],
41
+ course_processor: Callable[[CourseKey, dict[str, Any]], list[int]],
42
42
  options: dict[str, Any],
43
- user_id: int | None = None,
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 detailed results
50
- with step breakdown.
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 detailed progress for all users
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 dict mapping user_id to detailed progress, with step breakdown for learning paths
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, user_id) # type: ignore[invalid-argument-type]
61
+ return course_processor(learning_context_key, options)
64
62
 
65
63
  learning_path = LearningPath.objects.get(key=learning_context_key)
66
64
 
67
- step_results_by_course = {}
68
- all_user_ids = set()
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
- # TODO: Use a single Completion Aggregator request when retrieving step results for a single user.
71
- for step in learning_path.steps.all():
72
- course_options = options.get("steps", {}).get(str(step.course_key), options)
73
- step_results = course_processor(step.course_key, course_options, user_id)
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
- all_user_ids &= set(
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
- final_results = {}
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, float]]:
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 _calculate_grades_progress(
183
- user_grades: dict[str, float],
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, user_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
- results = {}
226
- for uid, user_grades in grades.items():
227
- results[uid] = _calculate_grades_progress(user_grades, required_grades, weights)
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 results
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
- detailed_results = _process_learning_context(
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 # type: ignore[invalid-assignment]
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
- course_id: CourseKey, options: dict[str, Any], user_id: int | None = None
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
- for res in response.data['results']:
349
- completions[res['username']] = res['completion']['percent']
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
- # Get all enrolled users and map usernames to user IDs
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
- detailed_results = _process_learning_context(learning_context_key, _retrieve_course_completions, options, user_id)
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 django.conf import Settings
3
+ from typing import TYPE_CHECKING
4
4
 
5
+ if TYPE_CHECKING:
6
+ from django.conf import Settings
5
7
 
6
- def plugin_settings(settings: Settings):
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 django.conf import Settings
3
+ from typing import TYPE_CHECKING
4
4
 
5
+ if TYPE_CHECKING:
6
+ from django.conf import Settings
5
7
 
6
- def plugin_settings(settings: Settings):
8
+
9
+ def plugin_settings(settings: 'Settings'):
7
10
  """
8
11
  Use the database scheduler for Celery Beat.
9
12
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  from django.urls import include, path
4
4
 
5
+ from .api import urls as api_urls
6
+
5
7
  urlpatterns = [
6
- path('api/learning_credentials/', include('learning_credentials.api.urls')),
8
+ path('api/learning_credentials/', include(api_urls)),
7
9
  ]