Skip to content

Commit ddedf44

Browse files
feat: add bulk certificate exceptions CSV upload endpoint (#38464)
1 parent 724d892 commit ddedf44

3 files changed

Lines changed: 347 additions & 0 deletions

File tree

lms/djangoapps/instructor/tests/test_certificates_api_v2.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Unit tests for instructor API v2 certificate management endpoints.
33
"""
4+
from io import BytesIO
45
from unittest.mock import patch
56

67
from django.urls import reverse
@@ -266,6 +267,233 @@ def test_delete_successful(self, mock_get_entry, mock_remove):
266267
mock_remove.assert_called_once_with(self.enrolled_student, self.course.id)
267268

268269

270+
class BulkCertificateExceptionsViewTest(SharedModuleStoreTestCase):
271+
"""Tests for BulkCertificateExceptionsView."""
272+
273+
@classmethod
274+
def setUpClass(cls):
275+
super().setUpClass()
276+
cls.course = CourseFactory.create()
277+
278+
def setUp(self):
279+
super().setUp()
280+
self.client = APIClient()
281+
self.instructor = InstructorFactory.create(course_key=self.course.id)
282+
self.student = UserFactory.create()
283+
self.url = reverse(
284+
'instructor_api_v2:bulk_certificate_exceptions',
285+
kwargs={'course_id': str(self.course.id)}
286+
)
287+
288+
def _create_csv_file(self, content):
289+
"""Helper to create a CSV file upload."""
290+
csv_file = BytesIO(content.encode('utf-8'))
291+
csv_file.name = 'test.csv'
292+
return csv_file
293+
294+
def test_permission_required(self):
295+
"""Test that only instructors can upload bulk exceptions."""
296+
self.client.force_authenticate(user=self.student)
297+
csv_file = self._create_csv_file('user1,notes1')
298+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
299+
assert response.status_code == status.HTTP_403_FORBIDDEN
300+
301+
def test_no_file_uploaded(self):
302+
"""Test error when no file is uploaded."""
303+
self.client.force_authenticate(user=self.instructor)
304+
response = self.client.post(self.url, {}, format='multipart')
305+
assert response.status_code == status.HTTP_400_BAD_REQUEST
306+
assert 'No file uploaded' in response.data['message']
307+
308+
def test_non_csv_file_type(self):
309+
"""Test error when uploaded file is not CSV."""
310+
self.client.force_authenticate(user=self.instructor)
311+
txt_file = BytesIO(b'user1,notes1')
312+
txt_file.name = 'test.txt'
313+
response = self.client.post(self.url, {'file': txt_file}, format='multipart')
314+
assert response.status_code == status.HTTP_400_BAD_REQUEST
315+
assert 'CSV format' in response.data['message']
316+
317+
def test_empty_csv(self):
318+
"""Test error when CSV file is empty."""
319+
self.client.force_authenticate(user=self.instructor)
320+
csv_file = self._create_csv_file('')
321+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
322+
assert response.status_code == status.HTTP_400_BAD_REQUEST
323+
assert 'empty' in response.data['message']
324+
325+
def test_csv_with_only_empty_rows(self):
326+
"""Test error when CSV contains only empty rows."""
327+
self.client.force_authenticate(user=self.instructor)
328+
csv_file = self._create_csv_file('\n\n \n')
329+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
330+
assert response.status_code == status.HTTP_400_BAD_REQUEST
331+
assert 'empty' in response.data['message']
332+
333+
@patch('lms.djangoapps.instructor.views.api_v2.certs_api.create_or_update_certificate_allowlist_entry')
334+
def test_happy_path_csv(self, mock_create):
335+
"""Test successful bulk upload with valid CSV."""
336+
student1 = UserFactory.create(username='student1')
337+
student2 = UserFactory.create(username='student2', email='[email protected]')
338+
CourseEnrollmentFactory.create(user=student1, course_id=self.course.id)
339+
CourseEnrollmentFactory.create(user=student2, course_id=self.course.id)
340+
341+
self.client.force_authenticate(user=self.instructor)
342+
csv_content = 'student1,First student notes\n[email protected],Second student notes'
343+
csv_file = self._create_csv_file(csv_content)
344+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
345+
346+
assert response.status_code == status.HTTP_200_OK
347+
assert len(response.data['success']) == 2
348+
assert 'student1' in response.data['success']
349+
assert '[email protected]' in response.data['success']
350+
assert len(response.data['errors']) == 0
351+
assert mock_create.call_count == 2
352+
353+
@patch('lms.djangoapps.instructor.views.api_v2.certs_api.create_or_update_certificate_allowlist_entry')
354+
def test_csv_without_notes_column(self, mock_create):
355+
"""Test CSV with only username column (no notes)."""
356+
student1 = UserFactory.create(username='student1')
357+
CourseEnrollmentFactory.create(user=student1, course_id=self.course.id)
358+
359+
self.client.force_authenticate(user=self.instructor)
360+
csv_content = 'student1'
361+
csv_file = self._create_csv_file(csv_content)
362+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
363+
364+
assert response.status_code == status.HTTP_200_OK
365+
assert len(response.data['success']) == 1
366+
# Verify empty notes were passed
367+
call_args = mock_create.call_args
368+
assert call_args[0][2] == '' # notes parameter
369+
370+
def test_unresolvable_learners(self):
371+
"""Test error handling for users that don't exist."""
372+
self.client.force_authenticate(user=self.instructor)
373+
csv_content = 'nonexistent1,notes1\nnonexistent2,notes2'
374+
csv_file = self._create_csv_file(csv_content)
375+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
376+
377+
assert response.status_code == status.HTTP_200_OK
378+
assert len(response.data['success']) == 0
379+
assert len(response.data['errors']) == 2
380+
assert any('nonexistent1' in str(err) for err in response.data['errors'])
381+
assert any('nonexistent2' in str(err) for err in response.data['errors'])
382+
383+
@patch('lms.djangoapps.instructor.views.api_v2.certs_api.create_or_update_certificate_allowlist_entry')
384+
def test_partial_success(self, mock_create):
385+
"""Test mix of valid and invalid learners in CSV."""
386+
student1 = UserFactory.create(username='valid_user')
387+
CourseEnrollmentFactory.create(user=student1, course_id=self.course.id)
388+
389+
self.client.force_authenticate(user=self.instructor)
390+
csv_content = 'valid_user,Valid notes\ninvalid_user,Invalid notes'
391+
csv_file = self._create_csv_file(csv_content)
392+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
393+
394+
assert response.status_code == status.HTTP_200_OK
395+
assert len(response.data['success']) == 1
396+
assert 'valid_user' in response.data['success']
397+
assert len(response.data['errors']) == 1
398+
assert any('invalid_user' in str(err) for err in response.data['errors'])
399+
mock_create.assert_called_once()
400+
401+
@patch('lms.djangoapps.instructor.views.api_v2.certs_api.create_or_update_certificate_allowlist_entry')
402+
def test_duplicate_csv_identifiers(self, mock_create):
403+
"""Test that duplicate identifiers use last occurrence's notes."""
404+
student1 = UserFactory.create(username='student1')
405+
CourseEnrollmentFactory.create(user=student1, course_id=self.course.id)
406+
407+
self.client.force_authenticate(user=self.instructor)
408+
# Same identifier twice with different notes
409+
csv_content = 'student1,First notes\nstudent1,Last notes'
410+
csv_file = self._create_csv_file(csv_content)
411+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
412+
413+
assert response.status_code == status.HTTP_200_OK
414+
assert len(response.data['success']) == 1
415+
# Verify the last notes value was used (dict behavior)
416+
call_args = mock_create.call_args
417+
assert call_args[0][2] == 'Last notes'
418+
419+
@patch('lms.djangoapps.instructor.views.api_v2.certs_api.create_or_update_certificate_allowlist_entry')
420+
def test_csv_with_empty_notes(self, mock_create):
421+
"""Test CSV rows with empty notes column."""
422+
student1 = UserFactory.create(username='student1')
423+
CourseEnrollmentFactory.create(user=student1, course_id=self.course.id)
424+
425+
self.client.force_authenticate(user=self.instructor)
426+
csv_content = 'student1,'
427+
csv_file = self._create_csv_file(csv_content)
428+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
429+
430+
assert response.status_code == status.HTTP_200_OK
431+
assert len(response.data['success']) == 1
432+
call_args = mock_create.call_args
433+
assert call_args[0][2] == ''
434+
435+
@patch('lms.djangoapps.instructor.views.api_v2.certs_api.create_or_update_certificate_allowlist_entry')
436+
def test_unenrolled_learner(self, mock_create):
437+
"""Test error when learner exists but is not enrolled in course."""
438+
UserFactory.create(username='unenrolled')
439+
# Don't enroll the student
440+
441+
self.client.force_authenticate(user=self.instructor)
442+
csv_content = 'unenrolled,notes'
443+
csv_file = self._create_csv_file(csv_content)
444+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
445+
446+
assert response.status_code == status.HTTP_200_OK
447+
assert len(response.data['success']) == 0
448+
assert len(response.data['errors']) == 1
449+
assert 'not enrolled' in response.data['errors'][0]['message']
450+
mock_create.assert_not_called()
451+
452+
@patch('lms.djangoapps.instructor.views.api_v2.certs_api.create_or_update_certificate_allowlist_entry')
453+
def test_learner_with_active_invalidation(self, mock_create):
454+
"""Test error when learner has an active certificate invalidation."""
455+
student1 = UserFactory.create(username='invalidated')
456+
CourseEnrollmentFactory.create(user=student1, course_id=self.course.id)
457+
cert = GeneratedCertificateFactory.create(
458+
user=student1,
459+
course_id=self.course.id,
460+
status=CertificateStatuses.unavailable
461+
)
462+
CertificateInvalidation.objects.create(
463+
generated_certificate=cert,
464+
invalidated_by=self.instructor,
465+
notes='Test invalidation',
466+
active=True
467+
)
468+
469+
self.client.force_authenticate(user=self.instructor)
470+
csv_content = 'invalidated,notes'
471+
csv_file = self._create_csv_file(csv_content)
472+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
473+
474+
assert response.status_code == status.HTTP_200_OK
475+
assert len(response.data['success']) == 0
476+
assert len(response.data['errors']) == 1
477+
assert 'invalidation' in response.data['errors'][0]['message']
478+
mock_create.assert_not_called()
479+
480+
@patch('lms.djangoapps.instructor.views.api_v2.certs_api.create_or_update_certificate_allowlist_entry')
481+
def test_csv_with_utf8_bom(self, mock_create):
482+
"""Test CSV file with UTF-8 BOM is handled correctly."""
483+
student1 = UserFactory.create(username='student1')
484+
CourseEnrollmentFactory.create(user=student1, course_id=self.course.id)
485+
486+
self.client.force_authenticate(user=self.instructor)
487+
# UTF-8 BOM + CSV content
488+
csv_content = '\ufeffstudent1,notes'
489+
csv_file = self._create_csv_file(csv_content)
490+
response = self.client.post(self.url, {'file': csv_file}, format='multipart')
491+
492+
assert response.status_code == status.HTTP_200_OK
493+
assert len(response.data['success']) == 1
494+
mock_create.assert_called_once()
495+
496+
269497
class CertificateInvalidationsViewTest(SharedModuleStoreTestCase):
270498
"""Tests for CertificateInvalidationsView."""
271499

lms/djangoapps/instructor/views/api_urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@
9696
api_v2.CertificateExceptionsView.as_view(),
9797
name='certificate_exceptions'
9898
),
99+
re_path(
100+
rf'^courses/{COURSE_ID_PATTERN}/certificates/exceptions/bulk$',
101+
api_v2.BulkCertificateExceptionsView.as_view(),
102+
name='bulk_certificate_exceptions'
103+
),
99104
re_path(
100105
rf'^courses/{COURSE_ID_PATTERN}/certificates/invalidations$',
101106
api_v2.CertificateInvalidationsView.as_view(),

lms/djangoapps/instructor/views/api_v2.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,6 +2120,120 @@ def _validate_certificates_for_invalidation(learner_to_user, course_key):
21202120
return certificates_to_invalidate, errors
21212121

21222122

2123+
class BulkCertificateExceptionsView(DeveloperErrorViewMixin, APIView):
2124+
"""
2125+
View to grant certificate exceptions via CSV upload.
2126+
2127+
**Example Requests**
2128+
2129+
POST /api/instructor/v2/courses/{course_id}/certificates/exceptions/bulk
2130+
2131+
**POST Request Body**
2132+
2133+
Form data with CSV file uploaded as 'file' field.
2134+
CSV format: username_or_email,notes (optional second column)
2135+
2136+
**Returns**
2137+
2138+
* 200: OK - Bulk exceptions processed with success/error details
2139+
* 400: Bad Request - Invalid CSV file or format
2140+
* 401: Unauthorized - User is not authenticated
2141+
* 403: Forbidden - User lacks instructor permissions
2142+
"""
2143+
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
2144+
permission_name = permissions.CERTIFICATE_EXCEPTION_VIEW
2145+
2146+
def post(self, request, course_id):
2147+
"""Grant certificate exceptions via CSV upload."""
2148+
course_key = CourseKey.from_string(course_id)
2149+
# Validate that the course exists
2150+
get_course_by_id(course_key)
2151+
2152+
# Check if file was uploaded
2153+
if 'file' not in request.FILES:
2154+
return Response(
2155+
{'message': _('No file uploaded')},
2156+
status=status.HTTP_400_BAD_REQUEST
2157+
)
2158+
2159+
uploaded_file = request.FILES['file']
2160+
2161+
# Validate file type
2162+
if not uploaded_file.name.endswith('.csv'):
2163+
return Response(
2164+
{'message': _('File must be in CSV format')},
2165+
status=status.HTTP_400_BAD_REQUEST
2166+
)
2167+
2168+
results = {
2169+
'success': [],
2170+
'errors': []
2171+
}
2172+
2173+
try:
2174+
file_content = uploaded_file.read().decode('utf-8-sig')
2175+
csv_reader = list(csv.reader(file_content.splitlines()))
2176+
except (UnicodeDecodeError, csv.Error) as exc:
2177+
log.exception("Error processing CSV file for certificate exceptions")
2178+
return Response(
2179+
{'message': _('Error processing CSV file: {error}').format(error=str(exc))},
2180+
status=status.HTTP_400_BAD_REQUEST
2181+
)
2182+
2183+
learners_with_notes = []
2184+
for row in csv_reader:
2185+
if not row or not row[0].strip():
2186+
continue # Skip empty rows
2187+
2188+
learner = row[0].strip()
2189+
notes = row[1].strip() if len(row) > 1 and row[1].strip() else ''
2190+
2191+
learners_with_notes.append((learner, notes))
2192+
2193+
if not learners_with_notes:
2194+
return Response(
2195+
{'message': _('CSV file is empty or contains no valid entries')},
2196+
status=status.HTTP_400_BAD_REQUEST
2197+
)
2198+
2199+
# Extract learners for resolution and build a notes lookup
2200+
learners = [learner for learner, _ in learners_with_notes]
2201+
notes_by_learner = dict(learners_with_notes)
2202+
2203+
# Resolve all usernames/emails to users upfront
2204+
learner_to_user, user_errors = _resolve_learners_to_users(learners)
2205+
results['errors'].extend(user_errors)
2206+
2207+
# Validate learners for certificate exceptions
2208+
exceptions_to_create, validation_errors = _validate_learners_for_certificate_exceptions(
2209+
learner_to_user, course_key
2210+
)
2211+
results['errors'].extend(validation_errors)
2212+
2213+
# Create all exceptions using the certificates API
2214+
for learner, user in exceptions_to_create:
2215+
notes = notes_by_learner.get(learner, '')
2216+
2217+
try:
2218+
certs_api.create_or_update_certificate_allowlist_entry(user, course_key, notes)
2219+
log.info(
2220+
"Certificate exception granted for user %s (%s) in course %s by %s via CSV upload",
2221+
user.id, learner, course_key, request.user.username
2222+
)
2223+
results['success'].append(learner)
2224+
except Exception as exc: # pylint: disable=broad-except
2225+
log.exception(
2226+
"Error creating certificate exception for user %s in course %s",
2227+
user.id, course_key
2228+
)
2229+
results['errors'].append({
2230+
'learner': learner,
2231+
'message': str(exc)
2232+
})
2233+
2234+
return Response(results, status=status.HTTP_200_OK)
2235+
2236+
21232237
class CertificateInvalidationsView(DeveloperErrorViewMixin, APIView):
21242238
"""
21252239
View to invalidate or re-validate certificates.

0 commit comments

Comments
 (0)