From 5b772ad7c5e5308ebc3cb2791f3b909a49b93aa0 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Mon, 9 Feb 2026 15:29:28 -0700 Subject: [PATCH 1/8] feat: implement instructor API v2 grading POST endpoints --- .../instructor/tests/views/test_api_v2.py | 227 +++++++++- lms/djangoapps/instructor/views/api_urls.py | 21 + lms/djangoapps/instructor/views/api_v2.py | 401 +++++++++++++++++- .../instructor/views/serializers_v2.py | 58 +++ 4 files changed, 705 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/instructor/tests/views/test_api_v2.py b/lms/djangoapps/instructor/tests/views/test_api_v2.py index 8b6fc465d7ea..4d02f81ec451 100644 --- a/lms/djangoapps/instructor/tests/views/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/views/test_api_v2.py @@ -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 @@ -402,3 +404,226 @@ def test_get_grading_config_requires_authentication(self): 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) + data = response.json() + self.assertTrue(data['success']) + self.assertEqual(data['learner'], 'test_student') + self.assertEqual(data['message'], 'Attempts reset successfully') + + # Verify the actual StudentModule was modified + self.student_module.refresh_from_db() + self.assertEqual(json.loads(self.student_module.state)['attempts'], 0) + + @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) + data = response.json() + self.assertEqual(data['task_id'], mock_task.task_id) + self.assertIn('status_url', data) + self.assertEqual(data['scope']['learners'], 'all') + mock_submit.assert_called_once() + + +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), + }) + + @patch('lms.djangoapps.grades.signals.handlers.PROBLEM_WEIGHTED_SCORE_CHANGED.send') + def test_delete_state(self, _mock_signal): + """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) + data = response.json() + self.assertTrue(data['success']) + self.assertEqual(data['learner'], 'test_student') + self.assertEqual(data['message'], 'State deleted successfully') + + # Verify the StudentModule was actually deleted + self.assertFalse( + 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) + + +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) + data = response.json() + self.assertEqual(data['task_id'], mock_task.task_id) + self.assertEqual(data['scope']['learners'], 'test_student') + mock_submit.assert_called_once() + # Default only_if_higher should be False + self.assertFalse(mock_submit.call_args[0][3]) + + @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) + self.assertTrue(mock_submit.call_args[0][3]) + + @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) + data = response.json() + self.assertEqual(data['scope']['learners'], 'all') + 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) + data = response.json() + self.assertEqual(data['task_id'], mock_task.task_id) + self.assertEqual(data['scope']['learners'], 'test_student') + # Verify the score value was passed through + self.assertEqual(mock_submit.call_args[0][3], 8.5) + + 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) + + 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) + + 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) diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index bc7548863368..621b0a9f3b7c 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -136,6 +136,27 @@ api_v2.CourseTeamView.as_view(), name='course_team' ), + # Grading endpoints + re_path( + rf'^courses/{COURSE_ID_PATTERN}/(?P.+)/grading/attempts/reset$', + api_v2.ResetAttemptsView.as_view(), + name='reset_attempts' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/(?P.+)/grading/state$', + api_v2.DeleteStateView.as_view(), + name='delete_state' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/(?P.+)/grading/scores/rescore$', + api_v2.RescoreView.as_view(), + name='rescore' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/(?P.+)/grading/scores$', + api_v2.ScoreOverrideView.as_view(), + name='score_override' + ), ] urlpatterns = [ diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 1473aaa06df6..1599998b8c32 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -38,6 +38,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from submissions import api as sub_api from common.djangoapps.student.api import is_user_enrolled_in_course from common.djangoapps.student.models import ( @@ -64,9 +65,11 @@ GeneratedCertificate, ) from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active +from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.courseware.tabs import get_course_tab_list -from lms.djangoapps.instructor import permissions +from lms.djangoapps.instructor import enrollment, permissions from lms.djangoapps.instructor.access import ( FORUM_ROLES, ROLE_DISPLAY_NAMES, @@ -105,6 +108,7 @@ from .filters_v2 import CourseEnrollmentFilter from .serializers_v2 import ( + AsyncOperationResultSerializer, BetaTesterModifyRequestSerializerV2, BetaTesterModifyResponseSerializerV2, BlockDueDateSerializerV2, @@ -123,6 +127,8 @@ ORASummarySerializer, ProblemSerializer, RegenerateCertificatesSerializer, + ScoreOverrideRequestSerializer, + SyncOperationResultSerializer, TaskStatusSerializer, UnitExtensionSerializer, ) @@ -1883,6 +1889,7 @@ def get(self, request, course_id, location): except StudentModule.DoesNotExist: pass # Leave current_score and attempts as None + serializer = ProblemSerializer(problem_data) return Response(serializer.data, status=status.HTTP_200_OK) @@ -2753,3 +2760,395 @@ def delete(self, request, course_id, email_or_username): 'action': 'revoke', 'success': True, }, status=status.HTTP_200_OK) + + +def _parse_course_and_problem(course_id, problem): + """ + Parse and validate course_id and problem location strings. + + Returns (course_key, usage_key) tuple on success. + Returns (None, Response) if validation fails — caller should return the Response. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return None, Response( + {'error': 'INVALID_PARAMETER', 'message': 'Invalid course key format', 'status_code': 400}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + usage_key = UsageKey.from_string(problem).map_into_course(course_key) + except InvalidKeyError: + return None, Response( + {'error': 'INVALID_PARAMETER', 'message': 'Invalid problem location', 'status_code': 400}, + status=status.HTTP_400_BAD_REQUEST + ) + + return (course_key, usage_key), None + + +def _resolve_learner(learner_identifier): + """ + Resolve a learner identifier (username or email) to a User object. + + Returns User on success, or a Response with 404 on failure. + """ + try: + return get_student_from_identifier(learner_identifier), None + except Exception: # pylint: disable=broad-except + return None, Response( + {'error': 'RESOURCE_NOT_FOUND', 'message': 'Learner not found', 'status_code': 404}, + status=status.HTTP_404_NOT_FOUND + ) + + +def _build_async_response(instructor_task, course_id, problem_location, learner_scope='all'): + """ + Build a 202 Accepted response for an async task. + """ + task_id = str(instructor_task.task_id) + status_url = reverse( + 'instructor_api_v2:task_status', + kwargs={'course_id': course_id, 'task_id': task_id} + ) + data = { + 'task_id': task_id, + 'status_url': status_url, + 'scope': { + 'learners': learner_scope, + 'problem_location': str(problem_location), + } + } + serializer = AsyncOperationResultSerializer(data) + return Response(serializer.data, status=status.HTTP_202_ACCEPTED) + + +class ResetAttemptsView(DeveloperErrorViewMixin, APIView): + """ + Reset problem attempts for a learner or all learners. + + **POST** with `learner` query param: resets a single learner's attempts (synchronous). + **POST** without `learner`: queues a background task to reset all learners (asynchronous). + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION + + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id, problem): + """Reset problem attempts for one or all learners.""" + parsed, error_response = _parse_course_and_problem(course_id, problem) + if error_response: + return error_response + course_key, usage_key = parsed + + learner_identifier = request.query_params.get('learner') + + if learner_identifier: + student, error_response = _resolve_learner(learner_identifier) + if error_response: + return error_response + + try: + enrollment.reset_student_attempts( + course_key, + student, + usage_key, + requesting_user=request.user, + delete_module=False, + ) + except StudentModule.DoesNotExist: + return Response( + {'error': 'RESOURCE_NOT_FOUND', + 'message': 'No state found for this learner and problem', + 'status_code': 404}, + status=status.HTTP_404_NOT_FOUND, + ) + except sub_api.SubmissionError: + return Response( + {'error': 'INTERNAL_ERROR', + 'message': 'An error occurred while resetting attempts', + 'status_code': 500}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + data = { + 'success': True, + 'learner': student.username, + 'problem_location': str(usage_key), + 'message': 'Attempts reset successfully', + } + serializer = SyncOperationResultSerializer(data) + return Response(serializer.data, status=status.HTTP_200_OK) + + else: + course = get_course_with_access(request.user, 'staff', course_key, depth=None) + if not has_access(request.user, 'instructor', course): + return Response( + {'error': 'PERMISSION_DENIED', + 'message': 'Instructor access required for bulk operations', + 'status_code': 403}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + instructor_task = task_api.submit_reset_problem_attempts_for_all_students( + request, usage_key + ) + except AlreadyRunningError: + return Response( + {'error': 'ALREADY_RUNNING', + 'message': 'A reset task is already running for this problem', + 'status_code': 409}, + status=status.HTTP_409_CONFLICT, + ) + except ItemNotFoundError: + return Response( + {'error': 'RESOURCE_NOT_FOUND', + 'message': 'Problem not found', + 'status_code': 404}, + status=status.HTTP_404_NOT_FOUND, + ) + + return _build_async_response(instructor_task, course_id, usage_key) + + +class DeleteStateView(DeveloperErrorViewMixin, APIView): + """ + Delete a learner's problem state permanently. + + The `learner` query parameter is required. This operation is destructive + and cannot be undone. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION + + @method_decorator(transaction.non_atomic_requests) + def delete(self, request, course_id, problem): + """Delete learner problem state.""" + parsed, error_response = _parse_course_and_problem(course_id, problem) + if error_response: + return error_response + course_key, usage_key = parsed + + learner_identifier = request.query_params.get('learner') + if not learner_identifier: + return Response( + {'error': 'INVALID_PARAMETER', + 'message': 'The learner parameter is required', + 'status_code': 400}, + status=status.HTTP_400_BAD_REQUEST, + ) + + student, error_response = _resolve_learner(learner_identifier) + if error_response: + return error_response + + try: + enrollment.reset_student_attempts( + course_key, + student, + usage_key, + requesting_user=request.user, + delete_module=True, + ) + except StudentModule.DoesNotExist: + return Response( + {'error': 'RESOURCE_NOT_FOUND', + 'message': 'No state found for this learner and problem', + 'status_code': 404}, + status=status.HTTP_404_NOT_FOUND, + ) + except sub_api.SubmissionError: + return Response( + {'error': 'INTERNAL_ERROR', + 'message': 'An error occurred while deleting state', + 'status_code': 500}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + data = { + 'success': True, + 'learner': student.username, + 'problem_location': str(usage_key), + 'message': 'State deleted successfully', + } + serializer = SyncOperationResultSerializer(data) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class RescoreView(DeveloperErrorViewMixin, APIView): + """ + Rescore problem submissions for a learner or all learners. + + **POST** with `learner` query param: rescores a single learner (asynchronous task). + **POST** without `learner`: rescores all learners (asynchronous task). + + Optionally accepts `only_if_higher=true` query param to only update if new score is higher. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.OVERRIDE_GRADES + + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id, problem): + """Rescore problem submissions.""" + parsed, error_response = _parse_course_and_problem(course_id, problem) + if error_response: + return error_response + course_key, usage_key = parsed + + only_if_higher = request.query_params.get('only_if_higher', 'false').lower() == 'true' + learner_identifier = request.query_params.get('learner') + + if learner_identifier: + student, error_response = _resolve_learner(learner_identifier) + if error_response: + return error_response + + try: + instructor_task = task_api.submit_rescore_problem_for_student( + request, usage_key, student, only_if_higher, + ) + except NotImplementedError as exc: + return Response( + {'error': 'INVALID_PARAMETER', 'message': str(exc), 'status_code': 400}, + status=status.HTTP_400_BAD_REQUEST, + ) + except ItemNotFoundError: + return Response( + {'error': 'RESOURCE_NOT_FOUND', + 'message': 'Problem not found', + 'status_code': 404}, + status=status.HTTP_404_NOT_FOUND, + ) + except AlreadyRunningError: + return Response( + {'error': 'ALREADY_RUNNING', + 'message': 'A rescore task is already running for this learner and problem', + 'status_code': 409}, + status=status.HTTP_409_CONFLICT, + ) + + return _build_async_response( + instructor_task, course_id, usage_key, learner_scope=student.username + ) + + else: + course = get_course_with_access(request.user, 'staff', course_key) + if not has_access(request.user, 'instructor', course): + return Response( + {'error': 'PERMISSION_DENIED', + 'message': 'Instructor access required for bulk operations', + 'status_code': 403}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + instructor_task = task_api.submit_rescore_problem_for_all_students( + request, usage_key, only_if_higher, + ) + except NotImplementedError as exc: + return Response( + {'error': 'INVALID_PARAMETER', 'message': str(exc), 'status_code': 400}, + status=status.HTTP_400_BAD_REQUEST, + ) + except ItemNotFoundError: + return Response( + {'error': 'RESOURCE_NOT_FOUND', + 'message': 'Problem not found', + 'status_code': 404}, + status=status.HTTP_404_NOT_FOUND, + ) + except AlreadyRunningError: + return Response( + {'error': 'ALREADY_RUNNING', + 'message': 'A rescore task is already running for this problem', + 'status_code': 409}, + status=status.HTTP_409_CONFLICT, + ) + + return _build_async_response(instructor_task, course_id, usage_key) + + +class ScoreOverrideView(DeveloperErrorViewMixin, APIView): + """ + Override a learner's score for a specific problem. + + The `learner` query parameter is required. Accepts a JSON body with `score` field. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.OVERRIDE_GRADES + + @method_decorator(transaction.non_atomic_requests) + def put(self, request, course_id, problem): + """Override a learner's score.""" + parsed, error_response = _parse_course_and_problem(course_id, problem) + if error_response: + return error_response + course_key, usage_key = parsed + + learner_identifier = request.query_params.get('learner') + if not learner_identifier: + return Response( + {'error': 'INVALID_PARAMETER', + 'message': 'The learner parameter is required', + 'status_code': 400}, + status=status.HTTP_400_BAD_REQUEST, + ) + + student, error_response = _resolve_learner(learner_identifier) + if error_response: + return error_response + + body_serializer = ScoreOverrideRequestSerializer(data=request.data) + if not body_serializer.is_valid(): + return Response( + {'error': 'INVALID_PARAMETER', 'message': 'Invalid request body', + 'field_errors': body_serializer.errors, 'status_code': 400}, + status=status.HTTP_400_BAD_REQUEST, + ) + score = body_serializer.validated_data['score'] + + try: + block = modulestore().get_item(usage_key) + except ItemNotFoundError: + return Response( + {'error': 'RESOURCE_NOT_FOUND', + 'message': 'Problem not found', + 'status_code': 404}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not has_access(request.user, 'staff', block): + return Response( + {'error': 'PERMISSION_DENIED', + 'message': 'You do not have permission to override scores for this problem', + 'status_code': 403}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + instructor_task = task_api.submit_override_score( + request, usage_key, student, score, + ) + except NotImplementedError as exc: + return Response( + {'error': 'INVALID_PARAMETER', 'message': str(exc), 'status_code': 400}, + status=status.HTTP_400_BAD_REQUEST, + ) + except ValueError as exc: + return Response( + {'error': 'INVALID_PARAMETER', 'message': str(exc), 'status_code': 400}, + status=status.HTTP_400_BAD_REQUEST, + ) + except AlreadyRunningError: + return Response( + {'error': 'ALREADY_RUNNING', + 'message': 'A score override task is already running for this learner and problem', + 'status_code': 409}, + status=status.HTTP_409_CONFLICT, + ) + + return _build_async_response( + instructor_task, course_id, usage_key, learner_scope=student.username + ) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index b163869b4fc9..a6a6b2574e41 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -977,3 +977,61 @@ class CourseTeamRevokeSerializer(serializers.Serializer): allow_empty=False, help_text="One or more roles to revoke (course access role or forum role)" ) + + +class SyncOperationResultSerializer(serializers.Serializer): + """ + Serializer for synchronous grading operation results. + """ + success = serializers.BooleanField( + help_text="Whether the operation succeeded" + ) + learner = serializers.CharField( + allow_null=True, + required=False, + help_text="Learner identifier (if applicable)" + ) + problem_location = serializers.CharField( + allow_null=True, + required=False, + help_text="Problem location (if applicable)" + ) + score = serializers.FloatField( + allow_null=True, + required=False, + help_text="Updated score (for override operations)" + ) + previous_score = serializers.FloatField( + allow_null=True, + required=False, + help_text="Previous score (for override operations)" + ) + message = serializers.CharField( + help_text="Human-readable result message" + ) + + +class AsyncOperationResultSerializer(serializers.Serializer): + """ + Serializer for asynchronous grading operation results. + """ + task_id = serializers.CharField( + help_text="Unique task identifier" + ) + status_url = serializers.CharField( + help_text="URL to poll for task status" + ) + scope = serializers.DictField( + required=False, + help_text="Scope of the operation" + ) + + +class ScoreOverrideRequestSerializer(serializers.Serializer): + """ + Serializer for score override request body. + """ + score = serializers.FloatField( + min_value=0, + help_text="New score value (out of problem's total possible points)" + ) From afed81c2811ff786165f7b38642f7616f5ff9d60 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Wed, 11 Mar 2026 10:31:25 -0600 Subject: [PATCH 2/8] fix: code formatting --- lms/djangoapps/instructor/views/api_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 1599998b8c32..5a23b30529f2 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -1889,7 +1889,6 @@ def get(self, request, course_id, location): except StudentModule.DoesNotExist: pass # Leave current_score and attempts as None - serializer = ProblemSerializer(problem_data) return Response(serializer.data, status=status.HTTP_200_OK) @@ -2083,6 +2082,7 @@ def get(self, request, course_id): return Response(serializer.data, status=status.HTTP_200_OK) +<<<<<<< HEAD class EnrollmentModifyView(DeveloperErrorViewMixin, APIView): """ Enroll or unenroll one or more learners in a course. From db8aded8ac48e02633a7f89ac064c0bb6c77a97c Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Wed, 8 Apr 2026 09:56:34 -0600 Subject: [PATCH 3/8] fix: Copilot feedback --- lms/djangoapps/instructor/views/api_v2.py | 207 +++++++++++++++------- 1 file changed, 144 insertions(+), 63 deletions(-) diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 5a23b30529f2..c1114d3c8df2 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -2773,7 +2773,7 @@ def _parse_course_and_problem(course_id, problem): course_key = CourseKey.from_string(course_id) except InvalidKeyError: return None, Response( - {'error': 'INVALID_PARAMETER', 'message': 'Invalid course key format', 'status_code': 400}, + {'error': 'Invalid course key'}, status=status.HTTP_400_BAD_REQUEST ) @@ -2781,7 +2781,7 @@ def _parse_course_and_problem(course_id, problem): usage_key = UsageKey.from_string(problem).map_into_course(course_key) except InvalidKeyError: return None, Response( - {'error': 'INVALID_PARAMETER', 'message': 'Invalid problem location', 'status_code': 400}, + {'error': 'Invalid problem location'}, status=status.HTTP_400_BAD_REQUEST ) @@ -2792,15 +2792,21 @@ def _resolve_learner(learner_identifier): """ Resolve a learner identifier (username or email) to a User object. - Returns User on success, or a Response with 404 on failure. + Returns (User, None) on success, or (None, Response) on failure. """ + UserModel = get_user_model() try: - return get_student_from_identifier(learner_identifier), None - except Exception: # pylint: disable=broad-except + return get_user_by_username_or_email(learner_identifier), None + except UserModel.DoesNotExist: return None, Response( - {'error': 'RESOURCE_NOT_FOUND', 'message': 'Learner not found', 'status_code': 404}, + {'error': 'Learner not found'}, status=status.HTTP_404_NOT_FOUND ) + except UserModel.MultipleObjectsReturned: + return None, Response( + {'error': 'Multiple learners found for the given identifier'}, + status=status.HTTP_400_BAD_REQUEST + ) def _build_async_response(instructor_task, course_id, problem_location, learner_scope='all'): @@ -2834,6 +2840,33 @@ class ResetAttemptsView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.GIVE_STUDENT_EXTENSION + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.PATH, + description="Course key for the course.", + ), + apidocs.string_parameter( + 'problem', + apidocs.ParameterLocation.PATH, + description="Problem block usage key.", + ), + apidocs.string_parameter( + 'learner', + apidocs.ParameterLocation.QUERY, + description="Optional: Learner username or email. If omitted, resets all learners (async).", + ), + ], + responses={ + 200: SyncOperationResultSerializer, + 202: AsyncOperationResultSerializer, + 400: "Invalid parameters provided.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks permission.", + 404: "Learner not found.", + }, + ) @method_decorator(transaction.non_atomic_requests) def post(self, request, course_id, problem): """Reset problem attempts for one or all learners.""" @@ -2859,16 +2892,12 @@ def post(self, request, course_id, problem): ) except StudentModule.DoesNotExist: return Response( - {'error': 'RESOURCE_NOT_FOUND', - 'message': 'No state found for this learner and problem', - 'status_code': 404}, + {'error': 'No state found for this learner and problem'}, status=status.HTTP_404_NOT_FOUND, ) except sub_api.SubmissionError: return Response( - {'error': 'INTERNAL_ERROR', - 'message': 'An error occurred while resetting attempts', - 'status_code': 500}, + {'error': 'An error occurred while resetting attempts'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -2885,9 +2914,7 @@ def post(self, request, course_id, problem): course = get_course_with_access(request.user, 'staff', course_key, depth=None) if not has_access(request.user, 'instructor', course): return Response( - {'error': 'PERMISSION_DENIED', - 'message': 'Instructor access required for bulk operations', - 'status_code': 403}, + {'error': 'Instructor access required for bulk operations'}, status=status.HTTP_403_FORBIDDEN, ) @@ -2897,16 +2924,12 @@ def post(self, request, course_id, problem): ) except AlreadyRunningError: return Response( - {'error': 'ALREADY_RUNNING', - 'message': 'A reset task is already running for this problem', - 'status_code': 409}, + {'error': 'A reset task is already running for this problem'}, status=status.HTTP_409_CONFLICT, ) except ItemNotFoundError: return Response( - {'error': 'RESOURCE_NOT_FOUND', - 'message': 'Problem not found', - 'status_code': 404}, + {'error': 'Problem not found'}, status=status.HTTP_404_NOT_FOUND, ) @@ -2923,6 +2946,32 @@ class DeleteStateView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.GIVE_STUDENT_EXTENSION + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.PATH, + description="Course key for the course.", + ), + apidocs.string_parameter( + 'problem', + apidocs.ParameterLocation.PATH, + description="Problem block usage key.", + ), + apidocs.string_parameter( + 'learner', + apidocs.ParameterLocation.QUERY, + description="Learner username or email (required).", + ), + ], + responses={ + 200: SyncOperationResultSerializer, + 400: "Invalid parameters or missing learner.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks permission.", + 404: "Learner not found.", + }, + ) @method_decorator(transaction.non_atomic_requests) def delete(self, request, course_id, problem): """Delete learner problem state.""" @@ -2934,9 +2983,7 @@ def delete(self, request, course_id, problem): learner_identifier = request.query_params.get('learner') if not learner_identifier: return Response( - {'error': 'INVALID_PARAMETER', - 'message': 'The learner parameter is required', - 'status_code': 400}, + {'error': 'The learner parameter is required'}, status=status.HTTP_400_BAD_REQUEST, ) @@ -2954,16 +3001,12 @@ def delete(self, request, course_id, problem): ) except StudentModule.DoesNotExist: return Response( - {'error': 'RESOURCE_NOT_FOUND', - 'message': 'No state found for this learner and problem', - 'status_code': 404}, + {'error': 'No state found for this learner and problem'}, status=status.HTTP_404_NOT_FOUND, ) except sub_api.SubmissionError: return Response( - {'error': 'INTERNAL_ERROR', - 'message': 'An error occurred while deleting state', - 'status_code': 500}, + {'error': 'An error occurred while deleting state'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -2989,6 +3032,37 @@ class RescoreView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.OVERRIDE_GRADES + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.PATH, + description="Course key for the course.", + ), + apidocs.string_parameter( + 'problem', + apidocs.ParameterLocation.PATH, + description="Problem block usage key.", + ), + apidocs.string_parameter( + 'learner', + apidocs.ParameterLocation.QUERY, + description="Optional: Learner username or email. If omitted, rescores all learners.", + ), + apidocs.string_parameter( + 'only_if_higher', + apidocs.ParameterLocation.QUERY, + description="Optional: If 'true', only update scores that are higher than current.", + ), + ], + responses={ + 202: AsyncOperationResultSerializer, + 400: "Invalid parameters provided.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks permission.", + 404: "Learner not found.", + }, + ) @method_decorator(transaction.non_atomic_requests) def post(self, request, course_id, problem): """Rescore problem submissions.""" @@ -3011,21 +3085,17 @@ def post(self, request, course_id, problem): ) except NotImplementedError as exc: return Response( - {'error': 'INVALID_PARAMETER', 'message': str(exc), 'status_code': 400}, + {'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST, ) except ItemNotFoundError: return Response( - {'error': 'RESOURCE_NOT_FOUND', - 'message': 'Problem not found', - 'status_code': 404}, + {'error': 'Problem not found'}, status=status.HTTP_404_NOT_FOUND, ) except AlreadyRunningError: return Response( - {'error': 'ALREADY_RUNNING', - 'message': 'A rescore task is already running for this learner and problem', - 'status_code': 409}, + {'error': 'A rescore task is already running for this learner and problem'}, status=status.HTTP_409_CONFLICT, ) @@ -3037,9 +3107,7 @@ def post(self, request, course_id, problem): course = get_course_with_access(request.user, 'staff', course_key) if not has_access(request.user, 'instructor', course): return Response( - {'error': 'PERMISSION_DENIED', - 'message': 'Instructor access required for bulk operations', - 'status_code': 403}, + {'error': 'Instructor access required for bulk operations'}, status=status.HTTP_403_FORBIDDEN, ) @@ -3049,21 +3117,17 @@ def post(self, request, course_id, problem): ) except NotImplementedError as exc: return Response( - {'error': 'INVALID_PARAMETER', 'message': str(exc), 'status_code': 400}, + {'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST, ) except ItemNotFoundError: return Response( - {'error': 'RESOURCE_NOT_FOUND', - 'message': 'Problem not found', - 'status_code': 404}, + {'error': 'Problem not found'}, status=status.HTTP_404_NOT_FOUND, ) except AlreadyRunningError: return Response( - {'error': 'ALREADY_RUNNING', - 'message': 'A rescore task is already running for this problem', - 'status_code': 409}, + {'error': 'A rescore task is already running for this problem'}, status=status.HTTP_409_CONFLICT, ) @@ -3079,6 +3143,32 @@ class ScoreOverrideView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.OVERRIDE_GRADES + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.PATH, + description="Course key for the course.", + ), + apidocs.string_parameter( + 'problem', + apidocs.ParameterLocation.PATH, + description="Problem block usage key.", + ), + apidocs.string_parameter( + 'learner', + apidocs.ParameterLocation.QUERY, + description="Learner username or email (required).", + ), + ], + responses={ + 200: SyncOperationResultSerializer, + 400: "Invalid parameters or invalid score.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks permission.", + 404: "Learner not found.", + }, + ) @method_decorator(transaction.non_atomic_requests) def put(self, request, course_id, problem): """Override a learner's score.""" @@ -3090,9 +3180,7 @@ def put(self, request, course_id, problem): learner_identifier = request.query_params.get('learner') if not learner_identifier: return Response( - {'error': 'INVALID_PARAMETER', - 'message': 'The learner parameter is required', - 'status_code': 400}, + {'error': 'The learner parameter is required'}, status=status.HTTP_400_BAD_REQUEST, ) @@ -3103,8 +3191,7 @@ def put(self, request, course_id, problem): body_serializer = ScoreOverrideRequestSerializer(data=request.data) if not body_serializer.is_valid(): return Response( - {'error': 'INVALID_PARAMETER', 'message': 'Invalid request body', - 'field_errors': body_serializer.errors, 'status_code': 400}, + {'error': 'Invalid request body', 'field_errors': body_serializer.errors}, status=status.HTTP_400_BAD_REQUEST, ) score = body_serializer.validated_data['score'] @@ -3113,17 +3200,13 @@ def put(self, request, course_id, problem): block = modulestore().get_item(usage_key) except ItemNotFoundError: return Response( - {'error': 'RESOURCE_NOT_FOUND', - 'message': 'Problem not found', - 'status_code': 404}, + {'error': 'Problem not found'}, status=status.HTTP_404_NOT_FOUND, ) if not has_access(request.user, 'staff', block): return Response( - {'error': 'PERMISSION_DENIED', - 'message': 'You do not have permission to override scores for this problem', - 'status_code': 403}, + {'error': 'You do not have permission to override scores for this problem'}, status=status.HTTP_403_FORBIDDEN, ) @@ -3133,19 +3216,17 @@ def put(self, request, course_id, problem): ) except NotImplementedError as exc: return Response( - {'error': 'INVALID_PARAMETER', 'message': str(exc), 'status_code': 400}, + {'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST, ) except ValueError as exc: return Response( - {'error': 'INVALID_PARAMETER', 'message': str(exc), 'status_code': 400}, + {'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST, ) except AlreadyRunningError: return Response( - {'error': 'ALREADY_RUNNING', - 'message': 'A score override task is already running for this learner and problem', - 'status_code': 409}, + {'error': 'A score override task is already running for this learner and problem'}, status=status.HTTP_409_CONFLICT, ) From e9e7fd6e1ae6a2509140336354e015cd48a5116c Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Mon, 13 Apr 2026 11:30:34 -0600 Subject: [PATCH 4/8] fix: GradingConfig view output response as HTML instead of JSON --- .../instructor/tests/views/test_api_v2.py | 110 +++++++++--------- lms/djangoapps/instructor/views/api_v2.py | 48 ++------ .../instructor/views/serializers_v2.py | 38 ------ 3 files changed, 68 insertions(+), 128 deletions(-) diff --git a/lms/djangoapps/instructor/tests/views/test_api_v2.py b/lms/djangoapps/instructor/tests/views/test_api_v2.py index 4d02f81ec451..2ca78a5b483a 100644 --- a/lms/djangoapps/instructor/tests/views/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/views/test_api_v2.py @@ -366,33 +366,39 @@ 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 - - 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.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.assertEqual(response['Content-Type'], 'text/html') # noqa: PT009 + + hbar = '-' * 77 + expected_html = ( + f'
{hbar}\n'
+            'Course grader:\n'
+            '<class 'xmodule.graders.WeightedSubsectionsGrader'>\n'
+            '\n'
+            'Graded sections:\n'
+            '  subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>,'
+            ' type=Homework, category=Homework, weight=0.15\n'
+            '  subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>,'
+            ' type=Lab, category=Lab, weight=0.15\n'
+            '  subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>,'
+            ' type=Midterm Exam, category=Midterm Exam, weight=0.3\n'
+            '  subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>,'
+            ' 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'
+            '
' + ) + self.assertEqual(response.content.decode(), expected_html) # noqa: PT009 def test_get_grading_config_requires_authentication(self): """Test that endpoint requires authentication""" @@ -461,15 +467,15 @@ def _get_url(self, problem=None): 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) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 data = response.json() - self.assertTrue(data['success']) - self.assertEqual(data['learner'], 'test_student') - self.assertEqual(data['message'], 'Attempts reset successfully') + 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) + 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): @@ -479,11 +485,11 @@ def test_reset_all_learners(self, mock_submit): mock_submit.return_value = mock_task response = self.client.post(self._get_url()) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 data = response.json() - self.assertEqual(data['task_id'], mock_task.task_id) - self.assertIn('status_url', data) - self.assertEqual(data['scope']['learners'], 'all') + 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() @@ -499,24 +505,24 @@ def _get_url(self, problem=None): }) @patch('lms.djangoapps.grades.signals.handlers.PROBLEM_WEIGHTED_SCORE_CHANGED.send') - def test_delete_state(self, _mock_signal): + 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) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 data = response.json() - self.assertTrue(data['success']) - self.assertEqual(data['learner'], 'test_student') - self.assertEqual(data['message'], 'State deleted successfully') + 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( + 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) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 class RescoreViewTestCase(GradingEndpointTestBase): @@ -538,13 +544,13 @@ def test_rescore_single_learner(self, mock_submit): 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) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 data = response.json() - self.assertEqual(data['task_id'], mock_task.task_id) - self.assertEqual(data['scope']['learners'], 'test_student') + 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]) + 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): @@ -554,8 +560,8 @@ def test_rescore_only_if_higher(self, mock_submit): 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) - self.assertTrue(mock_submit.call_args[0][3]) + 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): @@ -565,9 +571,9 @@ def test_rescore_all_learners(self, mock_submit): mock_submit.return_value = mock_task response = self.client.post(self._get_url()) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 data = response.json() - self.assertEqual(data['scope']['learners'], 'all') + self.assertEqual(data['scope']['learners'], 'all') # noqa: PT009 mock_submit.assert_called_once() @@ -594,12 +600,12 @@ def test_override_score(self, mock_submit): data={'score': 8.5}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 data = response.json() - self.assertEqual(data['task_id'], mock_task.task_id) - self.assertEqual(data['scope']['learners'], 'test_student') + 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) + 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.""" @@ -608,7 +614,7 @@ def test_override_requires_learner_param(self): data={'score': 8.5}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.""" @@ -617,7 +623,7 @@ def test_override_requires_score_in_body(self): data={}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.""" @@ -626,4 +632,4 @@ def test_override_rejects_negative_score(self): data={'score': -1}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index c1114d3c8df2..27116e9166ff 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -21,6 +21,7 @@ from django.core.validators import validate_email from django.db import transaction from django.db.models import Q +from django.http import HttpResponse from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.html import strip_tags @@ -119,7 +120,6 @@ CourseTeamRevokeSerializer, EnrollmentModifyRequestSerializerV2, EnrollmentModifyResponseSerializerV2, - GradingConfigSerializer, InstructorTaskListSerializer, IssuedCertificateSerializer, LearnerSerializer, @@ -2013,32 +2013,11 @@ class GradingConfigView(DeveloperErrorViewMixin, APIView): """ API view for retrieving course grading configuration. - **GET Example Response:** - ```json - { - "graders": [ - { - "type": "Homework", - "short_label": "HW", - "min_count": 12, - "drop_count": 2, - "weight": 0.15 - }, - { - "type": "Final Exam", - "short_label": "Final", - "min_count": 1, - "drop_count": 0, - "weight": 0.40 - } - ], - "grade_cutoffs": { - "A": 0.9, - "B": 0.8, - "C": 0.7 - } - } - ``` + Returns an HTML-formatted summary of the course grading context, including + the course grader type, graded sections with assignment types and weights, + and all graded blocks. + + **GET** returns ``text/html`` content type. """ permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_DASHBOARD @@ -2052,7 +2031,7 @@ class GradingConfigView(DeveloperErrorViewMixin, APIView): ), ], responses={ - 200: 'Grading configuration retrieved successfully', + 200: 'HTML-formatted grading configuration summary', 400: "Invalid parameters provided.", 401: "The requesting user is not authenticated.", 403: "The requesting user lacks instructor access to the course.", @@ -2061,8 +2040,7 @@ class GradingConfigView(DeveloperErrorViewMixin, APIView): ) def get(self, request, course_id): """ - Retrieve the grading configuration for a course, including assignment type - weights and grade cutoff thresholds. + Retrieve the grading configuration for a course as an HTML summary. """ try: course_key = CourseKey.from_string(course_id) @@ -2073,16 +2051,10 @@ def get(self, request, course_id): ) course = get_course_by_id(course_key) - grading_policy = course.grading_policy - config_data = { - 'graders': grading_policy.get('GRADER', []), - 'grade_cutoffs': grading_policy.get('GRADE_CUTOFFS', {}), - } - serializer = GradingConfigSerializer(config_data) - return Response(serializer.data, status=status.HTTP_200_OK) + grading_config_summary = instructor_analytics_basic.dump_grading_context(course) + return HttpResponse(grading_config_summary, content_type='text/html') -<<<<<<< HEAD class EnrollmentModifyView(DeveloperErrorViewMixin, APIView): """ Enroll or unenroll one or more learners in a course. diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index a6a6b2574e41..c8f85ed3d55f 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -763,44 +763,6 @@ class LearnerSerializer(serializers.Serializer): ) -class GraderSerializer(serializers.Serializer): - """Serializer for a single grader configuration entry.""" - type = serializers.CharField( - help_text="Assignment type (e.g. Homework, Lab, Midterm Exam)" - ) - short_label = serializers.CharField( - required=False, - allow_null=True, - help_text="Short label used when displaying assignment names" - ) - min_count = serializers.IntegerField( - help_text="Minimum number of assignments counted in this category" - ) - drop_count = serializers.IntegerField( - help_text="Number of lowest scores dropped from this category" - ) - weight = serializers.FloatField( - help_text="Weight of this assignment type in the final grade (0.0 to 1.0)" - ) - - -class GradingConfigSerializer(serializers.Serializer): - """ - Serializer for course grading configuration. - - Returns structured grading policy data including assignment type weights - and grade cutoff thresholds. - """ - graders = GraderSerializer( - many=True, - help_text="List of grader configurations by assignment type" - ) - grade_cutoffs = serializers.DictField( - child=serializers.FloatField(), - help_text="Grade cutoffs mapping letter grades to minimum score thresholds (0.0 to 1.0)" - ) - - class ProblemSerializer(serializers.Serializer): """ Serializer for problem metadata and location. From 26562dd3aabffc2fe6029bc3658a46d18bbb3f1d Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Mon, 20 Apr 2026 18:35:59 -0600 Subject: [PATCH 5/8] fix: Fix 500 error in grading reset and rescore endpoints --- .../instructor/tests/views/test_api_v2.py | 63 ++++++++----------- lms/djangoapps/instructor/views/api_v2.py | 8 +-- 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/lms/djangoapps/instructor/tests/views/test_api_v2.py b/lms/djangoapps/instructor/tests/views/test_api_v2.py index 2ca78a5b483a..918ceaf530e2 100644 --- a/lms/djangoapps/instructor/tests/views/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/views/test_api_v2.py @@ -2,6 +2,7 @@ Tests for Instructor API v2 endpoints. """ import json +from textwrap import dedent from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -418,6 +419,14 @@ class GradingEndpointTestBase(ModuleStoreTestCase): real permissions, and real StudentModule records. """ + PROBLEM_XML = dedent("""\ + + + + + + """) + def setUp(self): super().setUp() self.client = APIClient() @@ -435,7 +444,8 @@ def setUp(self): self.problem = BlockFactory.create( parent=self.sequential, category='problem', - display_name='Test Problem' + display_name='Test Problem', + data=self.PROBLEM_XML, ) # Real instructor with real course permissions @@ -477,20 +487,16 @@ def test_reset_single_learner(self): 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): + @patch('lms.djangoapps.instructor_task.tasks.reset_problem_attempts.apply_async') + def test_reset_all_learners(self, _mock_apply): """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('task_id', data) # noqa: PT009 self.assertIn('status_url', data) # noqa: PT009 self.assertEqual(data['scope']['learners'], 'all') # noqa: PT009 - mock_submit.assert_called_once() + _mock_apply.assert_called_once() class DeleteStateViewTestCase(GradingEndpointTestBase): @@ -536,21 +542,15 @@ def _get_url(self, problem=None): '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): + @patch('lms.djangoapps.instructor_task.tasks.rescore_problem.apply_async') + def test_rescore_single_learner(self, _mock_apply): """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.assertIn('task_id', data) # 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 + _mock_apply.assert_called_once() @patch('lms.djangoapps.instructor_task.api.submit_rescore_problem_for_student') def test_rescore_only_if_higher(self, mock_submit): @@ -563,18 +563,14 @@ def test_rescore_only_if_higher(self, mock_submit): 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): + @patch('lms.djangoapps.instructor_task.tasks.rescore_problem.apply_async') + def test_rescore_all_learners(self, _mock_apply): """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() + _mock_apply.assert_called_once() class ScoreOverrideViewTestCase(GradingEndpointTestBase): @@ -588,24 +584,19 @@ def _get_url(self, problem=None): 'problem': problem or str(self.problem.location), }) - @patch('lms.djangoapps.instructor_task.api.submit_override_score') - def test_override_score(self, mock_submit): + @patch('lms.djangoapps.instructor_task.tasks.override_problem_score.apply_async') + def test_override_score(self, _mock_apply): """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}, + data={'score': 0.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.assertIn('task_id', data) # 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 + _mock_apply.assert_called_once() def test_override_requires_learner_param(self): """PUT without learner query param returns 400.""" diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 27116e9166ff..c47dde9a85de 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -2802,6 +2802,7 @@ def _build_async_response(instructor_task, course_id, problem_location, learner_ return Response(serializer.data, status=status.HTTP_202_ACCEPTED) +@method_decorator(transaction.non_atomic_requests, name='dispatch') class ResetAttemptsView(DeveloperErrorViewMixin, APIView): """ Reset problem attempts for a learner or all learners. @@ -2839,7 +2840,6 @@ class ResetAttemptsView(DeveloperErrorViewMixin, APIView): 404: "Learner not found.", }, ) - @method_decorator(transaction.non_atomic_requests) def post(self, request, course_id, problem): """Reset problem attempts for one or all learners.""" parsed, error_response = _parse_course_and_problem(course_id, problem) @@ -2908,6 +2908,7 @@ def post(self, request, course_id, problem): return _build_async_response(instructor_task, course_id, usage_key) +@method_decorator(transaction.non_atomic_requests, name='dispatch') class DeleteStateView(DeveloperErrorViewMixin, APIView): """ Delete a learner's problem state permanently. @@ -2944,7 +2945,6 @@ class DeleteStateView(DeveloperErrorViewMixin, APIView): 404: "Learner not found.", }, ) - @method_decorator(transaction.non_atomic_requests) def delete(self, request, course_id, problem): """Delete learner problem state.""" parsed, error_response = _parse_course_and_problem(course_id, problem) @@ -2992,6 +2992,7 @@ def delete(self, request, course_id, problem): return Response(serializer.data, status=status.HTTP_200_OK) +@method_decorator(transaction.non_atomic_requests, name='dispatch') class RescoreView(DeveloperErrorViewMixin, APIView): """ Rescore problem submissions for a learner or all learners. @@ -3035,7 +3036,6 @@ class RescoreView(DeveloperErrorViewMixin, APIView): 404: "Learner not found.", }, ) - @method_decorator(transaction.non_atomic_requests) def post(self, request, course_id, problem): """Rescore problem submissions.""" parsed, error_response = _parse_course_and_problem(course_id, problem) @@ -3106,6 +3106,7 @@ def post(self, request, course_id, problem): return _build_async_response(instructor_task, course_id, usage_key) +@method_decorator(transaction.non_atomic_requests, name='dispatch') class ScoreOverrideView(DeveloperErrorViewMixin, APIView): """ Override a learner's score for a specific problem. @@ -3141,7 +3142,6 @@ class ScoreOverrideView(DeveloperErrorViewMixin, APIView): 404: "Learner not found.", }, ) - @method_decorator(transaction.non_atomic_requests) def put(self, request, course_id, problem): """Override a learner's score.""" parsed, error_response = _parse_course_and_problem(course_id, problem) From 17c493470ab831be294d5d84e72b7aea61d92b85 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Mon, 20 Apr 2026 18:50:48 -0600 Subject: [PATCH 6/8] fix: Fix delete grade endpoint error --- .../instructor/tests/views/test_api_v2.py | 7 +++++++ lms/djangoapps/instructor/views/api_v2.py | 15 +++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/instructor/tests/views/test_api_v2.py b/lms/djangoapps/instructor/tests/views/test_api_v2.py index 918ceaf530e2..c9174869da2b 100644 --- a/lms/djangoapps/instructor/tests/views/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/views/test_api_v2.py @@ -530,6 +530,13 @@ def test_delete_state_requires_learner_param(self): response = self.client.delete(self._get_url()) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 + @patch('lms.djangoapps.grades.signals.handlers.PROBLEM_WEIGHTED_SCORE_CHANGED.send') + def test_delete_state_learner_in_body(self, _mock_signal): # noqa: PT019 + """DELETE with learner in request body (form data) also works.""" + response = self.client.delete(self._get_url(), data={'learner': 'test_student'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertTrue(response.json()['success']) # noqa: PT009 + class RescoreViewTestCase(GradingEndpointTestBase): """ diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index c47dde9a85de..11eb445999f6 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -2734,6 +2734,13 @@ def delete(self, request, course_id, email_or_username): }, status=status.HTTP_200_OK) +def _get_learner_identifier(request): + """ + Extract the learner identifier from query params or request body. + """ + return request.query_params.get('learner') or request.data.get('learner') + + def _parse_course_and_problem(course_id, problem): """ Parse and validate course_id and problem location strings. @@ -2847,7 +2854,7 @@ def post(self, request, course_id, problem): return error_response course_key, usage_key = parsed - learner_identifier = request.query_params.get('learner') + learner_identifier = _get_learner_identifier(request) if learner_identifier: student, error_response = _resolve_learner(learner_identifier) @@ -2952,7 +2959,7 @@ def delete(self, request, course_id, problem): return error_response course_key, usage_key = parsed - learner_identifier = request.query_params.get('learner') + learner_identifier = _get_learner_identifier(request) if not learner_identifier: return Response( {'error': 'The learner parameter is required'}, @@ -3044,7 +3051,7 @@ def post(self, request, course_id, problem): course_key, usage_key = parsed only_if_higher = request.query_params.get('only_if_higher', 'false').lower() == 'true' - learner_identifier = request.query_params.get('learner') + learner_identifier = _get_learner_identifier(request) if learner_identifier: student, error_response = _resolve_learner(learner_identifier) @@ -3149,7 +3156,7 @@ def put(self, request, course_id, problem): return error_response course_key, usage_key = parsed - learner_identifier = request.query_params.get('learner') + learner_identifier = _get_learner_identifier(request) if not learner_identifier: return Response( {'error': 'The learner parameter is required'}, From 0ef7102b05bc9f7986ebad2e3fe4b6026e8ecb38 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Mon, 20 Apr 2026 18:58:42 -0600 Subject: [PATCH 7/8] fix: Fix grading rescore payloads --- .../instructor/tests/views/test_api_v2.py | 11 +++++++++++ lms/djangoapps/instructor/views/serializers_v2.py | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/lms/djangoapps/instructor/tests/views/test_api_v2.py b/lms/djangoapps/instructor/tests/views/test_api_v2.py index c9174869da2b..5f7c4f1ed710 100644 --- a/lms/djangoapps/instructor/tests/views/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/views/test_api_v2.py @@ -605,6 +605,17 @@ def test_override_score(self, _mock_apply): self.assertEqual(data['scope']['learners'], 'test_student') # noqa: PT009 _mock_apply.assert_called_once() + @patch('lms.djangoapps.instructor_task.tasks.override_problem_score.apply_async') + def test_override_score_with_new_score_field(self, _mock_apply): + """Score override also accepts 'new_score' field name (frontend compat).""" + response = self.client.put( + self._get_url(), + data={'new_score': 0.5, 'learner': 'test_student'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 + _mock_apply.assert_called_once() + def test_override_requires_learner_param(self): """PUT without learner query param returns 400.""" response = self.client.put( diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index c8f85ed3d55f..a86cea69f993 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -994,6 +994,19 @@ class ScoreOverrideRequestSerializer(serializers.Serializer): Serializer for score override request body. """ score = serializers.FloatField( + required=False, min_value=0, help_text="New score value (out of problem's total possible points)" ) + new_score = serializers.FloatField( + required=False, + min_value=0, + help_text="Alias for score (frontend compatibility)" + ) + + def validate(self, data): + score = data.get('score') or data.get('new_score') + if score is None: + raise serializers.ValidationError({'score': 'This field is required.'}) + data['score'] = score + return data From 8d93296dd945c2435d82567453a34afe60dec216 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Mon, 20 Apr 2026 19:06:56 -0600 Subject: [PATCH 8/8] fix: Convert assertions to regular assert and remove # noqa: PT009 from grading tests. --- .../instructor/tests/views/test_api_v2.py | 84 +++++++++---------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/lms/djangoapps/instructor/tests/views/test_api_v2.py b/lms/djangoapps/instructor/tests/views/test_api_v2.py index 5f7c4f1ed710..4383c621878a 100644 --- a/lms/djangoapps/instructor/tests/views/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/views/test_api_v2.py @@ -477,26 +477,26 @@ def _get_url(self, problem=None): 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 + assert response.status_code == status.HTTP_200_OK data = response.json() - self.assertTrue(data['success']) # noqa: PT009 - self.assertEqual(data['learner'], 'test_student') # noqa: PT009 - self.assertEqual(data['message'], 'Attempts reset successfully') # noqa: PT009 + assert data['success'] is True + assert data['learner'] == 'test_student' + assert data['message'] == 'Attempts reset successfully' # Verify the actual StudentModule was modified self.student_module.refresh_from_db() - self.assertEqual(json.loads(self.student_module.state)['attempts'], 0) # noqa: PT009 + assert json.loads(self.student_module.state)['attempts'] == 0 @patch('lms.djangoapps.instructor_task.tasks.reset_problem_attempts.apply_async') - def test_reset_all_learners(self, _mock_apply): + def test_reset_all_learners(self, mock_apply): """Bulk reset queues a background task and returns 202.""" response = self.client.post(self._get_url()) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 + assert response.status_code == status.HTTP_202_ACCEPTED data = response.json() - self.assertIn('task_id', data) # noqa: PT009 - self.assertIn('status_url', data) # noqa: PT009 - self.assertEqual(data['scope']['learners'], 'all') # noqa: PT009 - _mock_apply.assert_called_once() + assert 'task_id' in data + assert 'status_url' in data + assert data['scope']['learners'] == 'all' + mock_apply.assert_called_once() class DeleteStateViewTestCase(GradingEndpointTestBase): @@ -514,28 +514,26 @@ def _get_url(self, problem=None): 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 + assert response.status_code == status.HTTP_200_OK 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 + assert data['success'] is True + assert data['learner'] == 'test_student' + assert data['message'] == 'State deleted successfully' # Verify the StudentModule was actually deleted - self.assertFalse( # noqa: PT009 - StudentModule.objects.filter(pk=self.student_module.pk).exists() - ) + assert not 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 + assert response.status_code == status.HTTP_400_BAD_REQUEST @patch('lms.djangoapps.grades.signals.handlers.PROBLEM_WEIGHTED_SCORE_CHANGED.send') def test_delete_state_learner_in_body(self, _mock_signal): # noqa: PT019 """DELETE with learner in request body (form data) also works.""" response = self.client.delete(self._get_url(), data={'learner': 'test_student'}) - self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 - self.assertTrue(response.json()['success']) # noqa: PT009 + assert response.status_code == status.HTTP_200_OK + assert response.json()['success'] is True class RescoreViewTestCase(GradingEndpointTestBase): @@ -550,14 +548,14 @@ def _get_url(self, problem=None): }) @patch('lms.djangoapps.instructor_task.tasks.rescore_problem.apply_async') - def test_rescore_single_learner(self, _mock_apply): + def test_rescore_single_learner(self, mock_apply): """Single learner rescore queues a task and returns 202.""" response = self.client.post(self._get_url() + '?learner=test_student') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 + assert response.status_code == status.HTTP_202_ACCEPTED data = response.json() - self.assertIn('task_id', data) # noqa: PT009 - self.assertEqual(data['scope']['learners'], 'test_student') # noqa: PT009 - _mock_apply.assert_called_once() + assert 'task_id' in data + assert data['scope']['learners'] == 'test_student' + mock_apply.assert_called_once() @patch('lms.djangoapps.instructor_task.api.submit_rescore_problem_for_student') def test_rescore_only_if_higher(self, mock_submit): @@ -567,17 +565,17 @@ def test_rescore_only_if_higher(self, mock_submit): 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 + assert response.status_code == status.HTTP_202_ACCEPTED + assert mock_submit.call_args[0][3] is True @patch('lms.djangoapps.instructor_task.tasks.rescore_problem.apply_async') - def test_rescore_all_learners(self, _mock_apply): + def test_rescore_all_learners(self, mock_apply): """Bulk rescore queues a task and returns 202.""" response = self.client.post(self._get_url()) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 + assert response.status_code == status.HTTP_202_ACCEPTED data = response.json() - self.assertEqual(data['scope']['learners'], 'all') # noqa: PT009 - _mock_apply.assert_called_once() + assert data['scope']['learners'] == 'all' + mock_apply.assert_called_once() class ScoreOverrideViewTestCase(GradingEndpointTestBase): @@ -592,29 +590,29 @@ def _get_url(self, problem=None): }) @patch('lms.djangoapps.instructor_task.tasks.override_problem_score.apply_async') - def test_override_score(self, _mock_apply): + def test_override_score(self, mock_apply): """Score override queues a task and returns 202.""" response = self.client.put( self._get_url() + '?learner=test_student', data={'score': 0.5}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 + assert response.status_code == status.HTTP_202_ACCEPTED data = response.json() - self.assertIn('task_id', data) # noqa: PT009 - self.assertEqual(data['scope']['learners'], 'test_student') # noqa: PT009 - _mock_apply.assert_called_once() + assert 'task_id' in data + assert data['scope']['learners'] == 'test_student' + mock_apply.assert_called_once() @patch('lms.djangoapps.instructor_task.tasks.override_problem_score.apply_async') - def test_override_score_with_new_score_field(self, _mock_apply): + def test_override_score_with_new_score_field(self, mock_apply): """Score override also accepts 'new_score' field name (frontend compat).""" response = self.client.put( self._get_url(), data={'new_score': 0.5, 'learner': 'test_student'}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) # noqa: PT009 - _mock_apply.assert_called_once() + assert response.status_code == status.HTTP_202_ACCEPTED + mock_apply.assert_called_once() def test_override_requires_learner_param(self): """PUT without learner query param returns 400.""" @@ -623,7 +621,7 @@ def test_override_requires_learner_param(self): data={'score': 8.5}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 + assert response.status_code == status.HTTP_400_BAD_REQUEST def test_override_requires_score_in_body(self): """PUT without score in body returns 400.""" @@ -632,7 +630,7 @@ def test_override_requires_score_in_body(self): data={}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 + assert response.status_code == status.HTTP_400_BAD_REQUEST def test_override_rejects_negative_score(self): """PUT with negative score returns 400.""" @@ -641,4 +639,4 @@ def test_override_rejects_negative_score(self): data={'score': -1}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 + assert response.status_code == status.HTTP_400_BAD_REQUEST