Skip to content

Commit fd06ef7

Browse files
feat: add certificate management v2 API endpoints (#38404)
1 parent 3718ed8 commit fd06ef7

9 files changed

Lines changed: 1335 additions & 64 deletions

File tree

lms/djangoapps/certificates/data.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,12 @@ class CertificateStatuses:
5757
requesting = 'requesting'
5858

5959
readable_statuses = {
60-
downloadable: "already received",
61-
notpassing: "didn't receive",
62-
error: "error states",
63-
audit_passing: "audit passing states",
64-
audit_notpassing: "audit not passing states",
60+
downloadable: "Received",
61+
notpassing: "Not Received",
62+
unavailable: "Invalidated",
63+
error: "Error State",
64+
audit_passing: "Audit - Passing",
65+
audit_notpassing: "Audit - Not Passing",
6566
}
6667

6768
PASSED_STATUSES = (downloadable, generating)

lms/djangoapps/certificates/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,7 @@ def get_certificate_generation_candidates(self):
588588
if not task_input.strip():
589589
# if task input is empty, it means certificates were generated for all learners
590590
# Translators: This string represents task was executed for all learners.
591-
return _("All learners")
591+
return _("All Learners")
592592

593593
task_input_json = json.loads(task_input)
594594

@@ -607,9 +607,9 @@ def get_certificate_generation_candidates(self):
607607
# for backwards compatibility.
608608
if 'student_set' in task_input_json or 'students' in task_input_json:
609609
# Translators: This string represents task was executed for students having exceptions.
610-
return _("For exceptions")
610+
return _("Granted Exceptions")
611611
else:
612-
return _("All learners")
612+
return _("All Learners")
613613

614614
class Meta:
615615
app_label = "certificates"

lms/djangoapps/certificates/tests/test_models.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -267,22 +267,22 @@ class TestCertificateGenerationHistory(OpenEdxEventsTestMixin, TestCase):
267267
ENABLED_OPENEDX_EVENTS = []
268268

269269
@ddt.data(
270-
({"student_set": "allowlisted_not_generated"}, "For exceptions", True),
271-
({"student_set": "allowlisted_not_generated"}, "For exceptions", False),
270+
({"student_set": "allowlisted_not_generated"}, "Granted Exceptions", True),
271+
({"student_set": "allowlisted_not_generated"}, "Granted Exceptions", False),
272272
# check "students" key for backwards compatibility
273-
({"students": [1, 2, 3]}, "For exceptions", True),
274-
({"students": [1, 2, 3]}, "For exceptions", False),
275-
({}, "All learners", True),
276-
({}, "All learners", False),
273+
({"students": [1, 2, 3]}, "Granted Exceptions", True),
274+
({"students": [1, 2, 3]}, "Granted Exceptions", False),
275+
({}, "All Learners", True),
276+
({}, "All Learners", False),
277277
# test single status to regenerate returns correctly
278-
({"statuses_to_regenerate": ['downloadable']}, 'already received', True),
279-
({"statuses_to_regenerate": ['downloadable']}, 'already received', False),
278+
({"statuses_to_regenerate": ['downloadable']}, 'Received', True),
279+
({"statuses_to_regenerate": ['downloadable']}, 'Received', False),
280280
# test that list of > 1 statuses render correctly
281-
({"statuses_to_regenerate": ['downloadable', 'error']}, 'already received, error states', True),
282-
({"statuses_to_regenerate": ['downloadable', 'error']}, 'already received, error states', False),
281+
({"statuses_to_regenerate": ['downloadable', 'error']}, 'Received, Error State', True),
282+
({"statuses_to_regenerate": ['downloadable', 'error']}, 'Received, Error State', False),
283283
# test that only "readable" statuses are returned
284-
({"statuses_to_regenerate": ['downloadable', 'not_readable']}, 'already received', True),
285-
({"statuses_to_regenerate": ['downloadable', 'not_readable']}, 'already received', False),
284+
({"statuses_to_regenerate": ['downloadable', 'not_readable']}, 'Received', True),
285+
({"statuses_to_regenerate": ['downloadable', 'not_readable']}, 'Received', False),
286286
)
287287
@ddt.unpack
288288
def test_get_certificate_generation_candidates(self, task_input, expected, is_regeneration):

lms/djangoapps/instructor/tests/test_api_v2.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
UserFactory,
3737
)
3838
from lms.djangoapps.certificates.data import CertificateStatuses
39-
from lms.djangoapps.certificates.models import CertificateGenerationHistory
39+
from lms.djangoapps.certificates.models import CertificateAllowlist, CertificateGenerationHistory
4040
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
4141
from lms.djangoapps.courseware.models import StudentModule
4242
from lms.djangoapps.instructor.access import ROLE_DISPLAY_NAMES
@@ -2094,6 +2094,57 @@ def test_pagination(self):
20942094
assert 'previous' in response.data
20952095
assert 'results' in response.data
20962096

2097+
def test_granted_exceptions_without_certificates(self):
2098+
"""
2099+
Test that granted_exceptions filter shows allowlisted users
2100+
even if they don't have GeneratedCertificate records yet.
2101+
"""
2102+
# Add student1 to allowlist (has verified enrollment)
2103+
CertificateAllowlist.objects.create(
2104+
user=self.student1,
2105+
course_id=self.course_key,
2106+
allowlist=True,
2107+
notes='Medical emergency'
2108+
)
2109+
2110+
# Add student2 to allowlist (has audit enrollment, no certificate)
2111+
CertificateAllowlist.objects.create(
2112+
user=self.student2,
2113+
course_id=self.course_key,
2114+
allowlist=True,
2115+
notes='Special case'
2116+
)
2117+
2118+
# Create certificate only for student1
2119+
GeneratedCertificateFactory.create(
2120+
user=self.student1,
2121+
course_id=self.course_key,
2122+
status=CertificateStatuses.downloadable
2123+
)
2124+
2125+
self.client.force_authenticate(user=self.instructor)
2126+
params = {'filter': 'granted_exceptions'}
2127+
response = self.client.get(self._get_url(), params)
2128+
2129+
assert response.status_code == status.HTTP_200_OK
2130+
assert response.data['count'] == 2 # Both students should appear
2131+
2132+
results = {r['username']: r for r in response.data['results']}
2133+
2134+
# Verify student1 (has certificate)
2135+
assert 'student1' in results
2136+
assert results['student1']['enrollment_track'] == 'verified'
2137+
assert results['student1']['certificate_status'] == 'downloadable'
2138+
assert results['student1']['special_case'] == 'Exception'
2139+
assert results['student1']['exception_notes'] == 'Medical emergency'
2140+
2141+
# Verify student2 (no certificate, but should appear with enrollment data)
2142+
assert 'student2' in results
2143+
assert results['student2']['enrollment_track'] == 'audit'
2144+
assert results['student2']['certificate_status'] == 'audit_notpassing'
2145+
assert results['student2']['special_case'] == 'Exception'
2146+
assert results['student2']['exception_notes'] == 'Special case'
2147+
20972148

20982149
@ddt.ddt
20992150
class CertificateGenerationHistoryViewTest(SharedModuleStoreTestCase):
@@ -2206,7 +2257,7 @@ def test_history_entry_structure(self):
22062257
# Verify all required fields are present (snake_case from serializer)
22072258
assert entry['task_name'] == 'Regenerated'
22082259
assert 'date' in entry
2209-
assert entry['details'] == 'All learners'
2260+
assert entry['details'] == 'All Learners'
22102261

22112262
# Verify data types
22122263
assert isinstance(entry['task_name'], str)

0 commit comments

Comments
 (0)