diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 2e6d863c44..f8b6dc06f4 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -117,7 +117,8 @@ from lms.djangoapps.instructor.views.serializer import ( UpdateForumRoleMembershipSerializer, RescoreEntranceExamSerializer, OverrideProblemScoreSerializer, - StudentsUpdateEnrollmentSerializer + StudentsUpdateEnrollmentSerializer, + ResetEntranceExamAttemptsSerializer ) 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 @@ -2021,81 +2022,90 @@ class ResetStudentAttempts(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.GIVE_STUDENT_EXTENSION) -@common_exceptions_400 -def reset_student_attempts_for_entrance_exam(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class ResetStudentAttemptsForEntranceExam(DeveloperErrorViewMixin, APIView): """ - Resets a students attempts counter or starts a task to reset all students attempts counters for entrance exam. Optionally deletes student state for entrance exam. Limited to staff access. Some sub-methods limited to instructor access. - - Following are possible query parameters - - unique_student_identifier is an email or username - - all_students is a boolean - requires instructor access - mutually exclusive with delete_module - - delete_module is a boolean - requires instructor access - mutually exclusive with all_students """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access( - request.user, 'staff', course_id, depth=None - ) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION - if not course.entrance_exam_id: - return HttpResponseBadRequest( - _("Course has no entrance exam section.") + serializer_class = ResetEntranceExamAttemptsSerializer + + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Resets a student's entrance exam attempts or + deletes entrance exam state. + + Parameters (in request.data): + - unique_student_identifier (str, optional): + Email or username of the student. If provided, must exist. + - all_students (bool, optional): + If True, applies to all students. Mutually exclusive with + unique_student_identifier and delete_module. + - delete_module (bool, optional): + If True, deletes entrance exam state for the student. Mutually + exclusive with all_students. + + Behavior: + - At least one of unique_student_identifier, all_students, or + delete_module must be provided. + - If unique_student_identifier is provided but does not exist, + returns a validation error. + - If mutually exclusive parameters are provided, returns a + validation error. + - Requires staff access; instructor access required for + all_students or delete_module actions. + - Returns a JSON response with the task status and student + identifier. + """ + course_id = CourseKey.from_string(course_id) + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + course = get_course_with_access( + request.user, 'staff', course_id, depth=None ) - student_identifier = request.POST.get('unique_student_identifier', None) - student = None - - if student_identifier is not None: - student = get_student_from_identifier(student_identifier) - - all_students = _get_boolean_param(request, 'all_students') - delete_module = _get_boolean_param(request, 'delete_module') - - # parameter combinations - if all_students and student: - return HttpResponseBadRequest( - _("all_students and unique_student_identifier are mutually exclusive.") - ) - if all_students and delete_module: - return HttpResponseBadRequest( - _("all_students and delete_module are mutually exclusive.") - ) - - # instructor authorization - if all_students or delete_module: - if not has_access(request.user, 'instructor', course): - return HttpResponseForbidden(_("Requires instructor access.")) - - try: - entrance_exam_key = UsageKey.from_string(course.entrance_exam_id).map_into_course(course_id) - if delete_module: - task_api.submit_delete_entrance_exam_state_for_student( - request, - entrance_exam_key, - student + if not course.entrance_exam_id: + return HttpResponseBadRequest( + _("Course has no entrance exam section.") ) - else: - task_api.submit_reset_problem_attempts_in_entrance_exam( - request, - entrance_exam_key, - student - ) - except InvalidKeyError: - return HttpResponseBadRequest(_("Course has no valid entrance exam section.")) - response_payload = {'student': student_identifier or _('All Students'), 'task': TASK_SUBMISSION_OK} - return JsonResponse(response_payload) + student_identifier = serializer.initial_data.get('unique_student_identifier') + student = serializer.validated_data.get('unique_student_identifier') + all_students = serializer.validated_data.get('all_students') + delete_module = serializer.validated_data.get('delete_module') + + # instructor authorization + if all_students or delete_module: + if not has_access(request.user, 'instructor', course): + return HttpResponseForbidden(_("Requires instructor access.")) + + try: + entrance_exam_key = UsageKey.from_string(course.entrance_exam_id).map_into_course(course_id) + if delete_module: + task_api.submit_delete_entrance_exam_state_for_student( + request, + entrance_exam_key, + student + ) + else: + task_api.submit_reset_problem_attempts_in_entrance_exam( + request, + entrance_exam_key, + student + ) + except InvalidKeyError: + return HttpResponseBadRequest(_("Course has no valid entrance exam section.")) + + response_payload = {'student': student_identifier or _('All Students'), 'task': TASK_SUBMISSION_OK} + return JsonResponse(response_payload) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 27ceba37b7..90a087443a 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -40,7 +40,7 @@ urlpatterns = [ 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.OverrideProblemScoreView.as_view(), name='override_problem_score'), - path('reset_student_attempts_for_entrance_exam', api.reset_student_attempts_for_entrance_exam, + path('reset_student_attempts_for_entrance_exam', api.ResetStudentAttemptsForEntranceExam.as_view(), name='reset_student_attempts_for_entrance_exam'), path('rescore_entrance_exam', api.RescoreEntranceExamView.as_view(), name='rescore_entrance_exam'), path('list_entrance_exam_instructor_tasks', api.ListEntranceExamInstructorTasks.as_view(), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 11efb0367f..41bae91531 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -485,6 +485,69 @@ class RescoreEntranceExamSerializer(serializers.Serializer): only_if_higher = serializers.BooleanField(required=False, allow_null=True) +class ResetEntranceExamAttemptsSerializer(UniqueStudentIdentifierSerializer): + """ + Serializer for resetting entrance exam attempts or deleting entrance exam state. + Inherits user validation from UniqueStudentIdentifierSerializer. + """ + all_students = serializers.BooleanField( + required=False, + default=False, + help_text=_("Whether to reset for all students."), + ) + delete_module = serializers.BooleanField( + required=False, + default=False, + help_text=_("Whether to delete entrance exam state for the student."), + ) + + # Override the unique_student_identifier field to make it optional + unique_student_identifier = serializers.CharField( + required=False, # Make this field optional + allow_null=True, + help_text=_("unique student identifier.") + ) + + def validate_unique_student_identifier(self, value): + """ + Validate that the unique_student_identifier corresponds to an existing user, + if a value is provided. + """ + if not value: + return None + + user = super().validate_unique_student_identifier(value) + if user is None: + raise serializers.ValidationError( + "No user found with the provided identifier." + ) + + return user + + def validate(self, attrs): + all_students = attrs.get("all_students", False) + student = attrs.get("unique_student_identifier") + delete_module = attrs.get("delete_module", False) + + errors = [] + + if all_students and student: + errors.append(_("all_students and unique_student_identifier are mutually exclusive.")) + + if all_students and delete_module: + errors.append(_( + "all_students and delete_module are mutually exclusive." + )) + + if not (student or all_students): + errors.append(_("You must provide either unique_student_identifier or set all_students to True.")) + + if errors: + raise serializers.ValidationError({"non_field_errors": errors}) + + return attrs + + class StudentsUpdateEnrollmentSerializer(serializers.Serializer): """Serializer for student enroll/unenroll actions.""" action = serializers.ChoiceField(choices=["enroll", "unenroll"])