Skip to content

Commit 82f52c9

Browse files
committed
fixup! feat: implement Certificate XBlock
1 parent b5afcb3 commit 82f52c9

File tree

10 files changed

+144
-133
lines changed

10 files changed

+144
-133
lines changed

learning_credentials/api.py

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""API functions for the Open edX Certificates app."""
1+
"""API functions for the Learning Credentials app."""
22

33
from __future__ import annotations
44

@@ -7,70 +7,70 @@
77
from django.contrib.auth.models import User
88
from opaque_keys.edx.keys import CourseKey
99

10-
from .models import ExternalCertificate, ExternalCertificateCourseConfiguration
11-
from .tasks import generate_certificate_for_user_task
10+
from .models import Credential, CredentialConfiguration
11+
from .tasks import generate_credential_for_user_task
1212

1313
logger = logging.getLogger(__name__)
1414

1515

16-
def get_eligible_users_by_certificate_type(course_id: CourseKey, user_id: int = None) -> dict[str, list[User]]:
16+
def get_eligible_users_by_credential_type(course_id: CourseKey, user_id: int | None = None) -> dict[str, list[User]]:
1717
"""
18-
Retrieve eligible users for each certificate type in the given course.
18+
Retrieve eligible users for each credential type in the given course.
1919
2020
:param course_id: The key of the course for which to check eligibility.
2121
:param user_id: Optional. If provided, will check eligibility for the specific user.
22-
:return: A dictionary with certificate type as the key and eligible users as the value.
22+
:return: A dictionary with credential type as the key and eligible users as the value.
2323
"""
24-
certificate_configs = ExternalCertificateCourseConfiguration.objects.filter(course_id=course_id)
24+
credential_configs = CredentialConfiguration.objects.filter(course_id=course_id)
2525

26-
if not certificate_configs:
26+
if not credential_configs:
2727
return {}
2828

2929
eligible_users_by_type = {}
30-
for certificate_config in certificate_configs:
31-
user_ids = certificate_config.get_eligible_user_ids(user_id)
32-
filtered_user_ids = certificate_config.filter_out_user_ids_with_certificates(user_ids)
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)
3333

3434
if user_id:
35-
eligible_users_by_type[certificate_config.certificate_type.name] = list(set(filtered_user_ids) & {user_id})
35+
eligible_users_by_type[credential_config.credential_type.name] = list(set(filtered_user_ids) & {user_id})
3636
else:
37-
eligible_users_by_type[certificate_config.certificate_type.name] = filtered_user_ids
37+
eligible_users_by_type[credential_config.credential_type.name] = filtered_user_ids
3838

3939
return eligible_users_by_type
4040

4141

42-
def get_user_certificates_by_type(course_id: CourseKey, user_id: int) -> dict[str, dict[str, str]]:
42+
def get_user_credentials_by_type(course_id: CourseKey, user_id: int) -> dict[str, dict[str, str]]:
4343
"""
44-
Retrieve the available certificates for a given user in a course.
44+
Retrieve the available credentials for a given user in a course.
4545
46-
:param course_id: The course ID for which to retrieve certificates.
47-
:param user_id: The ID of the user for whom certificates are being retrieved.
48-
:return: A dict where keys are certificate types and values are dicts with the download link and status.
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.
4949
"""
50-
certificates = ExternalCertificate.objects.filter(user_id=user_id, course_id=course_id)
50+
credentials = Credential.objects.filter(user_id=user_id, course_id=course_id)
5151

52-
return {cert.certificate_type: {'download_url': cert.download_url, 'status': cert.status} for cert in certificates}
52+
return {cred.credential_type: {'download_url': cred.download_url, 'status': cred.status} for cred in credentials}
5353

5454

55-
def generate_certificate_for_user(course_id: CourseKey, certificate_type: str, user_id: int, force: bool = False):
55+
def generate_credential_for_user(course_id: CourseKey, credential_type: str, user_id: int, force: bool = False):
5656
"""
57-
Generate a certificate for a user in a course.
57+
Generate a credential for a user in a course.
5858
59-
:param course_id: The course ID for which to generate the certificate.
60-
:param certificate_type: The type of certificate to generate.
61-
:param user_id: The ID of the user for whom the certificate is being generated.
62-
:param force: If True, will generate the certificate even if the user is not eligible.
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.
6363
"""
64-
certificate_config = ExternalCertificateCourseConfiguration.objects.get(
65-
course_id=course_id, certificate_type__name=certificate_type
64+
credential_config = CredentialConfiguration.objects.get(
65+
course_id=course_id, credential_type__name=credential_type
6666
)
6767

68-
if not certificate_config:
68+
if not credential_config:
6969
logger.error('No course configuration found for course %s', course_id)
7070
return
7171

72-
if not force and not certificate_config.get_eligible_user_ids(user_id):
73-
logger.error('User %s is not eligible for the certificate in course %s', user_id, course_id)
74-
raise ValueError('User is not eligible for the certificate.')
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.')
7575

76-
generate_certificate_for_user_task.delay(certificate_config.id, user_id)
76+
generate_credential_for_user_task.delay(credential_config.id, user_id)

learning_credentials/compat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ 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, user_id: int = None) -> list[User]:
76+
def get_course_enrollments(course_id: CourseKey, user_id: int | None = None) -> list[User]:
7777
"""
7878
Get the course enrollments from Open edX.
7979

learning_credentials/processors.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +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,
43+
user_id: int | None = None,
4444
) -> list[int]:
4545
"""
4646
Process a learning context (course or learning path) using the given course processor function.
@@ -54,6 +54,7 @@ def _process_learning_context(
5454
course_processor: A function that processes a single course and returns eligible user IDs
5555
options: Options to pass to the processor. For learning paths, may contain a "steps" key
5656
with step-specific options in the format: {"steps": {"<course_key>": {...}}}
57+
user_id: Optional. If provided, will check eligibility for the specific user.
5758
5859
Returns:
5960
A list of eligible user IDs
@@ -163,7 +164,7 @@ def _are_grades_passing_criteria(
163164
return total_score >= required_grades.get('total', 0)
164165

165166

166-
def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str, Any], user_id: int = None) -> list[int]:
167+
def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str, Any], user_id: int | None = None) -> list[int]:
167168
"""Implementation for retrieving course grades."""
168169
required_grades: dict[str, int] = options['required_grades']
169170
required_grades = {key.lower(): value * 100 for key, value in required_grades.items()}
@@ -181,12 +182,13 @@ def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str,
181182
return eligible_users
182183

183184

184-
def retrieve_subsection_grades(learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int = None) -> list[int]:
185+
def retrieve_subsection_grades(learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None) -> list[int]:
185186
"""
186187
Retrieve the users that have passing grades in all required categories.
187188
188189
:param learning_context_key: The learning context key (course or learning path).
189190
:param options: The custom options for the credential.
191+
:param user_id: Optional. If provided, will check eligibility for the specific user.
190192
:returns: The IDs of the users that have passing grades in all required categories.
191193
192194
Options:
@@ -263,7 +265,7 @@ def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params
263265
return view
264266

265267

266-
def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any], user_id: int = None) -> list[int]:
268+
def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any], user_id: int | None = None) -> list[int]:
267269
"""Implementation for retrieving course completions."""
268270
# If it turns out to be too slow, we can:
269271
# 1. Modify the Completion Aggregator to emit a signal/event when a user achieves a certain completion threshold.
@@ -298,12 +300,13 @@ def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any],
298300
return list(get_user_model().objects.filter(username__in=completions).values_list('id', flat=True))
299301

300302

301-
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]:
302304
"""
303305
Retrieve the course completions for all users through the Completion Aggregator API.
304306
305307
:param learning_context_key: The learning context key (course or learning path).
306-
: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.
307310
:returns: The IDs of the users that have achieved the required completion percentage.
308311
309312
Options:
@@ -327,7 +330,7 @@ def retrieve_completions(learning_context_key: LearningContextKey, options: dict
327330
return _process_learning_context(learning_context_key, _retrieve_course_completions, options)
328331

329332

330-
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]:
331334
"""
332335
Retrieve the users that meet both completion and grade criteria.
333336
@@ -336,6 +339,7 @@ def retrieve_completions_and_grades(learning_context_key: LearningContextKey, op
336339
337340
:param learning_context_key: The learning context key (course or learning path).
338341
:param options: The custom options for the credential.
342+
:param user_id: Optional. If provided, will check eligibility for the specific user.
339343
:returns: The IDs of the users that meet both sets of criteria.
340344
341345
Options:

learning_credentials/public/css/certificates_xblock.css

Whitespace-only changes.
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+
}

learning_credentials/public/html/certificates_xblock.html

Lines changed: 0 additions & 48 deletions
This file was deleted.
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: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
function CertificatesXBlock(runtime, element) {
2-
function generateCertificate(event) {
1+
function CredentialsXBlock(runtime, element) {
2+
function generateCredential(event) {
33
const button = event.target;
4-
const certificateType = $(button).data('certificate-type');
5-
const handlerUrl = runtime.handlerUrl(element, 'generate_certificate');
4+
const credentialType = $(button).data('credential-type');
5+
const handlerUrl = runtime.handlerUrl(element, 'generate_credential');
66

7-
$.post(handlerUrl, JSON.stringify({ certificate_type: certificateType }))
7+
$.post(handlerUrl, JSON.stringify({ credential_type: credentialType }))
88
.done(function(data) {
9-
const messageArea = $(element).find('#message-area-' + certificateType);
9+
const messageArea = $(element).find('#message-area-' + credentialType);
1010
if (data.status === 'success') {
1111
messageArea.html('<p style="color:green;">Certificate generation initiated successfully.</p>');
1212
} else {
1313
messageArea.html('<p style="color:red;">' + data.message + '</p>');
1414
}
1515
})
1616
.fail(function() {
17-
const messageArea = $(element).find('#message-area-' + certificateType);
17+
const messageArea = $(element).find('#message-area-' + credentialType);
1818
messageArea.html('<p style="color:red;">An error occurred while processing your request.</p>');
1919
});
2020
}
2121

22-
$(element).find('.generate-certificate').on('click', generateCertificate);
22+
$(element).find('.generate-credential').on('click', generateCredential);
2323
}

0 commit comments

Comments
 (0)