Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions common/djangoapps/student/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@
from slumber.exceptions import HttpClientError, HttpServerError
from user_util import user_util

from openedx_events.learning.data import (
CourseData,
CourseEnrollmentData,
UserData,
UserPersonalData,
)
from openedx_events.learning.signals import (
COURSE_ENROLLMENT_CHANGED,
COURSE_ENROLLMENT_CREATED,
COURSE_UNENROLLMENT_COMPLETED,
)
import openedx.core.djangoapps.django_comment_common.comment_client as cc
from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price
from common.djangoapps.student.emails import send_proctoring_requirements_email
Expand Down Expand Up @@ -1411,6 +1422,16 @@ def update_enrollment(self, mode=None, is_active=None, skip_refund=False):
self.mode = mode
mode_changed = True

try:
course_data = CourseData(
course_key=self.course_id,
display_name=self.course.display_name,
)
except CourseOverview.DoesNotExist:
course_data = CourseData(
course_key=self.course_id,
)

if activation_changed or mode_changed:
self.save()
self._update_enrollment_in_request_cache(
Expand All @@ -1419,6 +1440,24 @@ def update_enrollment(self, mode=None, is_active=None, skip_refund=False):
CourseEnrollmentState(self.mode, self.is_active),
)

COURSE_ENROLLMENT_CHANGED.send_event(
enrollment=CourseEnrollmentData(
user=UserData(
pii=UserPersonalData(
username=self.user.username,
email=self.user.email,
name=self.user.profile.name,
),
id=self.user.id,
is_active=self.user.is_active,
),
course=course_data,
mode=self.mode,
is_active=self.is_active,
creation_date=self.created,
)
)

if activation_changed:
if self.is_active:
self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED)
Expand All @@ -1427,6 +1466,24 @@ def update_enrollment(self, mode=None, is_active=None, skip_refund=False):
self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED)
self.send_signal(EnrollStatusChange.unenroll)

COURSE_UNENROLLMENT_COMPLETED.send_event(
enrollment=CourseEnrollmentData(
user=UserData(
pii=UserPersonalData(
username=self.user.username,
email=self.user.email,
name=self.user.profile.name,
),
id=self.user.id,
is_active=self.user.is_active,
),
course=course_data,
mode=self.mode,
is_active=self.is_active,
creation_date=self.created,
)
)

if mode_changed:
if COURSEWARE_PROCTORING_IMPROVEMENTS.is_enabled(self.course_id):
# If mode changed to one that requires proctoring, send proctoring requirements email
Expand Down Expand Up @@ -1562,9 +1619,16 @@ def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=Fal
# All the server-side checks for whether a user is allowed to enroll.
try:
course = CourseOverview.get_from_id(course_key)
course_data = CourseData(
course_key=course.id,
display_name=course.display_name,
)
except CourseOverview.DoesNotExist:
# This is here to preserve legacy behavior which allowed enrollment in courses
# announced before the start of content creation.
course_data = CourseData(
course_key=course_key,
)
if check_access:
log.warning("User %s failed to enroll in non-existent course %s", user.username, str(course_key))
raise NonExistentCourseError # lint-amnesty, pylint: disable=raise-missing-from
Expand Down Expand Up @@ -1600,6 +1664,25 @@ def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=Fal
enrollment.update_enrollment(is_active=True, mode=mode)
enrollment.send_signal(EnrollStatusChange.enroll)

# Announce user's enrollment
COURSE_ENROLLMENT_CREATED.send_event(
enrollment=CourseEnrollmentData(
user=UserData(
pii=UserPersonalData(
username=user.username,
email=user.email,
name=user.profile.name,
),
id=user.id,
is_active=user.is_active,
),
course=course_data,
mode=enrollment.mode,
is_active=enrollment.is_active,
creation_date=enrollment.created,
)
)

return enrollment

@classmethod
Expand Down
12 changes: 11 additions & 1 deletion common/djangoapps/student/tests/test_enrollment.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.conf import settings
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from openedx_events.tests.utils import OpenEdxEventsTestMixin

from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
Expand All @@ -33,19 +34,28 @@
@override_waffle_flag(COURSEWARE_PROCTORING_IMPROVEMENTS, active=True)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class EnrollmentTest(UrlResetMixin, SharedModuleStoreTestCase):
class EnrollmentTest(UrlResetMixin, SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
"""
Test student enrollment, especially with different course modes.
"""

ENABLED_OPENEDX_EVENTS = []

USERNAME = "Bob"
EMAIL = "bob@example.com"
PASSWORD = "edx"
URLCONF_MODULES = ['openedx.core.djangoapps.embargo']

@classmethod
def setUpClass(cls):
"""
Set up class method for the Test class.

This method starts manually events isolation. Explanation here:
openedx/core/djangoapps/user_authn/views/tests/test_events.py#L44
"""
super().setUpClass()
cls.start_events_isolation()
cls.course = CourseFactory.create()
cls.course_limited = CourseFactory.create()
cls.proctored_course = CourseFactory(
Expand Down
197 changes: 195 additions & 2 deletions common/djangoapps/student/tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,27 @@
from django.test import TestCase
from django_countries.fields import Country

from common.djangoapps.student.models import CourseEnrollmentAllowed
from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory
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 openedx_events.learning.data import (
CourseData,
CourseEnrollmentData,
UserData,
UserPersonalData,
)
from openedx_events.learning.signals import (
COURSE_ENROLLMENT_CHANGED,
COURSE_ENROLLMENT_CREATED,
COURSE_UNENROLLMENT_COMPLETED,
)
from openedx_events.tests.utils import OpenEdxEventsTestMixin
from openedx.core.djangolib.testing.utils import skip_unless_lms

from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory


class TestUserProfileEvents(UserSettingsEventTestMixin, TestCase):
"""
Expand Down Expand Up @@ -180,3 +197,179 @@ def test_enrolled_after_email_change(self):
# CEAs shouldn't have been affected
assert CourseEnrollmentAllowed.objects.count() == 1
assert CourseEnrollmentAllowed.objects.filter(email='test@edx.org').count() == 1


@skip_unless_lms
class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
"""
Tests for the Open edX Events associated with the enrollment process through the enroll method.

This class guarantees that the following events are sent during the user's enrollment, with
the exact Data Attributes as the event definition stated:

- COURSE_ENROLLMENT_CREATED: sent after the user's enrollment.
- COURSE_ENROLLMENT_CHANGED: sent after the enrollment update.
- COURSE_UNENROLLMENT_COMPLETED: sent after the user's unenrollment.
"""

ENABLED_OPENEDX_EVENTS = [
"org.openedx.learning.course.enrollment.created.v1",
"org.openedx.learning.course.enrollment.changed.v1",
"org.openedx.learning.course.unenrollment.completed.v1",
]

@classmethod
def setUpClass(cls):
"""
Set up class method for the Test class.

This method starts manually events isolation. Explanation here:
openedx/core/djangoapps/user_authn/views/tests/test_events.py#L44
"""
super().setUpClass()
cls.start_events_isolation()

def setUp(self): # pylint: disable=arguments-differ
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create(
username="test",
email="test@example.com",
password="password",
)
self.user_profile = UserProfileFactory.create(user=self.user, name="Test Example")
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

def test_enrollment_created_event_emitted(self):
"""
Test whether the student enrollment event is sent after the user's
enrollment process.

Expected result:
- COURSE_ENROLLMENT_CREATED is sent and received by the mocked receiver.
- The arguments that the receiver gets are the arguments sent by the event
except the metadata generated on the fly.
"""
event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect)
COURSE_ENROLLMENT_CREATED.connect(event_receiver)

enrollment = CourseEnrollment.enroll(self.user, self.course.id)

self.assertTrue(self.receiver_called)
self.assertDictContainsSubset(
{
"signal": COURSE_ENROLLMENT_CREATED,
"sender": None,
"enrollment": CourseEnrollmentData(
user=UserData(
pii=UserPersonalData(
username=self.user.username,
email=self.user.email,
name=self.user.profile.name,
),
id=self.user.id,
is_active=self.user.is_active,
),
course=CourseData(
course_key=self.course.id,
display_name=self.course.display_name,
),
mode=enrollment.mode,
is_active=enrollment.is_active,
creation_date=enrollment.created,
),
},
event_receiver.call_args.kwargs
)

def test_enrollment_changed_event_emitted(self):
"""
Test whether the student enrollment changed event is sent after the enrollment
update process ends.

Expected result:
- COURSE_ENROLLMENT_CHANGED is sent and received by the mocked receiver.
- The arguments that the receiver gets are the arguments sent by the event
except the metadata generated on the fly.
"""
enrollment = CourseEnrollment.enroll(self.user, self.course.id)
event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect)
COURSE_ENROLLMENT_CHANGED.connect(event_receiver)

enrollment.update_enrollment(mode="verified")

self.assertTrue(self.receiver_called)
self.assertDictContainsSubset(
{
"signal": COURSE_ENROLLMENT_CHANGED,
"sender": None,
"enrollment": CourseEnrollmentData(
user=UserData(
pii=UserPersonalData(
username=self.user.username,
email=self.user.email,
name=self.user.profile.name,
),
id=self.user.id,
is_active=self.user.is_active,
),
course=CourseData(
course_key=self.course.id,
display_name=self.course.display_name,
),
mode=enrollment.mode,
is_active=enrollment.is_active,
creation_date=enrollment.created,
),
},
event_receiver.call_args.kwargs
)

def test_unenrollment_completed_event_emitted(self):
"""
Test whether the student un-enrollment completed event is sent after the
user's unenrollment process.

Expected result:
- COURSE_UNENROLLMENT_COMPLETED is sent and received by the mocked receiver.
- The arguments that the receiver gets are the arguments sent by the event
except the metadata generated on the fly.
"""
enrollment = CourseEnrollment.enroll(self.user, self.course.id)
event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect)
COURSE_UNENROLLMENT_COMPLETED.connect(event_receiver)

CourseEnrollment.unenroll(self.user, self.course.id)

self.assertTrue(self.receiver_called)
self.assertDictContainsSubset(
{
"signal": COURSE_UNENROLLMENT_COMPLETED,
"sender": None,
"enrollment": CourseEnrollmentData(
user=UserData(
pii=UserPersonalData(
username=self.user.username,
email=self.user.email,
name=self.user.profile.name,
),
id=self.user.id,
is_active=self.user.is_active,
),
course=CourseData(
course_key=self.course.id,
display_name=self.course.display_name,
),
mode=enrollment.mode,
is_active=False,
creation_date=enrollment.created,
),
},
event_receiver.call_args.kwargs
)
Loading