diff --git a/lms/djangoapps/grades/events.py b/lms/djangoapps/grades/events.py index 697674ee976a..ad5abea2b3e4 100644 --- a/lms/djangoapps/grades/events.py +++ b/lms/djangoapps/grades/events.py @@ -19,6 +19,9 @@ STATE_DELETED_EVENT_TYPE = 'edx.grades.problem.state_deleted' SUBSECTION_OVERRIDE_EVENT_TYPE = 'edx.grades.subsection.score_overridden' SUBSECTION_GRADE_CALCULATED = 'edx.grades.subsection.grade_calculated' +COURSE_GRADE_PASSED_FIRST_TIME_EVENT_TYPE = 'edx.course.grade.passed.first_time' +COURSE_GRADE_NOW_PASSED_EVENT_TYPE = 'edx.course.grade.now_passed' +COURSE_GRADE_NOW_FAILED_EVENT_TYPE = 'edx.course.grade.now_failed' def grade_updated(**kwargs): @@ -135,3 +138,61 @@ def course_grade_calculated(course_grade): 'grading_policy_hash': str(course_grade.grading_policy_hash), } ) + + +def course_grade_passed_first_time(user_id, course_id): + """ + Emits an event edx.course.grade.passed.first_time + with data from the passed course_grade. + """ + event_name = COURSE_GRADE_PASSED_FIRST_TIME_EVENT_TYPE + context = contexts.course_context_from_course_id(course_id) + # TODO (AN-6134): remove this context manager + with tracker.get_tracker().context(event_name, context): + tracker.emit( + event_name, + { + 'user_id': str(user_id), + 'course_id': str(course_id), + 'event_transaction_id': str(get_event_transaction_id()), + 'event_transaction_type': str(get_event_transaction_type()) + } + ) + + +def course_grade_now_passed(user, course_id): + """ + Emits an edx.course.grade.now_passed event + with data from the course and user passed now . + """ + event_name = COURSE_GRADE_NOW_PASSED_EVENT_TYPE + context = contexts.course_context_from_course_id(course_id) + with tracker.get_tracker().context(event_name, context): + tracker.emit( + event_name, + { + 'user_id': str(user.id), + 'course_id': str(course_id), + 'event_transaction_id': str(get_event_transaction_id()), + 'event_transaction_type': str(get_event_transaction_type()) + } + ) + + +def course_grade_now_failed(user, course_id): + """ + Emits an edx.course.grade.now_failed event + with data from the course and user failed now . + """ + event_name = COURSE_GRADE_NOW_FAILED_EVENT_TYPE + context = contexts.course_context_from_course_id(course_id) + with tracker.get_tracker().context(event_name, context): + tracker.emit( + event_name, + { + 'user_id': str(user.id), + 'course_id': str(course_id), + 'event_transaction_id': str(get_event_transaction_id()), + 'event_transaction_type': str(get_event_transaction_type()) + } + ) diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index e92bec883025..0d87873f20f9 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -8,7 +8,6 @@ persisted, course grades are also immune to changes in course content. """ - import json import logging from base64 import b64encode @@ -28,6 +27,7 @@ from lms.djangoapps.courseware.fields import UnsignedBigIntAutoField from lms.djangoapps.grades import events # lint-amnesty, pylint: disable=unused-import from openedx.core.lib.cache_utils import get_cache +from lms.djangoapps.grades.signals.signals import COURSE_GRADE_PASSED_FIRST_TIME log = logging.getLogger(__name__) @@ -645,6 +645,11 @@ def update_or_create(cls, user_id, course_id, **kwargs): defaults=kwargs ) if passed and not grade.passed_timestamp: + COURSE_GRADE_PASSED_FIRST_TIME.send( + sender=None, + course_id=course_id, + user_id=user_id + ) grade.passed_timestamp = now() grade.save() diff --git a/lms/djangoapps/grades/signals/handlers.py b/lms/djangoapps/grades/signals/handlers.py index 7bebf3af7855..14089d74ea56 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -33,7 +33,12 @@ PROBLEM_WEIGHTED_SCORE_CHANGED, SCORE_PUBLISHED, SUBSECTION_OVERRIDE_CHANGED, - SUBSECTION_SCORE_CHANGED + SUBSECTION_SCORE_CHANGED, + COURSE_GRADE_PASSED_FIRST_TIME +) +from openedx.core.djangoapps.signals.signals import ( + COURSE_GRADE_NOW_FAILED, + COURSE_GRADE_NOW_PASSED ) log = getLogger(__name__) @@ -264,3 +269,33 @@ def recalculate_course_and_subsection_grades(sender, user, course_key, countdown course_key=str(course_key) ) ) + + +@receiver(COURSE_GRADE_NOW_PASSED) +def listen_for_passing_grade(sender, user, course_id, **kwargs): # pylint: disable=unused-argument + """ + Listen for a signal indicating that the user has passed a course run. + + Emits an edx.course.grade.now_passed event + """ + events.course_grade_now_passed(user, course_id) + + +@receiver(COURSE_GRADE_NOW_FAILED) +def listen_for_failing_grade(sender, user, course_id, **kwargs): # pylint: disable=unused-argument + """ + Listen for a signal indicating that the user has failed a course run. + + Emits an edx.course.grade.now_failed event + """ + events.course_grade_now_failed(user, course_id) + + +@receiver(COURSE_GRADE_PASSED_FIRST_TIME) +def listen_for_course_grade_passed_first_time(sender, user_id, course_id, **kwargs): # pylint: disable=unused-argument + """ + Listen for a signal indicating that the user has passed course grade first time. + + Emits an event edx.course.grade.passed.first_time + """ + events.course_grade_passed_first_time(user_id, course_id) diff --git a/lms/djangoapps/grades/signals/signals.py b/lms/djangoapps/grades/signals/signals.py index 3354c94b45a5..3a569588caf1 100644 --- a/lms/djangoapps/grades/signals/signals.py +++ b/lms/djangoapps/grades/signals/signals.py @@ -106,3 +106,14 @@ # score that was created. ] ) + + +# This Signal indicates that the user has received a passing grade in the course for the first time. +# Any subsequent grade changes that may vary the passing/failing status will not re-trigger this event. +# Emits course grade passed first time event +COURSE_GRADE_PASSED_FIRST_TIME = Signal( + providing_args=[ + 'course_id', # Course object id + 'user_id', # User object id + ] +) diff --git a/lms/djangoapps/grades/tests/integration/test_events.py b/lms/djangoapps/grades/tests/integration/test_events.py index eb99fdeea823..86017b809dc3 100644 --- a/lms/djangoapps/grades/tests/integration/test_events.py +++ b/lms/djangoapps/grades/tests/integration/test_events.py @@ -135,20 +135,33 @@ def test_delete_student_state(self): 'event_transaction_type': events.STATE_DELETED_EVENT_TYPE, } ) - - events_tracker.emit.assert_called_with( - events.COURSE_GRADE_CALCULATED, - { - 'percent_grade': 0.0, - 'grading_policy_hash': 'ChVp0lHGQGCevD0t4njna/C44zQ=', - 'user_id': str(self.student.id), - 'letter_grade': '', - 'event_transaction_id': event_transaction_id, - 'event_transaction_type': events.STATE_DELETED_EVENT_TYPE, - 'course_id': str(self.course.id), - 'course_edited_timestamp': str(course.subtree_edited_on), - 'course_version': str(course.course_version), - } + events_tracker.emit.assert_has_calls( + [ + mock_call( + events.COURSE_GRADE_CALCULATED, + { + 'percent_grade': 0.0, + 'grading_policy_hash': 'ChVp0lHGQGCevD0t4njna/C44zQ=', + 'user_id': str(self.student.id), + 'letter_grade': '', + 'event_transaction_id': event_transaction_id, + 'event_transaction_type': events.STATE_DELETED_EVENT_TYPE, + 'course_id': str(self.course.id), + 'course_edited_timestamp': str(course.subtree_edited_on), + 'course_version': str(course.course_version), + } + ), + mock_call( + events.COURSE_GRADE_NOW_FAILED_EVENT_TYPE, + { + 'user_id': str(self.student.id), + 'event_transaction_id': event_transaction_id, + 'event_transaction_type': events.STATE_DELETED_EVENT_TYPE, + 'course_id': str(self.course.id), + } + ), + ], + any_order=True, ) def test_rescoring_events(self): diff --git a/lms/djangoapps/grades/tests/test_models.py b/lms/djangoapps/grades/tests/test_models.py index cdcccd751aa4..5405b03e9479 100644 --- a/lms/djangoapps/grades/tests/test_models.py +++ b/lms/djangoapps/grades/tests/test_models.py @@ -425,10 +425,12 @@ def test_passed_timestamp(self): assert grade.letter_grade == '' assert grade.passed_timestamp == passed_timestamp - def test_passed_timestamp_is_now(self): + @patch('lms.djangoapps.grades.signals.signals.COURSE_GRADE_PASSED_FIRST_TIME.send') + def test_passed_timestamp_is_now(self, mock): with freeze_time(now()): grade = PersistentCourseGrade.update_or_create(**self.params) assert now() == grade.passed_timestamp + self.assertEqual(mock.call_count, 1) def test_create_and_read_grade(self): created_grade = PersistentCourseGrade.update_or_create(**self.params) diff --git a/lms/djangoapps/grades/tests/test_signals.py b/lms/djangoapps/grades/tests/test_signals.py index f3f079caedee..4694c2429971 100644 --- a/lms/djangoapps/grades/tests/test_signals.py +++ b/lms/djangoapps/grades/tests/test_signals.py @@ -12,7 +12,9 @@ import pytz from django.test import TestCase from submissions.models import score_reset, score_set +from opaque_keys.edx.locator import CourseLocator +from common.djangoapps.track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type from common.djangoapps.util.date_utils import to_timestamp from ..constants import ScoreDatabaseTableEnum @@ -20,7 +22,10 @@ disconnect_submissions_signal_receiver, problem_raw_score_changed_handler, submissions_score_reset_handler, - submissions_score_set_handler + submissions_score_set_handler, + listen_for_course_grade_passed_first_time, + listen_for_passing_grade, + listen_for_failing_grade ) from ..signals.signals import PROBLEM_RAW_SCORE_CHANGED @@ -259,3 +264,124 @@ def test_disconnect_manager_bad_arg(self): with pytest.raises(ValueError): with disconnect_submissions_signal_receiver(PROBLEM_RAW_SCORE_CHANGED): pass + + +class CourseEventsSignalsTest(TestCase): + """ + Tests to ensure that the courseware module correctly catches + course grades passed/failed signal and emit course related event + """ + SIGNALS = { + 'score_set': score_set, + 'score_reset': score_reset, + } + + def setUp(self): + """ + Configure mocks for all the dependencies of the render method + """ + super().setUp() + self.signal_mock = self.setup_patch( + 'lms.djangoapps.grades.signals.signals.COURSE_GRADE_PASSED_FIRST_TIME.send', + None, + ) + self.user_mock = MagicMock() + self.user_mock.id = 42 + self.get_user_mock = self.setup_patch( + 'lms.djangoapps.grades.signals.handlers.user_by_anonymous_id', + self.user_mock + ) + self.course_id = CourseLocator( + org='some_org', + course='some_course', + run='some_run' + ) + + def setup_patch(self, function_name, return_value): + """ + Patch a function with a given return value, and return the mock + """ + mock = MagicMock(return_value=return_value) + new_patch = patch(function_name, new=mock) + new_patch.start() + self.addCleanup(new_patch.stop) + return mock + + def test_course_grade_passed_first_time_signal_handler(self): + """ + Ensure that on course grade passed first tim signal, course grade passed first time event is triggered + """ + handler = listen_for_course_grade_passed_first_time + + with patch('lms.djangoapps.grades.events.tracker') as tracker_mock: + handler(None, self.user_mock.id, self.course_id) + self._assert_tracker_emitted_course_grade_passed_first_time_event( + tracker_mock, + self.user_mock.id, + self.course_id + ) + + def _assert_tracker_emitted_course_grade_passed_first_time_event(self, tracker_mock, user_id, course_id): + """ + Helper function to ensure that the mocked event tracker + was called with the expected info based on the course grade passed first time. + """ + tracker_mock.emit.assert_called_with( + 'edx.course.grade.passed.first_time', + { + 'user_id': str(user_id), + 'course_id': str(course_id), + 'event_transaction_id': str(get_event_transaction_id()), + 'event_transaction_type': str(get_event_transaction_type()), + } + ) + + def test_now_passed_signal_handler(self): + """ + Ensure that on course now passed signal, course now passed event is triggered + """ + handler = listen_for_passing_grade + + with patch('lms.djangoapps.grades.events.tracker') as tracker_mock: + handler(None, self.user_mock, self.course_id) + self._assert_tracker_emitted_course_now_passed_event(tracker_mock, self.user_mock, self.course_id) + + def _assert_tracker_emitted_course_now_passed_event(self, tracker_mock, user, course_id): + """ + Helper function to ensure that the mocked event tracker + was called with the expected info based on passed course. + """ + tracker_mock.emit.assert_called_with( + 'edx.course.grade.now_passed', + { + 'user_id': str(user.id), + 'course_id': str(course_id), + 'event_transaction_id': str(get_event_transaction_id()), + 'event_transaction_type': str(get_event_transaction_type()), + } + ) + + def test_now_failed_signal_handler(self): + """ + Ensure that on course now failed signal, course now failed event is triggered + """ + handler = listen_for_failing_grade + + with patch('lms.djangoapps.grades.events.tracker') as tracker_mock: + handler(None, self.user_mock, self.course_id) + self._assert_tracker_emitted_course_now_failed_event(tracker_mock, self.user_mock, self.course_id) + + def _assert_tracker_emitted_course_now_failed_event(self, tracker_mock, user, course_id): + """ + Helper function to ensure that the mocked event tracker + was called with the expected info based on failed course. + """ + tracker_mock.emit.assert_called_with( + 'edx.course.grade.now_failed', + { + 'user_id': str(user.id), + 'course_id': str(course_id), + 'event_transaction_id': str(get_event_transaction_id()), + 'event_transaction_type': str(get_event_transaction_type()), + } + )