Skip to content

Commit

Permalink
feat: send course role events to the event bus (#34158)
Browse files Browse the repository at this point in the history
Notify the event bus when a user's role in a course is added or removed
  • Loading branch information
zacharis278 authored Feb 13, 2024
1 parent 57b480b commit 2f2ed4d
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 12 deletions.
2 changes: 2 additions & 0 deletions common/djangoapps/student/signals/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
32 changes: 30 additions & 2 deletions common/djangoapps/student/signals/receivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,26 @@
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,
is_email_retired,
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

Expand Down Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions common/djangoapps/student/signals/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

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.

Expand All @@ -21,3 +25,45 @@
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,
)
)
131 changes: 121 additions & 10 deletions common/djangoapps/student/tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,37 @@


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


Expand Down Expand Up @@ -377,3 +382,109 @@ def test_unenrollment_completed_event_emitted(self):
},
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
)
8 changes: 8 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5455,6 +5455,14 @@ def _should_send_certificate_events(settings):
# .. 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.
Expand Down
9 changes: 9 additions & 0 deletions lms/envs/devstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,15 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing
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/"
Expand Down

0 comments on commit 2f2ed4d

Please sign in to comment.