learning-credentials 0.2.3__py3-none-any.whl → 0.3.0rc2__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 (26) hide show
  1. learning_credentials/api/__init__.py +1 -0
  2. learning_credentials/api/urls.py +12 -0
  3. learning_credentials/api/v1/__init__.py +1 -0
  4. learning_credentials/api/v1/permissions.py +63 -0
  5. learning_credentials/api/v1/serializers.py +74 -0
  6. learning_credentials/api/v1/urls.py +17 -0
  7. learning_credentials/api/v1/views.py +357 -0
  8. learning_credentials/apps.py +10 -0
  9. learning_credentials/compat.py +11 -2
  10. learning_credentials/core_api.py +77 -0
  11. learning_credentials/generators.py +3 -1
  12. learning_credentials/models.py +44 -10
  13. learning_credentials/processors.py +148 -40
  14. learning_credentials/public/css/credentials_xblock.css +7 -0
  15. learning_credentials/public/html/credentials_xblock.html +48 -0
  16. learning_credentials/public/js/credentials_xblock.js +23 -0
  17. learning_credentials/urls.py +3 -5
  18. learning_credentials/xblocks.py +85 -0
  19. {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/METADATA +2 -1
  20. {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/RECORD +24 -13
  21. learning_credentials-0.3.0rc2.dist-info/entry_points.txt +8 -0
  22. learning_credentials/views.py +0 -1
  23. learning_credentials-0.2.3.dist-info/entry_points.txt +0 -2
  24. {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/WHEEL +0 -0
  25. {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/licenses/LICENSE.txt +0 -0
  26. {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/top_level.txt +0 -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
10
+ from typing import TYPE_CHECKING, Any
11
11
 
12
12
  import jsonfield
13
13
  from django.conf import settings
@@ -165,11 +165,12 @@ 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 get_eligible_user_ids(self) -> list[int]:
168
+ def _call_retrieval_func(self, user_id: int | None = None) -> list[int] | dict[str, Any]:
169
169
  """
170
- Get the list of eligible learners for the given course.
170
+ Call the retrieval function with the given parameters.
171
171
 
172
- :return: A list of user IDs.
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.
173
174
  """
174
175
  func_path = self.credential_type.retrieval_func
175
176
  module_path, func_name = func_path.rsplit('.', 1)
@@ -177,7 +178,40 @@ class CredentialConfiguration(TimeStampedModel):
177
178
  func = getattr(module, func_name)
178
179
 
179
180
  custom_options = {**self.credential_type.custom_options, **self.custom_options}
180
- return func(self.learning_context_key, 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}
181
215
 
182
216
  def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
183
217
  """
@@ -294,15 +328,15 @@ class Credential(TimeStampedModel):
294
328
  learning_context_name = get_learning_context_name(self.learning_context_key)
295
329
  user = get_user_model().objects.get(id=self.user_id)
296
330
  msg = Message(
297
- name="certificate_generated",
298
- app_label="learning_credentials",
299
- recipient=Recipient(lms_user_id=user.id, email_address=user.email),
300
- language='en',
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]
301
335
  context={
302
336
  'certificate_link': self.download_url,
303
337
  'course_name': learning_context_name,
304
338
  'platform_name': settings.PLATFORM_NAME,
305
- },
339
+ }, # type: ignore[unknown-argument]
306
340
  )
307
341
  ace.send(msg)
308
342
 
@@ -38,46 +38,63 @@ 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]], list[int]],
41
+ course_processor: Callable[[CourseKey, dict[str, Any], int | None], dict[int, dict[str, Any]]],
42
42
  options: dict[str, Any],
43
- ) -> list[int]:
43
+ user_id: int | None = None,
44
+ ) -> dict[int, dict[str, Any]]:
44
45
  """
45
46
  Process a learning context (course or learning path) using the given course processor function.
46
47
 
47
48
  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.
49
+ course in the path with step-specific options (if available), and returns detailed results
50
+ with step breakdown.
50
51
 
51
52
  Args:
52
53
  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
+ course_processor: A function that processes a single course and returns detailed progress for all users
54
55
  options: Options to pass to the processor. For learning paths, may contain a "steps" key
55
56
  with step-specific options in the format: {"steps": {"<course_key>": {...}}}
57
+ user_id: Optional. If provided, will filter to the specific user.
56
58
 
57
59
  Returns:
58
- A list of eligible user IDs
60
+ A dict mapping user_id to detailed progress, with step breakdown for learning paths
59
61
  """
60
62
  if learning_context_key.is_course:
61
- return course_processor(learning_context_key, options)
63
+ return course_processor(learning_context_key, options, user_id) # type: ignore[invalid-argument-type]
62
64
 
63
65
  learning_path = LearningPath.objects.get(key=learning_context_key)
64
66
 
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))
67
+ step_results_by_course = {}
68
+ all_user_ids = set()
69
69
 
70
- if results is None:
71
- results = course_results
72
- else:
73
- results &= course_results
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())
74
77
 
75
78
  # 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),
79
+ all_user_ids &= set(
80
+ learning_path.enrolled_users.filter(learningpathenrollment__is_active=True).values_list('id', flat=True)
78
81
  )
79
82
 
80
- return list(results) if results else []
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
81
98
 
82
99
 
83
100
  def _get_category_weights(course_id: CourseKey) -> dict[str, float]:
@@ -100,7 +117,7 @@ def _get_category_weights(course_id: CourseKey) -> dict[str, float]:
100
117
  return category_weight_ratios
101
118
 
102
119
 
103
- def _get_grades_by_format(course_id: CourseKey, users: list[User]) -> dict[int, dict[str, int]]:
120
+ def _get_grades_by_format(course_id: CourseKey, users: list[User]) -> dict[int, dict[str, float]]:
104
121
  """
105
122
  Get the grades for each user, categorized by assignment types.
106
123
 
@@ -162,30 +179,65 @@ def _are_grades_passing_criteria(
162
179
  return total_score >= required_grades.get('total', 0)
163
180
 
164
181
 
165
- def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
166
- """Implementation for retrieving course grades."""
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."""
167
217
  required_grades: dict[str, int] = options['required_grades']
168
218
  required_grades = {key.lower(): value * 100 for key, value in required_grades.items()}
169
219
 
170
- users = get_course_enrollments(course_id)
220
+ users = get_course_enrollments(course_id, user_id)
171
221
  grades = _get_grades_by_format(course_id, users)
172
222
  log.debug(grades)
173
223
  weights = _get_category_weights(course_id)
174
224
 
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)
225
+ results = {}
226
+ for uid, user_grades in grades.items():
227
+ results[uid] = _calculate_grades_progress(user_grades, required_grades, weights)
179
228
 
180
- return eligible_users
229
+ return results
181
230
 
182
231
 
183
- def retrieve_subsection_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
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]:
184
235
  """
185
236
  Retrieve the users that have passing grades in all required categories.
186
237
 
187
238
  :param learning_context_key: The learning context key (course or learning path).
188
239
  :param options: The custom options for the credential.
240
+ :param user_id: Optional. If provided, will check eligibility for the specific user.
189
241
  :returns: The IDs of the users that have passing grades in all required categories.
190
242
 
191
243
  Options:
@@ -232,7 +284,16 @@ def retrieve_subsection_grades(learning_context_key: LearningContextKey, options
232
284
  }
233
285
  }
234
286
  """
235
- return _process_learning_context(learning_context_key, _retrieve_course_subsection_grades, options)
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)]
236
297
 
237
298
 
238
299
  def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params: dict, url: str) -> APIView:
@@ -252,7 +313,7 @@ def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params
252
313
  drf_request = Request(django_request) # convert django.core.handlers.wsgi.WSGIRequest to DRF request
253
314
 
254
315
  view = CompletionDetailView()
255
- view.request = drf_request
316
+ view.request = drf_request # type: ignore[invalid-assignment]
256
317
 
257
318
  # HACK: Bypass the API permissions.
258
319
  staff_user = get_user_model().objects.filter(is_staff=True).first()
@@ -262,8 +323,10 @@ def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params
262
323
  return view
263
324
 
264
325
 
265
- def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
266
- """Implementation for retrieving course completions."""
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."""
267
330
  # If it turns out to be too slow, we can:
268
331
  # 1. Modify the Completion Aggregator to emit a signal/event when a user achieves a certain completion threshold.
269
332
  # 2. Get this data from the `Aggregator` model. Filter by `aggregation name == 'course'`, `course_key`, `percent`.
@@ -276,29 +339,47 @@ def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any])
276
339
 
277
340
  # TODO: Extract the logic of this view into an API. The current approach is very hacky.
278
341
  view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url)
279
- completions = []
342
+ completions = {}
280
343
 
281
344
  while True:
282
345
  # noinspection PyUnresolvedReferences
283
346
  response = view.get(view.request, str(course_id))
284
347
  log.debug(response.data)
285
- completions.extend(
286
- res['username'] for res in response.data['results'] if res['completion']['percent'] >= required_completion
287
- )
348
+ for res in response.data['results']:
349
+ completions[res['username']] = res['completion']['percent']
288
350
  if not response.data['pagination']['next']:
289
351
  break
290
352
  query_params['page'] += 1
291
353
  view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url)
292
354
 
293
- return list(get_user_model().objects.filter(username__in=completions).values_list('id', flat=True))
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
294
370
 
371
+ return detailed_results
295
372
 
296
- def retrieve_completions(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
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]:
297
377
  """
298
378
  Retrieve the course completions for all users through the Completion Aggregator API.
299
379
 
300
380
  :param learning_context_key: The learning context key (course or learning path).
301
381
  :param options: The custom options for the credential.
382
+ :param user_id: Optional. If provided, will check eligibility for the specific user.
302
383
  :returns: The IDs of the users that have achieved the required completion percentage.
303
384
 
304
385
  Options:
@@ -319,10 +400,19 @@ def retrieve_completions(learning_context_key: LearningContextKey, options: dict
319
400
  }
320
401
  }
321
402
  """
322
- return _process_learning_context(learning_context_key, _retrieve_course_completions, options)
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}
323
409
 
410
+ return [uid for uid, result in detailed_results.items() if result.get('is_eligible', False)]
324
411
 
325
- def retrieve_completions_and_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
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]:
326
416
  """
327
417
  Retrieve the users that meet both completion and grade criteria.
328
418
 
@@ -331,6 +421,7 @@ def retrieve_completions_and_grades(learning_context_key: LearningContextKey, op
331
421
 
332
422
  :param learning_context_key: The learning context key (course or learning path).
333
423
  :param options: The custom options for the credential.
424
+ :param user_id: Optional. If provided, will check eligibility for the specific user.
334
425
  :returns: The IDs of the users that meet both sets of criteria.
335
426
 
336
427
  Options:
@@ -372,6 +463,23 @@ def retrieve_completions_and_grades(learning_context_key: LearningContextKey, op
372
463
  }
373
464
  }
374
465
  """
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
+
375
483
  completion_eligible_users = set(retrieve_completions(learning_context_key, options))
376
484
  grades_eligible_users = set(retrieve_subsection_grades(learning_context_key, options))
377
485
 
@@ -0,0 +1,7 @@
1
+ .credentials-block .credentials-list .credential {
2
+ padding-bottom: 20px;
3
+
4
+ .credential-status {
5
+ margin-bottom: 10px;
6
+ }
7
+ }
@@ -0,0 +1,48 @@
1
+ <div class="credentials-block">
2
+ {% if is_author_mode %}
3
+ <p>The Studio view of this XBlock is not supported yet. Please preview the XBlock in the LMS.</p>
4
+ {% else %}
5
+ <h3>Check Your Certificate Eligibility Status</h3>
6
+ <ul class="credentials-list">
7
+ {% if credentials %}
8
+ {% for credential_type, credential in credentials.items %}
9
+ <li class="credential">
10
+ <strong>Type:</strong> {{ credential_type }}
11
+ {% if credential.download_url %}
12
+ <p class="credential-status">Congratulations on finishing strong!</p>
13
+ <strong>Download Link:</strong> <a href="{{ credential.download_url }}">Download Certificate</a>
14
+ {% elif credential.status == credential.Status.ERROR %}
15
+ <p class="credential-status">Something went wrong. Please contact us via the Help page for assistance.</p>
16
+ {% endif %}
17
+ <button class="btn-brand generate-credential" data-credential-type="{{ credential_type }}" disabled>
18
+ Certificate Claimed
19
+ </button>
20
+ <div id="message-area-{{ credential_type }}"></div>
21
+ </li>
22
+ {% endfor %}
23
+ {% endif %}
24
+
25
+ {% if eligible_types %}
26
+ {% for credential_type, is_eligible in eligible_types.items %}
27
+ {% if not credentials or credential_type not in credentials %}
28
+ <li class="credential">
29
+ <strong>Type:</strong> {{ credential_type }}
30
+ {% if is_eligible %}
31
+ <p class="credential-status">Congratulations! You have earned this certificate. Please claim it below.</p>
32
+ <button class="btn-brand generate-credential" data-credential-type="{{ credential_type }}">
33
+ Claim Certificate
34
+ </button>
35
+ {% else %}
36
+ <p class="certificate-status">You are not yet eligible for this certificate.</p>
37
+ <button class="btn-brand generate-certificate" data-certificate-type="{{ credential_type }}" disabled>
38
+ Claim Certificate
39
+ </button>
40
+ {% endif %}
41
+ <div id="message-area-{{ credential_type }}"></div>
42
+ </li>
43
+ {% endif %}
44
+ {% endfor %}
45
+ {% endif %}
46
+ </ul>
47
+ {% endif %}
48
+ </div>
@@ -0,0 +1,23 @@
1
+ function CredentialsXBlock(runtime, element) {
2
+ function generateCredential(event) {
3
+ const button = event.target;
4
+ const credentialType = $(button).data('credential-type');
5
+ const handlerUrl = runtime.handlerUrl(element, 'generate_credential');
6
+
7
+ $.post(handlerUrl, JSON.stringify({ credential_type: credentialType }))
8
+ .done(function(data) {
9
+ const messageArea = $(element).find('#message-area-' + credentialType);
10
+ if (data.status === 'success') {
11
+ messageArea.html('<p style="color:green;">Certificate generation initiated successfully.</p>');
12
+ } else {
13
+ messageArea.html('<p style="color:red;">' + data.message + '</p>');
14
+ }
15
+ })
16
+ .fail(function() {
17
+ const messageArea = $(element).find('#message-area-' + credentialType);
18
+ messageArea.html('<p style="color:red;">An error occurred while processing your request.</p>');
19
+ });
20
+ }
21
+
22
+ $(element).find('.generate-credential').on('click', generateCredential);
23
+ }
@@ -1,9 +1,7 @@
1
1
  """URLs for learning_credentials."""
2
2
 
3
- # from django.urls import re_path # noqa: ERA001, RUF100
4
- # from django.views.generic import TemplateView # noqa: ERA001, RUF100
3
+ from django.urls import include, path
5
4
 
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
5
+ urlpatterns = [
6
+ path('api/learning_credentials/', include('learning_credentials.api.urls')),
9
7
  ]
@@ -0,0 +1,85 @@
1
+ """XBlocks for Learning Credentials."""
2
+
3
+ import logging
4
+
5
+ from xblock.core import XBlock
6
+ from xblock.fields import Scope, String
7
+ from xblock.fragment import Fragment
8
+ from xblock.utils.resources import ResourceLoader
9
+ from xblock.utils.studio_editable import StudioEditableXBlockMixin
10
+
11
+ from .core_api import generate_credential_for_user, get_eligible_users_by_credential_type, get_user_credentials_by_type
12
+
13
+ loader = ResourceLoader(__name__)
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class CredentialsXBlock(StudioEditableXBlockMixin, XBlock):
18
+ """XBlock that displays the credential eligibility status and allows eligible users to generate credentials."""
19
+
20
+ display_name = String(
21
+ help='The display name for this component.',
22
+ scope=Scope.content,
23
+ display_name="Display name",
24
+ default='Credentials',
25
+ )
26
+
27
+ def student_view(self, context) -> Fragment: # noqa: ANN001, ARG002
28
+ """Main view for the student. Displays the credential eligibility or ineligibility status."""
29
+ fragment = Fragment()
30
+ eligible_types = False
31
+ credentials = []
32
+
33
+ if not (is_author_mode := getattr(self.runtime, 'is_author_mode', False)):
34
+ credentials = self.get_credentials()
35
+ eligible_types = self.get_eligible_credential_types()
36
+
37
+ # Filter out the eligible types that already have a credential generated
38
+ for cred_type in credentials:
39
+ if cred_type in eligible_types:
40
+ del eligible_types[cred_type]
41
+
42
+ fragment.add_content(
43
+ loader.render_django_template(
44
+ 'public/html/credentials_xblock.html',
45
+ {
46
+ 'credentials': credentials,
47
+ 'eligible_types': eligible_types,
48
+ 'is_author_mode': is_author_mode,
49
+ },
50
+ )
51
+ )
52
+
53
+ fragment.add_css_url(self.runtime.local_resource_url(self, "public/css/credentials_xblock.css"))
54
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, "public/js/credentials_xblock.js"))
55
+ fragment.initialize_js('CredentialsXBlock')
56
+ return fragment
57
+
58
+ def get_eligible_credential_types(self) -> dict[str, bool]:
59
+ """Retrieve the eligibility status for each credential type."""
60
+ eligible_users = get_eligible_users_by_credential_type(self.runtime.course_id, user_id=self.scope_ids.user_id)
61
+
62
+ return {credential_type: bool(users) for credential_type, users in eligible_users.items()}
63
+
64
+ def get_credentials(self) -> dict[str, dict[str, str]]:
65
+ """Retrieve the credentials for the current user in the current course."""
66
+ return get_user_credentials_by_type(self.runtime.course_id, self.scope_ids.user_id)
67
+
68
+ @XBlock.json_handler
69
+ def generate_credential(self, data: dict, suffix: str = '') -> dict[str, str]: # noqa: ARG002
70
+ """Handler for generating a credential for a specific type."""
71
+ credential_type = data.get('credential_type')
72
+ if not credential_type:
73
+ return {'status': 'error', 'message': 'No credential type specified.'}
74
+
75
+ course_id = self.runtime.course_id
76
+ user_id = self.scope_ids.user_id
77
+ logger.info(
78
+ 'Generating a credential for user %s in course %s with type %s.', user_id, course_id, credential_type
79
+ )
80
+
81
+ try:
82
+ generate_credential_for_user(course_id, credential_type, user_id)
83
+ except ValueError as e:
84
+ return {'status': 'error', 'message': str(e)}
85
+ return {'status': 'success'}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.2.3
3
+ Version: 0.3.0rc2
4
4
  Summary: A pluggable service for preparing Open edX credentials.
5
5
  Author-email: OpenCraft <help@opencraft.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -31,6 +31,7 @@ Requires-Dist: pypdf
31
31
  Requires-Dist: reportlab
32
32
  Requires-Dist: openedx-completion-aggregator
33
33
  Requires-Dist: edx_ace
34
+ Requires-Dist: edx-api-doc-tools
34
35
  Requires-Dist: learning-paths-plugin>=0.3.4
35
36
  Dynamic: license-file
36
37
 
@@ -1,14 +1,22 @@
1
1
  learning_credentials/__init__.py,sha256=8Q0-3Hdnfmcj41EKu1GSfzEfwWcYNDlItyEEke2r9bs,62
2
2
  learning_credentials/admin.py,sha256=ynK3tVJwLsIeV7Jk66t1FAVyVsU1G-KRIAdRkycVTmA,10439
3
- learning_credentials/apps.py,sha256=AA6JYUyqKYvNJ5POtQKw_s1g1VrUXuQI96hbea9H220,761
4
- learning_credentials/compat.py,sha256=Btm1Ii3D0nHuPZWZya_VR0JkkFcNRstuwg7A1DXlWG0,4547
3
+ learning_credentials/apps.py,sha256=F3W0q1BCWOm2MxqLr7GsDGkwqvNq3Icxc9OqgomdD7k,1128
4
+ learning_credentials/compat.py,sha256=g32MLgTnSZLGa6H3Qsq1WIPJXWPlFaNVuyZfbZXsKR8,4832
5
+ learning_credentials/core_api.py,sha256=ZVNPm7SQk54roZ8rmZ0Bc6T6NLvumKWJZjJepAxM6L8,3232
5
6
  learning_credentials/exceptions.py,sha256=UaqBVXFMWR2Iob7_LMb3j4NNVmWQFAgLi_MNMRUvGsI,290
6
- learning_credentials/generators.py,sha256=Tzl9fLEVxrjAB1lUYhifGEpIU3JzWPcwvFHeHcVxc4M,8960
7
- learning_credentials/models.py,sha256=Wepzng9WYDAxF8ptyQokp_9jCmuEv_4FY7ytkKFS4uU,16047
8
- learning_credentials/processors.py,sha256=LkdjmkLBnXc9qeMcksB1T8AQ5ZhYaECyQO__KfHB_aU,15212
7
+ learning_credentials/generators.py,sha256=aDo1_r9IE1ad31oHmuIpU0MxqFV8K8ymMVVGfNdeXlg,9098
8
+ learning_credentials/models.py,sha256=GurLaQ2-hY7bflOYKV5vwqTnNwM6puE0tOg4KZ8-bqQ,17694
9
+ learning_credentials/processors.py,sha256=CfHuotWjCsg_Yx-uyZNTKzSREJj3xXD_LnTWUw3g2ms,19806
9
10
  learning_credentials/tasks.py,sha256=byoFEUvN_ayVaU5K5SlEiA7vu9BRPaSSmKnB9g5toec,1927
10
- learning_credentials/urls.py,sha256=2YLZZW738D7Afyzq6hr5ajWIl6azmX-hNDGUg_8AFpE,370
11
- learning_credentials/views.py,sha256=1iBgQYelVHO_QWtoUZfVeyUc0o89IxQWAIwjPjaYaBQ,12
11
+ learning_credentials/urls.py,sha256=9Xc-imliMCIOWqFHfm-CSAgwm2tQGfMR18jCyBpKcho,176
12
+ learning_credentials/xblocks.py,sha256=gb-bfkOlRnBzwUg7CTqBJfg-BOJ1UOtaBQRmbyGuD0w,3568
13
+ learning_credentials/api/__init__.py,sha256=q8sLFfwo5RwQu8FY6BJUL_Jrt3TUojbZK-Zlw9v08EM,40
14
+ learning_credentials/api/urls.py,sha256=JfGSbzvC5d7s9dRq4C0d-AzTDuOVnen3wvFYSJQoEdQ,255
15
+ learning_credentials/api/v1/__init__.py,sha256=A7ZqENtM4QM1A7j_cAfnzw4zn0kuyfXSWtylFIE0_f8,43
16
+ learning_credentials/api/v1/permissions.py,sha256=DZMa2A6Q1ltPdWCksr7I8Ltv_qV46B9yvMM0UXXWo8E,2379
17
+ learning_credentials/api/v1/serializers.py,sha256=c4gNva-OzBUfmKHFOgsb1Ti1GiHSnsyIeT3gYBt7fMk,2685
18
+ learning_credentials/api/v1/urls.py,sha256=n8CTXgKyXyCkgNr210Jl5c5HQ2bZ1iEYMNLSnaB39Hc,585
19
+ learning_credentials/api/v1/views.py,sha256=LrlHRzURWi_zEii_zq55CH05tpC2ZqPhZLyebx1Jy8M,13606
12
20
  learning_credentials/conf/locale/config.yaml,sha256=jPen2DmckNDKK30axCKEd2Q2ha9oOG3IBxrJ63Pvznk,2280
13
21
  learning_credentials/migrations/0001_initial.py,sha256=61EvThCv-0UAnhCE5feyQVfjRodbp-6cDaAr4CY5PMA,8435
14
22
  learning_credentials/migrations/0002_migrate_to_learning_credentials.py,sha256=vUhcnQKDdwOsppkXsjz2zZwOGMwIJ-fkQRsaj-K7l1o,1779
@@ -17,6 +25,9 @@ learning_credentials/migrations/0004_replace_course_keys_with_learning_context_k
17
25
  learning_credentials/migrations/0005_rename_processors_and_generators.py,sha256=5UCqjq-CBJnRo1qBAoWs91ngyEuSMN8_tQtfzsuR5SI,5271
18
26
  learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py,sha256=aJs_gOP4TmW9J-Dmr21m94jBfLQxzjAu6-ua7x4uYLE,727
19
27
  learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
+ learning_credentials/public/css/credentials_xblock.css,sha256=O_16evZyQ6c1vr5IfkKVtwt9JbE1R4w7_mBaiXgfi4k,140
29
+ learning_credentials/public/html/credentials_xblock.html,sha256=U00jJKJfxhfKSJDcj6xyddXqjhCgi6ftCb5-2qb8qUM,2775
30
+ learning_credentials/public/js/credentials_xblock.js,sha256=zx0mXAPY4UOZeWKsm4rmD3yBlSDvFoX8acEoIFqKhG8,1094
20
31
  learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
21
32
  learning_credentials/settings/common.py,sha256=4n9AeQD-GB2MYFVrwXWEsTSrKC9btn8bgyr9OQuXNsY,302
22
33
  learning_credentials/settings/production.py,sha256=yEvsCldHOdsIswW7TPLW__b9YNEK-Qy05rX5WSAcEeo,484
@@ -26,9 +37,9 @@ learning_credentials/templates/learning_credentials/edx_ace/certificate_generate
26
37
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
27
38
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
39
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt,sha256=S7Hc5T_sZSsSBXm5_H5HBNNv16Ohl0oZn0nVqqeWL0g,132
29
- learning_credentials-0.2.3.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
30
- learning_credentials-0.2.3.dist-info/METADATA,sha256=bGXo0uhxTIqHjjVvY4VJdFhjd37NggDgf8mgqpBgSgI,6828
31
- learning_credentials-0.2.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
32
- learning_credentials-0.2.3.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
33
- learning_credentials-0.2.3.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
34
- learning_credentials-0.2.3.dist-info/RECORD,,
40
+ learning_credentials-0.3.0rc2.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
41
+ learning_credentials-0.3.0rc2.dist-info/METADATA,sha256=HgEuWC93fRcs84MGP4vJ6qMOiueVHhtAcPBGi-Mw3WA,6864
42
+ learning_credentials-0.3.0rc2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
43
+ learning_credentials-0.3.0rc2.dist-info/entry_points.txt,sha256=kaYKfkH1k_yFaGqe-MXjBxA5RsjkqOLxz7XLrF5BUC0,258
44
+ learning_credentials-0.3.0rc2.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
45
+ learning_credentials-0.3.0rc2.dist-info/RECORD,,