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
This commit is contained in:
@@ -583,6 +583,14 @@ FEATURES = {
|
||||
|
||||
# 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 @@ EVENT_BUS_PRODUCER_CONFIG = {
|
||||
'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 @@ derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.content_autho
|
||||
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 ######################
|
||||
|
||||
|
||||
@@ -6,6 +6,15 @@ from logging import getLogger
|
||||
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,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,16 +4,29 @@ Test that various events are fired for models in the grades app.
|
||||
|
||||
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 @@ class PersistentGradeEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixi
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1076,6 +1076,15 @@ FEATURES = {
|
||||
# .. 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 @@ SIMPLE_HISTORY_DATE_INDEX = False
|
||||
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 @@ EVENT_BUS_PRODUCER_CONFIG = {
|
||||
'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 ####
|
||||
|
||||
Reference in New Issue
Block a user