diff --git a/common/djangoapps/student/signals/__init__.py b/common/djangoapps/student/signals/__init__.py index 9d134198f0..06ac888896 100644 --- a/common/djangoapps/student/signals/__init__.py +++ b/common/djangoapps/student/signals/__init__.py @@ -1,6 +1,8 @@ # lint-amnesty, pylint: disable=missing-module-docstring from common.djangoapps.student.signals.signals import ( + emit_course_access_role_added, + emit_course_access_role_removed, ENROLL_STATUS_CHANGE, ENROLLMENT_TRACK_UPDATED, REFUND_ORDER, diff --git a/common/djangoapps/student/signals/receivers.py b/common/djangoapps/student/signals/receivers.py index 079647c145..82300f4e3a 100644 --- a/common/djangoapps/student/signals/receivers.py +++ b/common/djangoapps/student/signals/receivers.py @@ -8,13 +8,14 @@ from asyncio.log import logger from django.conf import settings from django.contrib.auth import get_user_model from django.db import IntegrityError -from django.db.models.signals import post_save, pre_save +from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver from lms.djangoapps.courseware.toggles import courseware_mfe_progress_milestones_are_active from lms.djangoapps.utils import get_braze_client from common.djangoapps.student.helpers import EMAIL_EXISTS_MSG_FMT, USERNAME_EXISTS_MSG_FMT, AccountValidationError from common.djangoapps.student.models import ( + CourseAccessRole, CourseEnrollment, CourseEnrollmentCelebration, PendingNameChange, @@ -22,7 +23,11 @@ from common.djangoapps.student.models import ( is_username_retired ) from common.djangoapps.student.models_api import confirm_name_change -from common.djangoapps.student.signals import USER_EMAIL_CHANGED +from common.djangoapps.student.signals import ( + emit_course_access_role_added, + emit_course_access_role_removed, + USER_EMAIL_CHANGED, +) from openedx.core.djangoapps.safe_sessions.middleware import EmailChangeMiddleware from openedx.features.name_affirmation_api.utils import is_name_affirmation_installed @@ -87,6 +92,29 @@ def create_course_enrollment_celebration(sender, instance, created, **kwargs): pass +@receiver(post_save, sender=CourseAccessRole) +def on_course_access_role_created(sender, instance, created, **kwargs): + """ + Emit an event to the event-bus when a CourseAccessRole is created + """ + # Updating a role instance to a different role is unhandled behavior at the moment + # this event assumes roles are only created or deleted + if not created: + return + + user = instance.user + emit_course_access_role_added(user, instance.course_id, instance.org, instance.role) + + +@receiver(post_delete, sender=CourseAccessRole) +def listen_for_course_access_role_removed(sender, instance, **kwargs): + """ + Emit an event to the event-bus when a CourseAccessRole is deleted + """ + user = instance.user + emit_course_access_role_removed(user, instance.course_id, instance.org, instance.role) + + def listen_for_verified_name_approved(sender, user_id, profile_name, **kwargs): """ If the user has a pending name change that corresponds to an approved verified name, confirm it. diff --git a/common/djangoapps/student/signals/signals.py b/common/djangoapps/student/signals/signals.py index 49745ca48c..15ccbe5cc0 100644 --- a/common/djangoapps/student/signals/signals.py +++ b/common/djangoapps/student/signals/signals.py @@ -5,6 +5,10 @@ Enrollment track related signals. from django.dispatch import Signal +from openedx_events.learning.data import CourseAccessRoleData, UserData, UserPersonalData +from openedx_events.learning.signals import COURSE_ACCESS_ROLE_ADDED, COURSE_ACCESS_ROLE_REMOVED + + # The purely documentational providing_args argument for Signal is deprecated. # So we are moving the args to a comment. @@ -21,3 +25,45 @@ ENROLL_STATUS_CHANGE = Signal() REFUND_ORDER = Signal() USER_EMAIL_CHANGED = Signal() + + +def emit_course_access_role_added(user, course_id, org_key, role): + """ + Emit an event to the event-bus when a CourseAccessRole is added + """ + COURSE_ACCESS_ROLE_ADDED.send_event( + course_access_role_data=CourseAccessRoleData( + user=UserData( + pii=UserPersonalData( + username=user.username, + email=user.email, + ), + id=user.id, + is_active=user.is_active, + ), + course_key=course_id, + org_key=org_key, + role=role, + ) + ) + + +def emit_course_access_role_removed(user, course_id, org_key, role): + """ + Emit an event to the event-bus when a CourseAccessRole is deleted + """ + COURSE_ACCESS_ROLE_REMOVED.send_event( + course_access_role_data=CourseAccessRoleData( + user=UserData( + pii=UserPersonalData( + username=user.username, + email=user.email, + ), + id=user.id, + is_active=user.is_active, + ), + course_key=course_id, + org_key=org_key, + role=role, + ) + ) diff --git a/common/djangoapps/student/tests/test_events.py b/common/djangoapps/student/tests/test_events.py index fe11c4be7f..f336396e6e 100644 --- a/common/djangoapps/student/tests/test_events.py +++ b/common/djangoapps/student/tests/test_events.py @@ -4,32 +4,37 @@ Test that various events are fired for models in the student app. from unittest import mock -import pytest +import ddt +import pytest from django.db.utils import IntegrityError from django.test import TestCase from django_countries.fields import Country - -from common.djangoapps.student.models import CourseEnrollmentAllowed, CourseEnrollment -from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory, UserProfileFactory -from common.djangoapps.student.tests.tests import UserSettingsEventTestMixin - +from opaque_keys.edx.keys import CourseKey from openedx_events.learning.data import ( # lint-amnesty, pylint: disable=wrong-import-order + CourseAccessRoleData, CourseData, CourseEnrollmentData, UserData, - UserPersonalData, + UserPersonalData ) from openedx_events.learning.signals import ( # lint-amnesty, pylint: disable=wrong-import-order + COURSE_ACCESS_ROLE_ADDED, + COURSE_ACCESS_ROLE_REMOVED, COURSE_ENROLLMENT_CHANGED, COURSE_ENROLLMENT_CREATED, - COURSE_UNENROLLMENT_COMPLETED, + COURSE_UNENROLLMENT_COMPLETED ) from openedx_events.tests.utils import OpenEdxEventsTestMixin # lint-amnesty, pylint: disable=wrong-import-order + +from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory, UserProfileFactory +from common.djangoapps.student.tests.tests import UserSettingsEventTestMixin from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangolib.testing.utils import skip_unless_lms - -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import \ + SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -377,3 +382,109 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): }, event_receiver.call_args.kwargs ) + + +@skip_unless_lms +@ddt.ddt +class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin): + """ + Tests for the events associated with the CourseAccessRole model. + """ + ENABLED_OPENEDX_EVENTS = [ + 'org.openedx.learning.user.course_access_role.added.v1', + 'org.openedx.learning.user.course_access_role.removed.v1', + ] + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.start_events_isolation() + + def setUp(self): + self.course_key = CourseKey.from_string("course-v1:test+blah+blah") + self.user = UserFactory.create( + username="test", + email="test@example.com", + password="password", + ) + self.receiver_called = False + + def _event_receiver_side_effect(self, **kwargs): # pylint: disable=unused-argument + """ + Used show that the Open edX Event was called by the Django signal handler. + """ + self.receiver_called = True + + @ddt.data( + CourseStaffRole, + CourseInstructorRole, + ) + def test_access_role_created_event_emitted(self, AccessRole): + """ + Event is emitted with the correct data when a CourseAccessRole is created. + """ + event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect) + COURSE_ACCESS_ROLE_ADDED.connect(event_receiver) + + role = AccessRole(self.course_key) + role.add_users(self.user) + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": COURSE_ACCESS_ROLE_ADDED, + "sender": None, + "course_access_role_data": CourseAccessRoleData( + user=UserData( + pii=UserPersonalData( + username=self.user.username, + email=self.user.email, + ), + id=self.user.id, + is_active=self.user.is_active, + ), + course_key=self.course_key, + org_key=self.course_key.org, + role=role._role_name, # pylint: disable=protected-access + ), + }, + event_receiver.call_args.kwargs + ) + + @ddt.data( + CourseStaffRole, + CourseInstructorRole, + ) + def test_access_role_removed_event_emitted(self, AccessRole): + """ + Event is emitted with the correct data when a CourseAccessRole is deleted. + """ + role = AccessRole(self.course_key) + role.add_users(self.user) + + # connect mock only after initial role is added + event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect) + COURSE_ACCESS_ROLE_REMOVED.connect(event_receiver) + role.remove_users(self.user) + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": COURSE_ACCESS_ROLE_REMOVED, + "sender": None, + "course_access_role_data": CourseAccessRoleData( + user=UserData( + pii=UserPersonalData( + username=self.user.username, + email=self.user.email, + ), + id=self.user.id, + is_active=self.user.is_active, + ), + course_key=self.course_key, + org_key=self.course_key.org, + role=role._role_name, # pylint: disable=protected-access + ), + }, + event_receiver.call_args.kwargs + ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 2650198145..1b305f0059 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5455,6 +5455,14 @@ EVENT_BUS_PRODUCER_CONFIG = { # .. toggle_tickets: https://github.com/openedx/openedx-events/issues/210 'enabled': False} }, + 'org.openedx.learning.user.course_access_role.added.v1': { + 'learning-course-access-role-lifecycle': + {'event_key_field': 'course_access_role_data.course_key', 'enabled': False}, + }, + 'org.openedx.learning.user.course_access_role.removed.v1': { + 'learning-course-access-role-lifecycle': + {'event_key_field': 'course_access_role_data.course_key', 'enabled': False}, + }, # CMS events. These have to be copied over here because cms.common adds some derived entries as well, # and the derivation fails if the keys are missing. If we ever fully decouple the lms and cms settings, # we can remove these. diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index a6fd11c0c6..6e3f29567f 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -521,6 +521,15 @@ certificate_revoked_event_config['learning-certificate-lifecycle']['enabled'] = certificate_created_event_config = EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.certificate.created.v1'] certificate_created_event_config['learning-certificate-lifecycle']['enabled'] = True +course_access_role_added_event_setting = EVENT_BUS_PRODUCER_CONFIG[ + 'org.openedx.learning.user.course_access_role.added.v1' +] +course_access_role_added_event_setting['learning-course-access-role-lifecycle']['enabled'] = True +course_access_role_removed_event_setting = EVENT_BUS_PRODUCER_CONFIG[ + 'org.openedx.learning.user.course_access_role.removed.v1' +] +course_access_role_removed_event_setting['learning-course-access-role-lifecycle']['enabled'] = True + ######################## Subscriptions API SETTINGS ######################## SUBSCRIPTIONS_ROOT_URL = "http://host.docker.internal:18750" SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/"