diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index f5e6d007e8..8bb59462ba 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -114,8 +114,9 @@ from lms.djangoapps.instructor.views.serializer import ( UserSerializer, UniqueStudentIdentifierSerializer, ProblemResetSerializer, + RescoreEntranceExamSerializer, UpdateForumRoleMembershipSerializer, - RescoreEntranceExamSerializer + OverrideProblemScoreSerializer ) from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted @@ -2189,64 +2190,88 @@ class RescoreProblem(DeveloperErrorViewMixin, APIView): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.OVERRIDE_GRADES) -@require_post_params(problem_to_reset="problem urlname to reset", score='overriding score') -@common_exceptions_400 -def override_problem_score(request, course_id): # lint-amnesty, pylint: disable=missing-function-docstring - course_key = CourseKey.from_string(course_id) - score = strip_if_string(request.POST.get('score')) - problem_to_reset = strip_if_string(request.POST.get('problem_to_reset')) - student_identifier = request.POST.get('unique_student_identifier', None) +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class OverrideProblemScoreView(DeveloperErrorViewMixin, APIView): + """ + DRF view to override a student's score for a specific problem. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.OVERRIDE_GRADES + serializer_class = OverrideProblemScoreSerializer - if not problem_to_reset: - return HttpResponseBadRequest("Missing query parameter problem_to_reset.") + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Takes the following query parameters: + - problem_to_reset: a urlname of a problem + - unique_student_identifier: an email or username + - score: the score to override with + Returns a response indicating the success or failure of the operation. + If the user does not have permission to override scores for the problem, + a 403 Forbidden response is returned. + If the problem cannot be found or parsed, a 400 Bad Request response is returned. + If the score override is successful, a 200 OK response is returned with the task status + and the problem and student identifiers in the response payload. + """ - if not student_identifier: - return HttpResponseBadRequest("Missing query parameter student_identifier.") + serializer_data = self.serializer_class(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - if student_identifier is not None: - student = get_student_from_identifier(student_identifier) - else: - return _create_error_response(request, f"Invalid student ID {student_identifier}.") + course_key = CourseKey.from_string(course_id) + problem_to_reset = serializer_data.validated_data['problem_to_reset'] + score = serializer_data.validated_data['score'] + student = serializer_data.validated_data['unique_student_identifier'] + student_identifier = request.data.get('unique_student_identifier') - try: - usage_key = UsageKey.from_string(problem_to_reset).map_into_course(course_key) - block = modulestore().get_item(usage_key) - except InvalidKeyError: - return _create_error_response(request, f"Unable to parse problem id {problem_to_reset}.") - except ItemNotFoundError: - return _create_error_response(request, f"Unable to find problem id {problem_to_reset}.") + try: + usage_key = UsageKey.from_string(problem_to_reset).map_into_course(course_key) + block = modulestore().get_item(usage_key) + except InvalidKeyError: + return Response( + {"error": f"Unable to parse problem id {problem_to_reset}."}, + status=status.HTTP_400_BAD_REQUEST + ) + except ItemNotFoundError: + return Response( + {"error": f"Unable to find problem id {problem_to_reset}."}, + status=status.HTTP_400_BAD_REQUEST + ) - # check the user's access to this specific problem - if not has_access(request.user, "staff", block): - _create_error_response(request, "User {} does not have permission to override scores for problem {}.".format( - request.user.id, - problem_to_reset - )) + if not has_access(request.user, "staff", block): + return Response( + { + "error": _( + "User {user_id} does not have permission to " + "override scores for problem {problem_to_reset}." + ).format( + user_id=request.user.id, + problem_to_reset=problem_to_reset + ) + }, + status=status.HTTP_403_FORBIDDEN + ) - response_payload = { - 'problem_to_reset': problem_to_reset, - 'student': student_identifier - } - try: - task_api.submit_override_score( - request, - usage_key, - student, - score, - ) - except NotImplementedError as exc: # if we try to override the score of a non-scorable block, catch it here - return _create_error_response(request, str(exc)) + response_payload = { + 'problem_to_reset': problem_to_reset, + 'student': student_identifier + } + try: + task_api.submit_override_score( + request, + usage_key, + student, + score, + ) + except NotImplementedError as exc: + return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except ValueError as exc: + return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) - except ValueError as exc: - return _create_error_response(request, str(exc)) - - response_payload['task'] = TASK_SUBMISSION_OK - return JsonResponse(response_payload) + response_payload['task'] = TASK_SUBMISSION_OK + return Response(response_payload) @method_decorator(transaction.non_atomic_requests, name='dispatch') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index f47fc2d299..ba49f558bd 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -39,7 +39,7 @@ urlpatterns = [ path('get_student_progress_url', api.StudentProgressUrl.as_view(), name='get_student_progress_url'), path('reset_student_attempts', api.ResetStudentAttempts.as_view(), name='reset_student_attempts'), path('rescore_problem', api.RescoreProblem.as_view(), name='rescore_problem'), - path('override_problem_score', api.override_problem_score, name='override_problem_score'), + path('override_problem_score', api.OverrideProblemScoreView.as_view(), name='override_problem_score'), path('reset_student_attempts_for_entrance_exam', api.reset_student_attempts_for_entrance_exam, name='reset_student_attempts_for_entrance_exam'), path('rescore_entrance_exam', api.RescoreEntranceExamView.as_view(), name='rescore_entrance_exam'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 5327a17116..9cf4af2f0f 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -483,3 +483,18 @@ class RescoreEntranceExamSerializer(serializers.Serializer): unique_student_identifier = serializers.CharField(required=False, allow_null=True) all_students = serializers.BooleanField(required=False) only_if_higher = serializers.BooleanField(required=False, allow_null=True) + + +class OverrideProblemScoreSerializer(UniqueStudentIdentifierSerializer): + """ + Serializer for overriding a student's score for a specific problem. + """ + problem_to_reset = serializers.CharField( + help_text=_("The URL name of the problem to override the score for."), + error_messages={ + 'blank': _("Problem URL name cannot be blank."), + } + ) + score = serializers.FloatField( + help_text=_("The overriding score to set."), + )