From 15380e0a8d3a61a1c316bfad3f0f67e5e74e71bc Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Thu, 9 May 2024 18:52:36 +0300 Subject: [PATCH] feat: emit passing status updated events for badging (#34749) Introduce emission of the COURSE_PASSING_STATUS_UPDATED as well as CCX_COURSE_PASSING_STATUS_UPDATED events, that are groundwork for the new Credly integration and the future badging initiative. Product GH ticket for tracking - openedx/platform-roadmap#280 --- cms/envs/common.py | 36 +++++ lms/djangoapps/grades/events.py | 65 +++++++- lms/djangoapps/grades/tests/test_events.py | 164 ++++++++++++++++++++- lms/envs/common.py | 40 +++++ 4 files changed, 297 insertions(+), 8 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index e684946d74af..e505f8245726 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -583,6 +583,14 @@ # See annotations in lms/envs/common.py for details. 'ENABLE_BLAKE2B_HASHING': False, + + # .. toggle_name: FEATURES['BADGES_ENABLED'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Set to True to enable the Badges feature. + # .. toggle_use_cases: open_edx + # .. toggle_creation_date: 2024-04-10 + 'BADGES_ENABLED': False, } # .. toggle_name: ENABLE_COPPA_COMPLIANCE @@ -2881,6 +2889,10 @@ def _should_send_xblock_events(settings): return settings.FEATURES['ENABLE_SEND_XBLOCK_LIFECYCLE_EVENTS_OVER_BUS'] +def _should_send_learning_badge_events(settings): + return settings.FEATURES['BADGES_ENABLED'] + + # .. setting_name: EVENT_BUS_PRODUCER_CONFIG # .. setting_default: all events disabled # .. setting_description: Dictionary of event_types mapped to dictionaries of topic to topic-related configuration. @@ -2930,6 +2942,18 @@ def _should_send_xblock_events(settings): 'learning-certificate-lifecycle': {'event_key_field': 'certificate.course.course_key', 'enabled': False}, }, + "org.openedx.learning.course.passing.status.updated.v1": { + "learning-badges-lifecycle": { + "event_key_field": "course_passing_status.course.course_key", + "enabled": _should_send_learning_badge_events, + }, + }, + "org.openedx.learning.ccx.course.passing.status.updated.v1": { + "learning-badges-lifecycle": { + "event_key_field": "course_passing_status.course.ccx_course_key", + "enabled": _should_send_learning_badge_events, + }, + }, } @@ -2940,6 +2964,18 @@ def _should_send_xblock_events(settings): derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.content_authoring.xblock.deleted.v1', 'course-authoring-xblock-lifecycle', 'enabled') +derived_collection_entry( + "EVENT_BUS_PRODUCER_CONFIG", + "org.openedx.learning.course.passing.status.updated.v1", + "learning-badges-lifecycle", + "enabled", +) +derived_collection_entry( + "EVENT_BUS_PRODUCER_CONFIG", + "org.openedx.learning.ccx.course.passing.status.updated.v1", + "learning-badges-lifecycle", + "enabled", +) ################### Authoring API ###################### diff --git a/lms/djangoapps/grades/events.py b/lms/djangoapps/grades/events.py index 90279a3e69fe..51d1b13702f0 100644 --- a/lms/djangoapps/grades/events.py +++ b/lms/djangoapps/grades/events.py @@ -6,6 +6,15 @@ from crum import get_current_user from django.conf import settings from eventtracking import tracker +from openedx_events.learning.data import ( + CcxCourseData, + CcxCoursePassingStatusData, + CourseData, + CoursePassingStatusData, + UserData, + UserPersonalData +) +from openedx_events.learning.signals import CCX_COURSE_PASSING_STATUS_UPDATED, COURSE_PASSING_STATUS_UPDATED from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment @@ -174,8 +183,8 @@ def course_grade_passed_first_time(user_id, course_id): 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 . + Emits an edx.course.grade.now_passed and passing status updated events + 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) @@ -190,11 +199,13 @@ def course_grade_now_passed(user, course_id): } ) + _emit_course_passing_status_update(user, course_id, is_passing=True) + 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 . + Emits an edx.course.grade.now_failed and passing status updated events + 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) @@ -209,6 +220,8 @@ def course_grade_now_failed(user, course_id): } ) + _emit_course_passing_status_update(user, course_id, is_passing=False) + def fire_segment_event_on_course_grade_passed_first_time(user_id, course_locator): """ @@ -258,3 +271,47 @@ def fire_segment_event_on_course_grade_passed_first_time(user_id, course_locator ) log.info("Segment event fired for passed learners. Event: [{}], Data: [{}]".format(event_name, event_properties)) + + +def _emit_course_passing_status_update(user, course_id, is_passing): + """ + Emit course passing status event according to the course type. + The status of event is determined by is_passing parameter. + """ + if hasattr(course_id, 'ccx'): + CCX_COURSE_PASSING_STATUS_UPDATED.send_event( + course_passing_status=CcxCoursePassingStatusData( + is_passing=is_passing, + user=UserData( + pii=UserPersonalData( + username=user.username, + email=user.email, + name=user.get_full_name(), + ), + id=user.id, + is_active=user.is_active, + ), + course=CcxCourseData( + ccx_course_key=course_id, + master_course_key=course_id.to_course_locator(), + ), + ) + ) + else: + COURSE_PASSING_STATUS_UPDATED.send_event( + course_passing_status=CoursePassingStatusData( + is_passing=is_passing, + user=UserData( + pii=UserPersonalData( + username=user.username, + email=user.email, + name=user.get_full_name(), + ), + id=user.id, + is_active=user.is_active, + ), + course=CourseData( + course_key=course_id, + ), + ) + ) diff --git a/lms/djangoapps/grades/tests/test_events.py b/lms/djangoapps/grades/tests/test_events.py index b7843dfc1c1c..eac8cc9a4a70 100644 --- a/lms/djangoapps/grades/tests/test_events.py +++ b/lms/djangoapps/grades/tests/test_events.py @@ -4,16 +4,29 @@ from unittest import mock +from ccx_keys.locator import CCXLocator from django.utils.timezone import now from openedx_events.learning.data import ( + CcxCourseData, + CcxCoursePassingStatusData, CourseData, - PersistentCourseGradeData + CoursePassingStatusData, + PersistentCourseGradeData, + UserData, + UserPersonalData +) +from openedx_events.learning.signals import ( + CCX_COURSE_PASSING_STATUS_UPDATED, + COURSE_PASSING_STATUS_UPDATED, + PERSISTENT_GRADE_SUMMARY_CHANGED ) -from openedx_events.learning.signals import PERSISTENT_GRADE_SUMMARY_CHANGED from openedx_events.tests.utils import OpenEdxEventsTestMixin -from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from lms.djangoapps.ccx.models import CustomCourseForEdX +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.grades.models import PersistentCourseGrade +from lms.djangoapps.grades.tests.utils import mock_passing_grade from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -94,5 +107,148 @@ def test_persistent_grade_event_emitted(self): passed_timestamp=grade.passed_timestamp ) }, - event_receiver.call_args.kwargs + event_receiver.call_args.kwargs, + ) + + +class CoursePassingStatusEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): + """ + Tests for Open edX passing status update event. + """ + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.learning.course.passing.status.updated.v1", + ] + + @classmethod + def setUpClass(cls): + """ + Set up class method for the Test class. + """ + super().setUpClass() + cls.start_events_isolation() + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.user = UserFactory.create() + self.receiver_called = False + + def _event_receiver_side_effect(self, **kwargs): + """ + Used show that the Open edX Event was called by the Django signal handler. + """ + self.receiver_called = True + + def test_course_passing_status_updated_emitted(self): + """ + Test whether passing status updated event is sent after the grade is being updated for a user. + """ + event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect) + COURSE_PASSING_STATUS_UPDATED.connect(event_receiver) + grade_factory = CourseGradeFactory() + + with mock_passing_grade(): + grade_factory.update(self.user, self.course) + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": COURSE_PASSING_STATUS_UPDATED, + "sender": None, + "course_passing_status": CoursePassingStatusData( + is_passing=True, + user=UserData( + pii=UserPersonalData( + username=self.user.username, + email=self.user.email, + name=self.user.get_full_name(), + ), + id=self.user.id, + is_active=self.user.is_active, + ), + course=CourseData( + course_key=self.course.id, + ), + ), + }, + event_receiver.call_args.kwargs, + ) + + +class CCXCoursePassingStatusEventsTest( + SharedModuleStoreTestCase, OpenEdxEventsTestMixin +): + """ + Tests for Open edX passing status update event in a CCX course. + """ + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.learning.ccx.course.passing.status.updated.v1", + ] + + @classmethod + def setUpClass(cls): + """ + Set up class method for the Test class. + """ + super().setUpClass() + cls.start_events_isolation() + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.user = UserFactory.create() + self.coach = AdminFactory.create() + self.ccx = ccx = CustomCourseForEdX( + course_id=self.course.id, display_name="Test CCX", coach=self.coach + ) + ccx.save() + self.ccx_locator = CCXLocator.from_course_locator(self.course.id, ccx.id) + + self.receiver_called = False + + def _event_receiver_side_effect(self, **kwargs): + """ + Used show that the Open edX Event was called by the Django signal handler. + """ + self.receiver_called = True + + def test_ccx_course_passing_status_updated_emitted(self): + """ + Test whether passing status updated event is sent after the grade is being updated in CCX course. + """ + event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect) + CCX_COURSE_PASSING_STATUS_UPDATED.connect(event_receiver) + grade_factory = CourseGradeFactory() + + with mock_passing_grade(): + grade_factory.update(self.user, self.store.get_course(self.ccx_locator)) + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": CCX_COURSE_PASSING_STATUS_UPDATED, + "sender": None, + "course_passing_status": CcxCoursePassingStatusData( + is_passing=True, + user=UserData( + pii=UserPersonalData( + username=self.user.username, + email=self.user.email, + name=self.user.get_full_name(), + ), + id=self.user.id, + is_active=self.user.is_active, + ), + course=CcxCourseData( + ccx_course_key=self.ccx_locator, + master_course_key=self.course.id, + display_name="", + coach_email="", + start=None, + end=None, + max_students_allowed=self.ccx.max_student_enrollments_allowed, + ), + ), + }, + event_receiver.call_args.kwargs, ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 6acf880d4f4a..5f8714ca9cf7 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1076,6 +1076,15 @@ # .. toggle_warning: For consistency, keep the value in sync with the setting of the same name in the LMS and CMS. # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/34442 'ENABLE_BLAKE2B_HASHING': False, + + # .. toggle_name: FEATURES['BADGES_ENABLED'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Set to True to enable badges functionality. + # .. toggle_use_cases: open_edx + # .. toggle_creation_date: 2024-04-02 + # .. toggle_target_removal_date: None + 'BADGES_ENABLED': False, } # Specifies extra XBlock fields that should available when requested via the Course Blocks API @@ -5447,7 +5456,12 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring def _should_send_certificate_events(settings): return settings.FEATURES['SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS'] + #### Event bus producing #### + +def _should_send_learning_badge_events(settings): + return settings.FEATURES['BADGES_ENABLED'] + # .. setting_name: EVENT_BUS_PRODUCER_CONFIG # .. setting_default: all events disabled # .. setting_description: Dictionary of event_types mapped to dictionaries of topic to topic-related configuration. @@ -5520,11 +5534,37 @@ def _should_send_certificate_events(settings): 'course-authoring-xblock-lifecycle': {'event_key_field': 'xblock_info.usage_key', 'enabled': False}, }, + "org.openedx.learning.course.passing.status.updated.v1": { + "learning-badges-lifecycle": { + "event_key_field": "course_passing_status.course.course_key", + "enabled": _should_send_learning_badge_events, + }, + }, + "org.openedx.learning.ccx.course.passing.status.updated.v1": { + "learning-badges-lifecycle": { + "event_key_field": "course_passing_status.course.ccx_course_key", + "enabled": _should_send_learning_badge_events, + }, + }, } derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.certificate.created.v1', 'learning-certificate-lifecycle', 'enabled') derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.certificate.revoked.v1', 'learning-certificate-lifecycle', 'enabled') + +derived_collection_entry( + "EVENT_BUS_PRODUCER_CONFIG", + "org.openedx.learning.course.passing.status.updated.v1", + "learning-badges-lifecycle", + "enabled", +) +derived_collection_entry( + "EVENT_BUS_PRODUCER_CONFIG", + "org.openedx.learning.ccx.course.passing.status.updated.v1", + "learning-badges-lifecycle", + "enabled", +) + BEAMER_PRODUCT_ID = "" #### Survey Report ####