Merge pull request #28415 from edly-io/ERTE-77
feat: Emit course completion related events
This commit is contained in:
@@ -19,6 +19,9 @@ PROBLEM_SUBMITTED_EVENT_TYPE = 'edx.grades.problem.submitted'
|
||||
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())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ a student's score or the course grading policy changes. As they are
|
||||
persisted, course grades are also immune to changes in course content.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
import logging
|
||||
from base64 import b64encode
|
||||
@@ -28,6 +27,7 @@ from simple_history.models import HistoricalRecords
|
||||
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 @@ class PersistentCourseGrade(TimeStampedModel):
|
||||
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()
|
||||
|
||||
|
||||
@@ -33,7 +33,12 @@ from .signals import (
|
||||
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)
|
||||
|
||||
@@ -106,3 +106,14 @@ SUBSECTION_OVERRIDE_CHANGED = Signal(
|
||||
# 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
|
||||
]
|
||||
)
|
||||
|
||||
@@ -135,20 +135,33 @@ class GradesEventIntegrationTest(ProblemSubmissionTestMixin, SharedModuleStoreTe
|
||||
'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):
|
||||
|
||||
@@ -425,10 +425,12 @@ class PersistentCourseGradesTest(GradesModelTestCase):
|
||||
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)
|
||||
|
||||
@@ -12,7 +12,9 @@ import pytest
|
||||
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 @@ from ..signals.handlers import (
|
||||
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 @@ class ScoreChangedSignalRelayTest(TestCase):
|
||||
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()),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user