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.
- learning_credentials/api/__init__.py +1 -0
- learning_credentials/api/urls.py +12 -0
- learning_credentials/api/v1/__init__.py +1 -0
- learning_credentials/api/v1/permissions.py +63 -0
- learning_credentials/api/v1/serializers.py +74 -0
- learning_credentials/api/v1/urls.py +17 -0
- learning_credentials/api/v1/views.py +357 -0
- learning_credentials/apps.py +10 -0
- learning_credentials/compat.py +11 -2
- learning_credentials/core_api.py +77 -0
- learning_credentials/generators.py +3 -1
- learning_credentials/models.py +44 -10
- learning_credentials/processors.py +148 -40
- learning_credentials/public/css/credentials_xblock.css +7 -0
- learning_credentials/public/html/credentials_xblock.html +48 -0
- learning_credentials/public/js/credentials_xblock.js +23 -0
- learning_credentials/urls.py +3 -5
- learning_credentials/xblocks.py +85 -0
- {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/METADATA +2 -1
- {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/RECORD +24 -13
- learning_credentials-0.3.0rc2.dist-info/entry_points.txt +8 -0
- learning_credentials/views.py +0 -1
- learning_credentials-0.2.3.dist-info/entry_points.txt +0 -2
- {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/WHEEL +0 -0
- {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/licenses/LICENSE.txt +0 -0
- {learning_credentials-0.2.3.dist-info → learning_credentials-0.3.0rc2.dist-info}/top_level.txt +0 -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, 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
|
|
168
|
+
def _call_retrieval_func(self, user_id: int | None = None) -> list[int] | dict[str, Any]:
|
|
169
169
|
"""
|
|
170
|
-
|
|
170
|
+
Call the retrieval function with the given parameters.
|
|
171
171
|
|
|
172
|
-
:
|
|
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]],
|
|
41
|
+
course_processor: Callable[[CourseKey, dict[str, Any], int | None], dict[int, dict[str, Any]]],
|
|
42
42
|
options: dict[str, Any],
|
|
43
|
-
|
|
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
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
166
|
-
|
|
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
|
-
|
|
176
|
-
for
|
|
177
|
-
|
|
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
|
|
229
|
+
return results
|
|
181
230
|
|
|
182
231
|
|
|
183
|
-
def retrieve_subsection_grades(
|
|
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
|
-
|
|
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(
|
|
266
|
-
|
|
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
|
-
|
|
286
|
-
res['username']
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
learning_credentials/urls.py
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"""URLs for learning_credentials."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
# from django.views.generic import TemplateView # noqa: ERA001, RUF100
|
|
3
|
+
from django.urls import include, path
|
|
5
4
|
|
|
6
|
-
urlpatterns = [
|
|
7
|
-
|
|
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.
|
|
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=
|
|
4
|
-
learning_credentials/compat.py,sha256=
|
|
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=
|
|
7
|
-
learning_credentials/models.py,sha256=
|
|
8
|
-
learning_credentials/processors.py,sha256=
|
|
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=
|
|
11
|
-
learning_credentials/
|
|
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.
|
|
30
|
-
learning_credentials-0.
|
|
31
|
-
learning_credentials-0.
|
|
32
|
-
learning_credentials-0.
|
|
33
|
-
learning_credentials-0.
|
|
34
|
-
learning_credentials-0.
|
|
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,,
|