Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,11 @@ definitions:
type: string
exam_id:
type: integer
exam_name:
type: string
exam_type:
type: string
enum: [timed, proctored, practice]
status:
type: string
start_time:
Expand All @@ -501,6 +506,8 @@ definitions:
allowed_time_limit_mins:
type: integer
x-nullable: true
ready_to_resume:
type: boolean

ProctoringSettings:
type: object
Expand Down
18 changes: 18 additions & 0 deletions lms/djangoapps/instructor/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4581,6 +4581,24 @@ def test_change_due_date_v2_success(self):

assert get_extended_due(self.course, self.homework, self.user1) == due_date

def test_change_due_date_v2_without_reason(self):
"""Test that reason is optional — both omitted and blank are accepted."""
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
base_payload = {
'email_or_username': self.user1.username,
'block_id': str(self.homework.location),
'due_datetime': '12/30/2013 00:00',
}
# Omitted reason
response = self.client.post(url, json.dumps(base_payload), content_type='application/json')
assert response.status_code == 200, response.content

# Blank reason
response = self.client.post(
url, json.dumps({**base_payload, 'reason': ''}), content_type='application/json'
)
assert response.status_code == 200, response.content

def test_change_due_date_v2_with_email(self):
"""Test due date change using email instead of username"""
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
Expand Down
172 changes: 166 additions & 6 deletions lms/djangoapps/instructor/tests/test_api_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""
import json
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
from unittest.mock import MagicMock, Mock, patch
from urllib.parse import urlencode
from uuid import uuid4

Expand All @@ -12,6 +12,7 @@
from django.contrib.auth import get_user_model
from django.http import Http404
from django.test import SimpleTestCase, override_settings
from django.test.client import Client as DjangoClient
from django.urls import NoReverseMatch, reverse
from edx_when.api import set_date_for_block, set_dates_for_course
from opaque_keys import InvalidKeyError
Expand Down Expand Up @@ -39,6 +40,7 @@
from lms.djangoapps.certificates.models import CertificateAllowlist, CertificateGenerationHistory
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin
from lms.djangoapps.instructor.access import ROLE_DISPLAY_NAMES
from lms.djangoapps.instructor.permissions import InstructorPermission
from lms.djangoapps.instructor.views.serializers_v2 import CourseInformationSerializerV2
Expand All @@ -50,7 +52,7 @@


@ddt.ddt
class CourseMetadataViewTest(SharedModuleStoreTestCase):
class CourseMetadataViewTest(SharedModuleStoreTestCase, MasqueradeMixin):
"""
Tests for the CourseMetadataView API endpoint.
"""
Expand Down Expand Up @@ -177,6 +179,9 @@ def test_get_course_metadata_as_instructor(self):
assert 'studio_grading_url' in data
assert 'admin_console_url' in data

# Verify current user's username is returned
assert data['username'] == self.instructor.username

assert data['studio_grading_url'] == f'http://localhost:2001/authoring/course/{self.course.id}/settings/grading'
assert data['admin_console_url'] == 'http://localhost:2025/admin-console/authz'

Expand Down Expand Up @@ -214,12 +219,13 @@ def test_get_course_metadata_as_staff(self):
self.client.force_authenticate(user=self.staff)
response = self.client.get(self._get_url())

self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009
assert response.status_code == status.HTTP_200_OK
data = response.data
self.assertEqual(data['course_id'], str(self.course_key)) # noqa: PT009
self.assertIn('permissions', data) # noqa: PT009
assert data['course_id'] == str(self.course_key)
assert 'permissions' in data
# Staff should have staff permission
self.assertTrue(data['permissions']['staff']) # noqa: PT009
assert data['permissions']['staff'] is True
assert data['username'] == self.staff.username

def test_get_course_metadata_unauthorized(self):
"""
Expand Down Expand Up @@ -625,6 +631,31 @@ def test_pacing_self_for_self_paced_course(self):
self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009
self.assertEqual(response.data['pacing'], 'self') # noqa: PT009

def test_masquerade_as_student_role_returns_403(self):
"""
Test that the endpoint returns 403 when a staff user masquerades as a student role.
"""
# Use Django's test Client for masquerade (MasqueradeMixin is incompatible with DRF APIClient)
original_client = self.client
self.client = DjangoClient()
self.client.login(username=self.staff.username, password='Password1234')
self.update_masquerade(course=self.course, role='student')
response = self.client.get(self._get_url())
self.client = original_client
assert response.status_code == status.HTTP_403_FORBIDDEN

def test_masquerade_as_specific_student_returns_403(self):
"""
Test that the endpoint returns 403 when a staff user masquerades as a specific student.
"""
original_client = self.client
self.client = DjangoClient()
self.client.login(username=self.staff.username, password='Password1234')
self.update_masquerade(course=self.course, username=self.student.username)
response = self.client.get(self._get_url())
self.client = original_client
assert response.status_code == status.HTTP_403_FORBIDDEN


class BuildTabUrlTest(SimpleTestCase):
"""
Expand Down Expand Up @@ -2180,6 +2211,135 @@ def test_granted_exceptions_without_certificates(self):
assert results['student2']['exception_notes'] == 'Special case'


class RegenerateCertificatesViewTest(SharedModuleStoreTestCase):
"""
Tests for the RegenerateCertificatesView API endpoint.
"""

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create(
org='edX',
number='TestX',
run='Test_Course',
display_name='Test Course',
)
cls.course_key = cls.course.id

def setUp(self):
super().setUp()
self.client = APIClient()
self.instructor = InstructorFactory.create(course_key=self.course_key)
self.student = UserFactory.create(username='student1', email='student1@example.com')

# Enroll student
CourseEnrollmentFactory.create(
user=self.student,
course_id=self.course_key,
mode='verified',
is_active=True
)

def _get_url(self, course_id=None):
"""Helper to get the API URL."""
if course_id is None:
course_id = str(self.course_key)
return reverse('instructor_api_v2:regenerate_certificates', kwargs={'course_id': course_id})

@patch('lms.djangoapps.instructor.views.api_v2.task_api.generate_certificates_for_students')
def test_allowlisted_not_generated_passes_correct_student_set(self, mock_generate_certs):
"""
Test that student_set='allowlisted_not_generated' is passed correctly to the task layer.

This test prevents future drift between the API layer and task layer if either
is renamed independently.
"""
# Mock the task API to return a fake InstructorTask
mock_task = MagicMock()
mock_task.task_id = 'test-task-id-123'
mock_generate_certs.return_value = mock_task

# Authenticate and make the request
self.client.force_authenticate(user=self.instructor)
response = self.client.post(
self._get_url(),
data={'student_set': 'allowlisted_not_generated'},
format='json'
)

# Assert the response is successful
assert response.status_code == status.HTTP_200_OK
assert response.data['task_id'] == 'test-task-id-123'

# Assert the task API was called with the correct parameters
# Expected call signature: generate_certificates_for_students(request, course_key, student_set=...)
mock_generate_certs.assert_called_once()
call_args = mock_generate_certs.call_args
_, course_key_arg = call_args.args[:2] # Unpack request and course_key positional args
assert course_key_arg == self.course_key
assert call_args.kwargs['student_set'] == 'allowlisted_not_generated'

@patch('lms.djangoapps.instructor.views.api_v2.task_api.generate_certificates_for_students')
def test_allowlisted_translates_to_all_allowlisted(self, mock_generate_certs):
"""
Test that student_set='allowlisted' is translated to 'all_allowlisted' for the task layer.

This preserves the legacy translation from the pre-allowlist "whitelist" naming era.
"""
# Mock the task API to return a fake InstructorTask
mock_task = MagicMock()
mock_task.task_id = 'test-task-id-456'
mock_generate_certs.return_value = mock_task

# Authenticate and make the request
self.client.force_authenticate(user=self.instructor)
response = self.client.post(
self._get_url(),
data={'student_set': 'allowlisted'},
format='json'
)

# Assert the response is successful
assert response.status_code == status.HTTP_200_OK

# Assert the task API was called with the translated value
mock_generate_certs.assert_called_once()
call_kwargs = mock_generate_certs.call_args.kwargs
assert call_kwargs['student_set'] == 'all_allowlisted'

@patch('lms.djangoapps.instructor.views.api_v2.task_api.generate_certificates_for_students')
def test_all_students_omits_student_set_kwarg(self, mock_generate_certs):
"""
Test that student_set='all' calls the task layer without a student_set kwarg.

This ensures the default behavior (generate for all enrolled students) is preserved.
"""
# Mock the task API to return a fake InstructorTask
mock_task = MagicMock()
mock_task.task_id = 'test-task-id-789'
mock_generate_certs.return_value = mock_task

# Authenticate and make the request with student_set='all'
self.client.force_authenticate(user=self.instructor)
response = self.client.post(
self._get_url(),
data={'student_set': 'all'},
format='json'
)

# Assert the response is successful
assert response.status_code == status.HTTP_200_OK

# Assert the task API was called without student_set kwarg
# Expected call signature: generate_certificates_for_students(request, course_key)
mock_generate_certs.assert_called_once()
call_args = mock_generate_certs.call_args
_, course_key_arg = call_args.args[:2] # Unpack request and course_key positional args
assert course_key_arg == self.course_key
assert 'student_set' not in call_args.kwargs


@ddt.ddt
class CertificateGenerationHistoryViewTest(SharedModuleStoreTestCase):
"""
Expand Down
Loading
Loading