Skip to content

Commit ce6cc61

Browse files
committed
feat: implement Certificate XBlock
1 parent 8d032de commit ce6cc61

13 files changed

Lines changed: 315 additions & 23 deletions

File tree

learning_credentials/api.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""API functions for the Learning Credentials app."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from django.contrib.auth.models import User
8+
from opaque_keys.edx.keys import CourseKey
9+
10+
from .models import Credential, CredentialConfiguration
11+
from .tasks import generate_credential_for_user_task
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
def get_eligible_users_by_credential_type(course_id: CourseKey, user_id: int | None = None) -> dict[str, list[User]]:
17+
"""
18+
Retrieve eligible users for each credential type in the given course.
19+
20+
:param course_id: The key of the course for which to check eligibility.
21+
:param user_id: Optional. If provided, will check eligibility for the specific user.
22+
:return: A dictionary with credential type as the key and eligible users as the value.
23+
"""
24+
credential_configs = CredentialConfiguration.objects.filter(course_id=course_id)
25+
26+
if not credential_configs:
27+
return {}
28+
29+
eligible_users_by_type = {}
30+
for credential_config in credential_configs:
31+
user_ids = credential_config.get_eligible_user_ids(user_id)
32+
filtered_user_ids = credential_config.filter_out_user_ids_with_credentials(user_ids)
33+
34+
if user_id:
35+
eligible_users_by_type[credential_config.credential_type.name] = list(set(filtered_user_ids) & {user_id})
36+
else:
37+
eligible_users_by_type[credential_config.credential_type.name] = filtered_user_ids
38+
39+
return eligible_users_by_type
40+
41+
42+
def get_user_credentials_by_type(course_id: CourseKey, user_id: int) -> dict[str, dict[str, str]]:
43+
"""
44+
Retrieve the available credentials for a given user in a course.
45+
46+
:param course_id: The course ID for which to retrieve credentials.
47+
:param user_id: The ID of the user for whom credentials are being retrieved.
48+
:return: A dict where keys are credential types and values are dicts with the download link and status.
49+
"""
50+
credentials = Credential.objects.filter(user_id=user_id, course_id=course_id)
51+
52+
return {cred.credential_type: {'download_url': cred.download_url, 'status': cred.status} for cred in credentials}
53+
54+
55+
def generate_credential_for_user(course_id: CourseKey, credential_type: str, user_id: int, force: bool = False):
56+
"""
57+
Generate a credential for a user in a course.
58+
59+
:param course_id: The course ID for which to generate the credential.
60+
:param credential_type: The type of credential to generate.
61+
:param user_id: The ID of the user for whom the credential is being generated.
62+
:param force: If True, will generate the credential even if the user is not eligible.
63+
"""
64+
credential_config = CredentialConfiguration.objects.get(
65+
course_id=course_id, credential_type__name=credential_type
66+
)
67+
68+
if not credential_config:
69+
logger.error('No course configuration found for course %s', course_id)
70+
return
71+
72+
if not force and not credential_config.get_eligible_user_ids(user_id):
73+
logger.error('User %s is not eligible for the credential in course %s', user_id, course_id)
74+
raise ValueError('User is not eligible for the credential.')
75+
76+
generate_credential_for_user_task.delay(credential_config.id, user_id)

learning_credentials/apps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,9 @@ class LearningCredentialsConfig(AppConfig):
2020
'common': {'relative_path': 'settings.common'},
2121
'production': {'relative_path': 'settings.production'},
2222
},
23+
'cms.djangoapp': {
24+
'common': {'relative_path': 'settings.common'},
25+
'production': {'relative_path': 'settings.production'},
26+
},
2327
},
2428
}

learning_credentials/compat.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,21 @@ def get_learning_context_name(learning_context_key: LearningContextKey) -> str:
7373
return _get_learning_path_name(learning_context_key)
7474

7575

76-
def get_course_enrollments(course_id: CourseKey) -> list[User]:
77-
"""Get the course enrollments from Open edX."""
76+
def get_course_enrollments(course_id: CourseKey, user_id: int | None = None) -> list[User]:
77+
"""
78+
Get the course enrollments from Open edX.
79+
80+
:param course_id: The course ID.
81+
:param user_id: Optional. If provided, will filter the enrollments by user.
82+
:return: A list of users enrolled in the course.
83+
"""
7884
# noinspection PyUnresolvedReferences,PyPackageRequirements
7985
from common.djangoapps.student.models import CourseEnrollment
8086

8187
enrollments = CourseEnrollment.objects.filter(course_id=course_id, is_active=True).select_related('user')
88+
if user_id:
89+
enrollments = enrollments.filter(user__id=user_id)
90+
8291
return [enrollment.user for enrollment in enrollments]
8392

8493

learning_credentials/models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,11 @@ def filter_out_user_ids_with_credentials(self, user_ids: list[int]) -> list[int]
165165
filtered_user_ids_set = set(user_ids) - set(users_ids_with_credentials)
166166
return list(filtered_user_ids_set)
167167

168-
def get_eligible_user_ids(self) -> list[int]:
168+
def get_eligible_user_ids(self, user_id: int = None) -> list[int]:
169169
"""
170170
Get the list of eligible learners for the given course.
171171
172+
:param user_id: Optional. If provided, will check eligibility for the specific user.
172173
:return: A list of user IDs.
173174
"""
174175
func_path = self.credential_type.retrieval_func
@@ -177,7 +178,7 @@ def get_eligible_user_ids(self) -> list[int]:
177178
func = getattr(module, func_name)
178179

179180
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)
181182

182183
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
183184
"""

learning_credentials/processors.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@
3838

3939
def _process_learning_context(
4040
learning_context_key: LearningContextKey,
41-
course_processor: Callable[[CourseKey, dict[str, Any]], list[int]],
41+
course_processor: Callable[[CourseKey, dict[str, Any], int | None], list[int]],
4242
options: dict[str, Any],
43+
user_id: int | None = None,
4344
) -> list[int]:
4445
"""
4546
Process a learning context (course or learning path) using the given course processor function.
@@ -53,19 +54,20 @@ def _process_learning_context(
5354
course_processor: A function that processes a single course and returns eligible user IDs
5455
options: Options to pass to the processor. For learning paths, may contain a "steps" key
5556
with step-specific options in the format: {"steps": {"<course_key>": {...}}}
57+
user_id: Optional. If provided, will check eligibility for the specific user.
5658
5759
Returns:
5860
A list of eligible user IDs
5961
"""
6062
if learning_context_key.is_course:
61-
return course_processor(learning_context_key, options)
63+
return course_processor(learning_context_key, options, user_id)
6264

6365
learning_path = LearningPath.objects.get(key=learning_context_key)
6466

6567
results = None
6668
for course in learning_path.steps.all():
6769
course_options = options.get("steps", {}).get(str(course.course_key), options)
68-
course_results = set(course_processor(course.course_key, course_options))
70+
course_results = set(course_processor(course.course_key, course_options, user_id))
6971

7072
if results is None:
7173
results = course_results
@@ -162,30 +164,31 @@ def _are_grades_passing_criteria(
162164
return total_score >= required_grades.get('total', 0)
163165

164166

165-
def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
167+
def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str, Any], user_id: int | None = None) -> list[int]:
166168
"""Implementation for retrieving course grades."""
167169
required_grades: dict[str, int] = options['required_grades']
168170
required_grades = {key.lower(): value * 100 for key, value in required_grades.items()}
169171

170-
users = get_course_enrollments(course_id)
172+
users = get_course_enrollments(course_id, user_id)
171173
grades = _get_grades_by_format(course_id, users)
172174
log.debug(grades)
173175
weights = _get_category_weights(course_id)
174176

175177
eligible_users = []
176-
for user_id, user_grades in grades.items():
178+
for uid, user_grades in grades.items():
177179
if _are_grades_passing_criteria(user_grades, required_grades, weights):
178-
eligible_users.append(user_id)
180+
eligible_users.append(uid)
179181

180182
return eligible_users
181183

182184

183-
def retrieve_subsection_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
185+
def retrieve_subsection_grades(learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None) -> list[int]:
184186
"""
185187
Retrieve the users that have passing grades in all required categories.
186188
187189
:param learning_context_key: The learning context key (course or learning path).
188190
:param options: The custom options for the credential.
191+
:param user_id: Optional. If provided, will check eligibility for the specific user.
189192
:returns: The IDs of the users that have passing grades in all required categories.
190193
191194
Options:
@@ -232,7 +235,7 @@ def retrieve_subsection_grades(learning_context_key: LearningContextKey, options
232235
}
233236
}
234237
"""
235-
return _process_learning_context(learning_context_key, _retrieve_course_subsection_grades, options)
238+
return _process_learning_context(learning_context_key, _retrieve_course_subsection_grades, options, user_id)
236239

237240

238241
def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params: dict, url: str) -> APIView:
@@ -262,7 +265,7 @@ def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params
262265
return view
263266

264267

265-
def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any]) -> list[int]:
268+
def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any], user_id: int | None = None) -> list[int]:
266269
"""Implementation for retrieving course completions."""
267270
# If it turns out to be too slow, we can:
268271
# 1. Modify the Completion Aggregator to emit a signal/event when a user achieves a certain completion threshold.
@@ -290,15 +293,20 @@ def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any])
290293
query_params['page'] += 1
291294
view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url)
292295

296+
if user_id:
297+
username = get_user_model().objects.get(id=user_id).username
298+
return [user_id] if username in completions else []
299+
293300
return list(get_user_model().objects.filter(username__in=completions).values_list('id', flat=True))
294301

295302

296-
def retrieve_completions(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
303+
def retrieve_completions(learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None) -> list[int]:
297304
"""
298305
Retrieve the course completions for all users through the Completion Aggregator API.
299306
300307
:param learning_context_key: The learning context key (course or learning path).
301-
:param options: The custom options for the credential.
308+
:param options: The custom options for the credentia
309+
:param user_id: Optional. If provided, will check eligibility for the specific user.l.
302310
:returns: The IDs of the users that have achieved the required completion percentage.
303311
304312
Options:
@@ -322,7 +330,7 @@ def retrieve_completions(learning_context_key: LearningContextKey, options: dict
322330
return _process_learning_context(learning_context_key, _retrieve_course_completions, options)
323331

324332

325-
def retrieve_completions_and_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]:
333+
def retrieve_completions_and_grades(learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None) -> list[int]:
326334
"""
327335
Retrieve the users that meet both completion and grade criteria.
328336
@@ -331,6 +339,7 @@ def retrieve_completions_and_grades(learning_context_key: LearningContextKey, op
331339
332340
:param learning_context_key: The learning context key (course or learning path).
333341
:param options: The custom options for the credential.
342+
:param user_id: Optional. If provided, will check eligibility for the specific user.
334343
:returns: The IDs of the users that meet both sets of criteria.
335344
336345
Options:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.credentials-block .credentials-list .credential {
2+
padding-bottom: 20px;
3+
4+
.credential-status {
5+
margin-bottom: 10px;
6+
}
7+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)