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
271 changes: 251 additions & 20 deletions lms/djangoapps/instructor/tests/views/test_api_v2.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""
Tests for Instructor API v2 GET endpoints.
Tests for Instructor API v2 endpoints.
"""
import json
from unittest.mock import MagicMock, patch
from uuid import uuid4

from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient

from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import InstructorFactory, UserFactory
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.instructor_task.models import InstructorTask
Expand Down Expand Up @@ -364,41 +366,270 @@ def setUp(self):
self.client.force_authenticate(user=self.instructor)

def test_get_grading_config(self):
"""Test retrieving grading configuration returns graders and grade cutoffs"""
"""Test retrieving grading configuration returns HTML summary from dump_grading_context"""
url = reverse('instructor_api_v2:grading_config', kwargs={
'course_id': str(self.course.id),
})
response = self.client.get(url)

self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009
data = response.json()
self.assertIn('graders', data) # noqa: PT009
self.assertIn('grade_cutoffs', data) # noqa: PT009
self.assertIsInstance(data['graders'], list) # noqa: PT009
self.assertIsInstance(data['grade_cutoffs'], dict) # noqa: PT009
self.assertEqual(response['Content-Type'], 'text/html') # noqa: PT009

hbar = '-' * 77
expected_html = (
f'<pre>{hbar}\n'
'Course grader:\n'
'&lt;class &#39;xmodule.graders.WeightedSubsectionsGrader&#39;&gt;\n'
'\n'
'Graded sections:\n'
' subgrader=&lt;class &#39;xmodule.graders.AssignmentFormatGrader&#39;&gt;,'
' type=Homework, category=Homework, weight=0.15\n'
' subgrader=&lt;class &#39;xmodule.graders.AssignmentFormatGrader&#39;&gt;,'
' type=Lab, category=Lab, weight=0.15\n'
' subgrader=&lt;class &#39;xmodule.graders.AssignmentFormatGrader&#39;&gt;,'
' type=Midterm Exam, category=Midterm Exam, weight=0.3\n'
' subgrader=&lt;class &#39;xmodule.graders.AssignmentFormatGrader&#39;&gt;,'
' type=Final Exam, category=Final Exam, weight=0.4\n'
f'{hbar}\n'
f'Listing grading context for course {self.course.id}\n'
'graded sections:\n'
'[]\n'
'all graded blocks:\n'
'length=0\n'
'</pre>'
)
self.assertEqual(response.content.decode(), expected_html) # noqa: PT009

def test_get_grading_config_requires_authentication(self):
"""Test that endpoint requires authentication"""
self.client.force_authenticate(user=None)

def test_get_grading_config_grader_fields(self):
"""Test that each grader entry has the expected fields"""
url = reverse('instructor_api_v2:grading_config', kwargs={
'course_id': str(self.course.id),
})
response = self.client.get(url)

self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) # noqa: PT009


class GradingEndpointTestBase(ModuleStoreTestCase):
"""
Base test class for grading endpoints with real course structures,
real permissions, and real StudentModule records.
"""

def setUp(self):
super().setUp()
self.client = APIClient()
self.course = CourseFactory.create(display_name='Test Course')
self.chapter = BlockFactory.create(
parent=self.course,
category='chapter',
display_name='Week 1'
)
self.sequential = BlockFactory.create(
parent=self.chapter,
category='sequential',
display_name='Homework 1'
)
self.problem = BlockFactory.create(
parent=self.sequential,
category='problem',
display_name='Test Problem'
)

# Real instructor with real course permissions
self.instructor = InstructorFactory(course_key=self.course.id)
self.client.force_authenticate(user=self.instructor)

# Real enrolled student with real module state
self.student = UserFactory(username='test_student', email='student@example.com')
CourseEnrollment.enroll(self.student, self.course.id)
self.student_module = StudentModule.objects.create(
student=self.student,
course_id=self.course.id,
module_state_key=self.problem.location,
state=json.dumps({'attempts': 10}),
)


class ResetAttemptsViewTestCase(GradingEndpointTestBase):
"""
Tests for POST /api/instructor/v2/courses/{course_key}/{problem}/grading/attempts/reset
"""

def _get_url(self, problem=None):
return reverse('instructor_api_v2:reset_attempts', kwargs={
'course_id': str(self.course.id),
'problem': problem or str(self.problem.location),
})

def test_reset_single_learner(self):
"""Single learner reset zeroes attempt count and returns 200."""
response = self.client.post(self._get_url() + '?learner=test_student')
self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009
data = response.json()
for grader in data['graders']:
self.assertIn('type', grader) # noqa: PT009
self.assertIn('min_count', grader) # noqa: PT009
self.assertIn('drop_count', grader) # noqa: PT009
self.assertIn('weight', grader) # noqa: PT009
self.assertTrue(data['success']) # noqa: PT009
self.assertEqual(data['learner'], 'test_student') # noqa: PT009
self.assertEqual(data['message'], 'Attempts reset successfully') # noqa: PT009

# Verify the actual StudentModule was modified
self.student_module.refresh_from_db()
self.assertEqual(json.loads(self.student_module.state)['attempts'], 0) # noqa: PT009

@patch('lms.djangoapps.instructor_task.api.submit_reset_problem_attempts_for_all_students')
def test_reset_all_learners(self, mock_submit):
"""Bulk reset queues a background task and returns 202."""
mock_task = MagicMock()
mock_task.task_id = str(uuid4())
mock_submit.return_value = mock_task

response = self.client.post(self._get_url())
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009
data = response.json()
self.assertEqual(data['task_id'], mock_task.task_id) # noqa: PT009
self.assertIn('status_url', data) # noqa: PT009
self.assertEqual(data['scope']['learners'], 'all') # noqa: PT009
mock_submit.assert_called_once()

def test_get_grading_config_requires_authentication(self):
"""Test that endpoint requires authentication"""
self.client.force_authenticate(user=None)

url = reverse('instructor_api_v2:grading_config', kwargs={
class DeleteStateViewTestCase(GradingEndpointTestBase):
"""
Tests for DELETE /api/instructor/v2/courses/{course_key}/{problem}/grading/state
"""

def _get_url(self, problem=None):
return reverse('instructor_api_v2:delete_state', kwargs={
'course_id': str(self.course.id),
'problem': problem or str(self.problem.location),
})
response = self.client.get(url)

self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) # noqa: PT009
@patch('lms.djangoapps.grades.signals.handlers.PROBLEM_WEIGHTED_SCORE_CHANGED.send')
def test_delete_state(self, _mock_signal): # noqa: PT019
"""Delete state removes the StudentModule record and returns 200."""
response = self.client.delete(self._get_url() + '?learner=test_student')
self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009
data = response.json()
self.assertTrue(data['success']) # noqa: PT009
self.assertEqual(data['learner'], 'test_student') # noqa: PT009
self.assertEqual(data['message'], 'State deleted successfully') # noqa: PT009

# Verify the StudentModule was actually deleted
self.assertFalse( # noqa: PT009
StudentModule.objects.filter(pk=self.student_module.pk).exists()
)

def test_delete_state_requires_learner_param(self):
"""DELETE without learner query param returns 400."""
response = self.client.delete(self._get_url())
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009


class RescoreViewTestCase(GradingEndpointTestBase):
"""
Tests for POST /api/instructor/v2/courses/{course_key}/{problem}/grading/scores/rescore
"""

def _get_url(self, problem=None):
return reverse('instructor_api_v2:rescore', kwargs={
'course_id': str(self.course.id),
'problem': problem or str(self.problem.location),
})

@patch('lms.djangoapps.instructor_task.api.submit_rescore_problem_for_student')
def test_rescore_single_learner(self, mock_submit):
"""Single learner rescore queues a task and returns 202."""
mock_task = MagicMock()
mock_task.task_id = str(uuid4())
mock_submit.return_value = mock_task

response = self.client.post(self._get_url() + '?learner=test_student')
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009
data = response.json()
self.assertEqual(data['task_id'], mock_task.task_id) # noqa: PT009
self.assertEqual(data['scope']['learners'], 'test_student') # noqa: PT009
mock_submit.assert_called_once()
# Default only_if_higher should be False
self.assertFalse(mock_submit.call_args[0][3]) # noqa: PT009

@patch('lms.djangoapps.instructor_task.api.submit_rescore_problem_for_student')
def test_rescore_only_if_higher(self, mock_submit):
"""Rescore with only_if_higher=true passes the flag through."""
mock_task = MagicMock()
mock_task.task_id = str(uuid4())
mock_submit.return_value = mock_task

response = self.client.post(self._get_url() + '?learner=test_student&only_if_higher=true')
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009
self.assertTrue(mock_submit.call_args[0][3]) # noqa: PT009

@patch('lms.djangoapps.instructor_task.api.submit_rescore_problem_for_all_students')
def test_rescore_all_learners(self, mock_submit):
"""Bulk rescore queues a task and returns 202."""
mock_task = MagicMock()
mock_task.task_id = str(uuid4())
mock_submit.return_value = mock_task

response = self.client.post(self._get_url())
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009
data = response.json()
self.assertEqual(data['scope']['learners'], 'all') # noqa: PT009
mock_submit.assert_called_once()


class ScoreOverrideViewTestCase(GradingEndpointTestBase):
"""
Tests for PUT /api/instructor/v2/courses/{course_key}/{problem}/grading/scores
"""

def _get_url(self, problem=None):
return reverse('instructor_api_v2:score_override', kwargs={
'course_id': str(self.course.id),
'problem': problem or str(self.problem.location),
})

@patch('lms.djangoapps.instructor_task.api.submit_override_score')
def test_override_score(self, mock_submit):
"""Score override queues a task and returns 202."""
mock_task = MagicMock()
mock_task.task_id = str(uuid4())
mock_submit.return_value = mock_task

response = self.client.put(
self._get_url() + '?learner=test_student',
data={'score': 8.5},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009
data = response.json()
self.assertEqual(data['task_id'], mock_task.task_id) # noqa: PT009
self.assertEqual(data['scope']['learners'], 'test_student') # noqa: PT009
# Verify the score value was passed through
self.assertEqual(mock_submit.call_args[0][3], 8.5) # noqa: PT009

def test_override_requires_learner_param(self):
"""PUT without learner query param returns 400."""
response = self.client.put(
self._get_url(),
data={'score': 8.5},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009

def test_override_requires_score_in_body(self):
"""PUT without score in body returns 400."""
response = self.client.put(
self._get_url() + '?learner=test_student',
data={},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009

def test_override_rejects_negative_score(self):
"""PUT with negative score returns 400."""
response = self.client.put(
self._get_url() + '?learner=test_student',
data={'score': -1},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009
21 changes: 21 additions & 0 deletions lms/djangoapps/instructor/views/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@
api_v2.GradingConfigView.as_view(),
name='grading_config'
),
# Grading endpoints
re_path(
rf'^courses/{COURSE_ID_PATTERN}/(?P<problem>.+)/grading/attempts/reset$',
api_v2.ResetAttemptsView.as_view(),
name='reset_attempts'
),
re_path(
rf'^courses/{COURSE_ID_PATTERN}/(?P<problem>.+)/grading/state$',
api_v2.DeleteStateView.as_view(),
name='delete_state'
),
re_path(
rf'^courses/{COURSE_ID_PATTERN}/(?P<problem>.+)/grading/scores/rescore$',
api_v2.RescoreView.as_view(),
name='rescore'
),
re_path(
rf'^courses/{COURSE_ID_PATTERN}/(?P<problem>.+)/grading/scores$',
api_v2.ScoreOverrideView.as_view(),
name='score_override'
),
]

urlpatterns = [
Expand Down
Loading
Loading