diff --git a/lms/djangoapps/grades/signals/handlers.py b/lms/djangoapps/grades/signals/handlers.py index af33279515..15741197aa 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -8,6 +8,7 @@ from logging import getLogger from django.dispatch import receiver from opaque_keys.edx.keys import LearningContextKey +from openedx_events.learning.signals import EXAM_ATTEMPT_VERIFIED from submissions.models import score_reset, score_set from xblock.scorable import ScorableXBlockMixin, Score @@ -25,7 +26,7 @@ from openedx.core.djangoapps.course_groups.signals.signals import COHORT_MEMBERS from openedx.core.lib.grade_utils import is_score_higher_or_equal from .. import events -from ..constants import ScoreDatabaseTableEnum +from ..constants import GradeOverrideFeatureEnum, ScoreDatabaseTableEnum from ..course_grade_factory import CourseGradeFactory from ..scores import weighted_score from .signals import ( @@ -122,6 +123,10 @@ def submissions_score_reset_handler(sender, **kwargs): # pylint: disable=unused def disconnect_submissions_signal_receiver(signal): """ Context manager to be used for temporarily disconnecting edx-submission's set or reset signal. + + Clear Student State on ORA problems currently results in a set->reset signal pair getting fired + from submissions which leads to tasks being enqueued, one of which can never succeed. This context manager + fixes the issue by disconnecting the "set" handler during the clear_state operation. """ if signal == score_set: handler = submissions_score_set_handler @@ -300,3 +305,20 @@ def listen_for_course_grade_passed_first_time(sender, user_id, course_id, **kwar """ events.course_grade_passed_first_time(user_id, course_id) events.fire_segment_event_on_course_grade_passed_first_time(user_id, course_id) + + +@receiver(EXAM_ATTEMPT_VERIFIED) +def exam_attempt_verified_event_handler(sender, signal, **kwargs): # pylint: disable=unused-argument + """ + Consume `EXAM_ATTEMPT_VERIFIED` events from the event bus. This will trigger + an undo section override, if one exists. + """ + from ..api import should_override_grade_on_rejected_exam, undo_override_subsection_grade + + event_data = kwargs.get('exam_attempt') + user_data = event_data.student_user + course_key = event_data.course_key + usage_key = event_data.usage_key + + if should_override_grade_on_rejected_exam(course_key): + undo_override_subsection_grade(user_data.id, course_key, usage_key, GradeOverrideFeatureEnum.proctoring) diff --git a/lms/djangoapps/grades/tests/test_handlers.py b/lms/djangoapps/grades/tests/test_handlers.py new file mode 100644 index 0000000000..42ad9aa1bf --- /dev/null +++ b/lms/djangoapps/grades/tests/test_handlers.py @@ -0,0 +1,103 @@ +""" +Tests for the grades handlers +""" +from datetime import datetime, timezone +from unittest import mock +from uuid import uuid4 + +import ddt +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_events.data import EventsMetadata +from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData +from openedx_events.learning.signals import EXAM_ATTEMPT_VERIFIED + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.grades.signals.handlers import exam_attempt_verified_event_handler +from ..constants import GradeOverrideFeatureEnum + + +@ddt.ddt +class ExamCompletionEventBusTests(TestCase): + """ + Tests for exam events from the event bus + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course') + cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection' + cls.usage_key = UsageKey.from_string(cls.subsection_id) + cls.student_user = UserFactory( + username='student_user', + ) + + @staticmethod + def _get_exam_event_data(student_user, course_key, usage_key, exam_type, requesting_user=None): + """ create ExamAttemptData object for exam based event """ + if requesting_user: + requesting_user_data = UserData( + id=requesting_user.id, + is_active=True, + pii=None + ) + else: + requesting_user_data = None + + return ExamAttemptData( + student_user=UserData( + id=student_user.id, + is_active=True, + pii=UserPersonalData( + username=student_user.username, + email=student_user.email, + ), + ), + course_key=course_key, + usage_key=usage_key, + requesting_user=requesting_user_data, + exam_type=exam_type, + ) + + @staticmethod + def _get_exam_event_metadata(event_signal): + """ create metadata object for event """ + return EventsMetadata( + event_type=event_signal.event_type, + id=uuid4(), + minorversion=0, + source='openedx/lms/web', + sourcehost='lms.test', + time=datetime.now(timezone.utc) + ) + + @ddt.data( + True, + False + ) + @mock.patch('lms.djangoapps.grades.api.should_override_grade_on_rejected_exam') + @mock.patch('lms.djangoapps.grades.api.undo_override_subsection_grade') + def test_exam_attempt_verified_event_handler(self, override_enabled, mock_undo_override, mock_should_override): + mock_should_override.return_value = override_enabled + + exam_event_data = self._get_exam_event_data(self.student_user, + self.course_key, + self.usage_key, + exam_type='proctored') + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_VERIFIED) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + exam_attempt_verified_event_handler(None, EXAM_ATTEMPT_VERIFIED, ** event_kwargs) + + if override_enabled: + mock_undo_override.assert_called_once_with( + self.student_user.id, + self.course_key, + self.usage_key, + GradeOverrideFeatureEnum.proctoring + ) + else: + mock_undo_override.assert_not_called()