Skip to content

Commit b2a9997

Browse files
fix: PR feedback
1 parent 472329e commit b2a9997

2 files changed

Lines changed: 231 additions & 3 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+
student1 = 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_v2.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,8 +2188,9 @@ def post(self, request, course_id):
21882188
status=status.HTTP_400_BAD_REQUEST
21892189
)
21902190

2191-
# Extract just the learners for resolution
2191+
# Extract learners for resolution and build a notes lookup
21922192
learners = [learner for learner, _ in learners_with_notes]
2193+
notes_by_learner = dict(learners_with_notes)
21932194

21942195
# Resolve all usernames/emails to users upfront
21952196
learner_to_user, user_errors = _resolve_learners_to_users(learners)
@@ -2203,8 +2204,7 @@ def post(self, request, course_id):
22032204

22042205
# Create all exceptions using the certificates API
22052206
for learner, user in exceptions_to_create:
2206-
# Find the notes for this learner
2207-
notes = next((n for l, n in learners_with_notes if l == learner), '')
2207+
notes = notes_by_learner.get(learner, '')
22082208

22092209
try:
22102210
certs_api.create_or_update_certificate_allowlist_entry(user, course_key, notes)

0 commit comments

Comments
 (0)