diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index b6861e4ddc5b..b7a249bfa5b4 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -256,7 +256,7 @@ def update_special_exams_and_publish(course_key_str): """ from cms.djangoapps.contentstore.exams import register_exams from cms.djangoapps.contentstore.proctoring import register_special_exams as register_exams_legacy - from openedx.core.djangoapps.credit.signals import on_course_publish + from openedx.core.djangoapps.credit.signals.handlers import on_course_publish course_key = CourseKey.from_string(course_key_str) LOGGER.info('Attempting to register exams for course %s', course_key_str) diff --git a/cms/djangoapps/contentstore/tests/test_tasks.py b/cms/djangoapps/contentstore/tests/test_tasks.py index d0fcd78210c3..cf82a6d16571 100644 --- a/cms/djangoapps/contentstore/tests/test_tasks.py +++ b/cms/djangoapps/contentstore/tests/test_tasks.py @@ -195,7 +195,7 @@ def test_exam_service_enabled_success(self, _mock_register_exams_proctoring, _mo @mock.patch('cms.djangoapps.contentstore.proctoring.register_special_exams') def test_register_exams_failure(self, _mock_register_exams_proctoring, _mock_register_exams_service): """ credit requirements update signal fires even if exam registration fails """ - with mock.patch('openedx.core.djangoapps.credit.signals.on_course_publish') as course_publish: + with mock.patch('openedx.core.djangoapps.credit.signals.handlers.on_course_publish') as course_publish: _mock_register_exams_proctoring.side_effect = Exception('boom!') update_special_exams_and_publish(str(self.course.id)) course_publish.assert_called() diff --git a/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py index 3d6d2d9e81b7..5cfa2ded2b80 100644 --- a/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py +++ b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py @@ -9,7 +9,7 @@ from cms.djangoapps.contentstore.utils import reverse_course_url from openedx.core.djangoapps.credit.api import get_credit_requirements from openedx.core.djangoapps.credit.models import CreditCourse -from openedx.core.djangoapps.credit.signals import on_course_publish +from openedx.core.djangoapps.credit.signals.handlers import on_course_publish from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order diff --git a/openedx/core/djangoapps/credit/apps.py b/openedx/core/djangoapps/credit/apps.py index 6107d73de7db..e057cc5cab77 100644 --- a/openedx/core/djangoapps/credit/apps.py +++ b/openedx/core/djangoapps/credit/apps.py @@ -15,7 +15,7 @@ class CreditConfig(AppConfig): name = 'openedx.core.djangoapps.credit' def ready(self): - from . import signals # lint-amnesty, pylint: disable=unused-import + from .signals import handlers # lint-amnesty, pylint: disable=unused-import if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): from .services import CreditService set_runtime_service('credit', CreditService()) diff --git a/openedx/core/djangoapps/credit/signals/__init__.py b/openedx/core/djangoapps/credit/signals/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/credit/signals.py b/openedx/core/djangoapps/credit/signals/handlers.py similarity index 54% rename from openedx/core/djangoapps/credit/signals.py rename to openedx/core/djangoapps/credit/signals/handlers.py index de22fe42a852..3748f1cfba86 100644 --- a/openedx/core/djangoapps/credit/signals.py +++ b/openedx/core/djangoapps/credit/signals/handlers.py @@ -5,15 +5,78 @@ import logging +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist from django.dispatch import receiver from django.utils import timezone from opaque_keys.edx.keys import CourseKey - +from openedx_events.learning.signals import ( + EXAM_ATTEMPT_ERRORED, + EXAM_ATTEMPT_REJECTED, + EXAM_ATTEMPT_RESET, + EXAM_ATTEMPT_SUBMITTED, + EXAM_ATTEMPT_VERIFIED +) + +from openedx.core.djangoapps.credit.api.eligibility import ( + is_credit_course, + remove_credit_requirement_status, + set_credit_requirement_status +) from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED +User = get_user_model() + log = logging.getLogger(__name__) +def handle_exam_event(signal, event_data, credit_status=None): + """ + update credit requirements based on exam event + """ + user_data = event_data.student_user + course_key = event_data.course_key + usage_key = event_data.usage_key + request_namespace = 'exam' + + # quick exit, if course is not credit enabled + if not is_credit_course(course_key): + return + + # log any activity to the credit requirements table + log.info( + f'Recieved {signal} signal, changing credit requirement for ' + f'user_id={user_data.id}, course_key_or_id={course_key} ' + f'content_id={usage_key}' + ) + + try: + user = User.objects.get(id=user_data.id) + except ObjectDoesNotExist: + log.error( + 'Error occurred while handling exam event for ' + f'{user_data.id} and content_id {usage_key}. ' + 'User does not exist!' + ) + return + + if signal == EXAM_ATTEMPT_RESET: + remove_credit_requirement_status( + user.username, + course_key, + request_namespace, + str(usage_key), + ) + else: + set_credit_requirement_status( + user.username, + course_key, + request_namespace, + str(usage_key), + credit_status + ) + + def on_course_publish(course_key): """ Will receive a delegated 'course_published' signal from cms/djangoapps/contentstore/signals.py @@ -94,3 +157,48 @@ def listen_for_grade_calculation(sender, user, course_grade, course_key, deadlin api.set_credit_requirement_status( user, course_id, 'grade', 'grade', status=status, reason=reason ) + + +@receiver(EXAM_ATTEMPT_RESET) +def listen_for_exam_reset(sender, signal, **kwargs): + """ + exam reset event from the event bus + """ + event_data = kwargs.get('exam_attempt') + handle_exam_event(signal, event_data) + + +@receiver(EXAM_ATTEMPT_SUBMITTED) +def listen_for_exam_submitted(sender, signal, **kwargs): + """ + exam submission event from the event bus + """ + event_data = kwargs.get('exam_attempt') + handle_exam_event(signal, event_data, credit_status='submitted') + + +@receiver(EXAM_ATTEMPT_VERIFIED) +def listen_for_exam_verified(sender, signal, **kwargs): + """ + exam verification event from the event bus + """ + event_data = kwargs.get('exam_attempt') + handle_exam_event(signal, event_data, credit_status='satisfied') + + +@receiver(EXAM_ATTEMPT_REJECTED) +def listen_for_exam_rejected(sender, signal, **kwargs): + """ + exam rejection event from the event bus + """ + event_data = kwargs.get('exam_attempt') + handle_exam_event(signal, event_data, credit_status='failed') + + +@receiver(EXAM_ATTEMPT_ERRORED) +def listen_for_exam_errored(sender, signal, **kwargs): + """ + exam error event from the event bus + """ + event_data = kwargs.get('exam_attempt') + handle_exam_event(signal, event_data, credit_status='failed') diff --git a/openedx/core/djangoapps/credit/tests/test_signals.py b/openedx/core/djangoapps/credit/tests/test_signals.py index 198f88e875ac..c3331c0ecfc6 100644 --- a/openedx/core/djangoapps/credit/tests/test_signals.py +++ b/openedx/core/djangoapps/credit/tests/test_signals.py @@ -1,23 +1,41 @@ """ -Tests for minimum grade requirement status +Tests for minimum grade and credit requirement status """ +from datetime import datetime, timedelta, timezone +from unittest import mock +from uuid import uuid4 -from datetime import datetime, timedelta - -from unittest.mock import MagicMock import ddt import pytz from django.test.client import RequestFactory +from opaque_keys.edx.keys import UsageKey +from openedx_events.data import EventsMetadata +from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData +from openedx_events.learning.signals import ( + EXAM_ATTEMPT_ERRORED, + EXAM_ATTEMPT_REJECTED, + EXAM_ATTEMPT_RESET, + EXAM_ATTEMPT_SUBMITTED, + EXAM_ATTEMPT_VERIFIED +) from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.credit.api import get_credit_requirement_status, set_credit_requirements from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider -from openedx.core.djangoapps.credit.signals import listen_for_grade_calculation +from openedx.core.djangoapps.credit.signals.handlers import ( + listen_for_exam_errored, + listen_for_exam_rejected, + listen_for_exam_reset, + listen_for_exam_submitted, + listen_for_exam_verified, + listen_for_grade_calculation +) from openedx.core.djangolib.testing.utils import skip_unless_lms -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import \ + ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -76,7 +94,7 @@ def setUp(self): def assert_requirement_status(self, grade, due_date, expected_status): """ Verify the user's credit requirement status is as expected after simulating a grading calculation. """ - course_grade = MagicMock() + course_grade = mock.MagicMock() course_grade.percent = grade listen_for_grade_calculation(None, self.user, course_grade, self.course.id, due_date) req_status = get_credit_requirement_status(self.course.id, self.request.user.username, 'grade', 'grade') @@ -129,3 +147,146 @@ def test_requirement_failed_for_non_verified_enrollment(self, mode): """Test with valid grades submitted before deadline with non-verified enrollment.""" self.enrollment.update_enrollment(mode, True) self.assert_requirement_status(0.8, self.VALID_DUE_DATE, None) + + +@skip_unless_lms +@ddt.ddt +class TestExamEvents(ModuleStoreTestCase): + """ + Test exam events + """ + HANDLERS = { + EXAM_ATTEMPT_ERRORED: listen_for_exam_errored, + EXAM_ATTEMPT_REJECTED: listen_for_exam_rejected, + EXAM_ATTEMPT_RESET: listen_for_exam_reset, + EXAM_ATTEMPT_SUBMITTED: listen_for_exam_submitted, + EXAM_ATTEMPT_VERIFIED: listen_for_exam_verified, + } + + def setUp(self): + super().setUp() + self.course = CourseFactory.create( + org='TestX', number='999', display_name='Test Course' + ) + self.subsection_key = UsageKey.from_string('block-v1:edX+TestX+Test_Course+type@sequential+block@subsection') + + self.user = UserFactory() + + # Enable the course for credit + CreditCourse.objects.create( + course_key=self.course.id, + enabled=True, + ) + + @staticmethod + def _get_exam_event_data(user, course, usage_key): + """ create ExamAttemptData object for user """ + return ExamAttemptData( + student_user=UserData( + id=user.id, + is_active=True, + pii=UserPersonalData( + username=user.username, + email=user.email, + ), + ), + course_key=course.id, + usage_key=usage_key, + exam_type='timed', + requesting_user=None, + ) + + @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) + ) + + @mock.patch('openedx.core.djangoapps.credit.signals.handlers.remove_credit_requirement_status', autospec=True) + def test_exam_reset(self, mock_remove_credit_status): + """ + Test exam reset event + """ + event_data = self._get_exam_event_data(self.user, self.course, self.subsection_key) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET) + + listen_for_exam_reset(None, EXAM_ATTEMPT_RESET, event_metadata=event_metadata, exam_attempt=event_data) + + mock_remove_credit_status.assert_called_once_with( + self.user.username, self.course.id, 'exam', str(self.subsection_key) + ) + + @ddt.data( + (EXAM_ATTEMPT_ERRORED, 'failed'), + (EXAM_ATTEMPT_REJECTED, 'failed'), + (EXAM_ATTEMPT_VERIFIED, 'satisfied'), + (EXAM_ATTEMPT_SUBMITTED, 'submitted'), + ) + @ddt.unpack + @mock.patch('openedx.core.djangoapps.credit.signals.handlers.set_credit_requirement_status', autospec=True) + def test_exam_update_event(self, event_signal, expected_status, mock_set_credit_status): + """ + Test exam events that update credit status + """ + event_data = self._get_exam_event_data(self.user, self.course, self.subsection_key) + event_metadata = self._get_exam_event_metadata(event_signal) + + handler = self.HANDLERS.get(event_signal) + handler(None, event_signal, event_metadata=event_metadata, exam_attempt=event_data) + + mock_set_credit_status.assert_called_once_with( + self.user.username, self.course.id, 'exam', str(self.subsection_key), status=expected_status + ) + + @ddt.data( + EXAM_ATTEMPT_RESET, + EXAM_ATTEMPT_REJECTED, + EXAM_ATTEMPT_ERRORED, + EXAM_ATTEMPT_VERIFIED, + EXAM_ATTEMPT_SUBMITTED, + ) + def test_exam_event_bad_user(self, event_signal): + """ + Test exam event with a user that does not exist in the LMS + """ + self.user.id = 999 # don't save to db so user doesn't exist + event_data = self._get_exam_event_data(self.user, self.course, self.subsection_key) + event_metadata = self._get_exam_event_metadata(event_signal) + handler = self.HANDLERS.get(event_signal) + + with mock.patch('openedx.core.djangoapps.credit.signals.handlers.log.error') as mock_log: + handler(None, event_signal, event_metadata=event_metadata, exam_attempt=event_data) + mock_log.assert_called_once_with( + 'Error occurred while handling exam event for ' + f'{self.user.id} and content_id {self.subsection_key}. ' + 'User does not exist!' + ) + + @ddt.data( + EXAM_ATTEMPT_RESET, + EXAM_ATTEMPT_REJECTED, + EXAM_ATTEMPT_ERRORED, + EXAM_ATTEMPT_VERIFIED, + EXAM_ATTEMPT_SUBMITTED, + ) + @mock.patch('openedx.core.djangoapps.credit.signals.handlers.remove_credit_requirement_status', autospec=True) + @mock.patch('openedx.core.djangoapps.credit.signals.handlers.set_credit_requirement_status', autospec=True) + def test_exam_event_non_credit_course(self, event_signal, mock_remove_credit_status, mock_set_credit_status): + """ + Credit credit logic should not run on non-credit courses + """ + non_credit_course = CourseFactory.create() + event_data = self._get_exam_event_data(self.user, non_credit_course, self.subsection_key) + event_metadata = self._get_exam_event_metadata(event_signal) + handler = self.HANDLERS.get(event_signal) + + handler(None, event_signal, event_metadata=event_metadata, exam_attempt=event_data) + + mock_remove_credit_status.assert_not_called() + mock_set_credit_status.assert_not_called() diff --git a/openedx/core/djangoapps/credit/tests/test_tasks.py b/openedx/core/djangoapps/credit/tests/test_tasks.py index 7d520f22f3cb..47c4b50925e9 100644 --- a/openedx/core/djangoapps/credit/tests/test_tasks.py +++ b/openedx/core/djangoapps/credit/tests/test_tasks.py @@ -11,7 +11,7 @@ from openedx.core.djangoapps.credit.api import get_credit_requirements from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements from openedx.core.djangoapps.credit.models import CreditCourse -from openedx.core.djangoapps.credit.signals import on_course_publish +from openedx.core.djangoapps.credit.signals.handlers import on_course_publish from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order