From 1c447e61763b3e1487a55bd985f2a903c016d56b Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 2 Sep 2021 14:41:46 -0400 Subject: [PATCH] feat: add 2nd batch of Open edX Events * Add COURSE_ENROLLMENT_CHANGED: sent after the enrollment update * Add COURSE_UNENROLLMENT_COMPLETED: sent after the user's unenrollment * Add CERTIFICATE_CREATED after the user's certificate generation has been completed * Add CERTIFICATE_CHANGED: after the certification update has been completed * Add CERTIFICATE_REVOKED: after the certificate revocation has been completed * Add COHORT_MEMBERSHIP_CHANGED: when a cohort membership update ends --- common/djangoapps/student/models.py | 52 +++- .../djangoapps/student/tests/test_events.py | 100 +++++++- lms/djangoapps/certificates/models.py | 70 ++++++ .../certificates/tests/test_events.py | 227 ++++++++++++++++++ .../certificates/tests/test_models.py | 108 ++++++++- .../core/djangoapps/course_groups/models.py | 21 ++ .../course_groups/tests/test_cohorts.py | 16 +- .../course_groups/tests/test_events.py | 108 +++++++++ requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/testing.txt | 2 +- 11 files changed, 694 insertions(+), 14 deletions(-) create mode 100644 lms/djangoapps/certificates/tests/test_events.py create mode 100644 openedx/core/djangoapps/course_groups/tests/test_events.py diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 30a39091fc02..4dbd1995d809 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -61,7 +61,11 @@ UserData, UserPersonalData, ) -from openedx_events.learning.signals import COURSE_ENROLLMENT_CREATED +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 @@ -1417,6 +1421,16 @@ def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterp 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( @@ -1425,6 +1439,24 @@ def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterp 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, enterprise_uuid=enterprise_uuid) @@ -1433,6 +1465,24 @@ def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterp 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 mode changed to one that requires proctoring, send proctoring requirements email if should_send_proctoring_requirements_email(self.user.username, self.course_id): diff --git a/common/djangoapps/student/tests/test_events.py b/common/djangoapps/student/tests/test_events.py index 00b0b62784cb..2d3f17b0cfd2 100644 --- a/common/djangoapps/student/tests/test_events.py +++ b/common/djangoapps/student/tests/test_events.py @@ -20,7 +20,11 @@ UserData, UserPersonalData, ) -from openedx_events.learning.signals import COURSE_ENROLLMENT_CREATED +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 @@ -203,9 +207,15 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): 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"] + 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): @@ -276,3 +286,89 @@ def test_enrollment_created_event_emitted(self): }, 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 + ) diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index b5ff0ad29c0c..e77e0e39908b 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -35,6 +35,9 @@ from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED, COURSE_CERT_REVOKED from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager +from openedx_events.learning.data import CourseData, UserData, UserPersonalData, CertificateData +from openedx_events.learning.signals import CERTIFICATE_CHANGED, CERTIFICATE_CREATED, CERTIFICATE_REVOKED + log = logging.getLogger(__name__) User = get_user_model() @@ -391,6 +394,28 @@ def _revoke_certificate(self, status, mode=None, grade=None, source=None): status=self.status, ) + CERTIFICATE_REVOKED.send_event( + certificate=CertificateData( + 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, + ), + mode=self.mode, + grade=self.grade, + current_status=self.status, + download_url=self.download_url, + name=self.name, + ) + ) + if previous_certificate_status == CertificateStatuses.downloadable: # imported here to avoid a circular import issue from lms.djangoapps.certificates.utils import emit_certificate_event @@ -446,6 +471,29 @@ def save(self, *args, **kwargs): # pylint: disable=signature-differs mode=self.mode, status=self.status, ) + + CERTIFICATE_CHANGED.send_event( + certificate=CertificateData( + 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, + ), + mode=self.mode, + grade=self.grade, + current_status=self.status, + download_url=self.download_url, + name=self.name, + ) + ) + if CertificateStatuses.is_passing_status(self.status): COURSE_CERT_AWARDED.send_robust( sender=self.__class__, @@ -455,6 +503,28 @@ def save(self, *args, **kwargs): # pylint: disable=signature-differs status=self.status, ) + CERTIFICATE_CREATED.send_event( + certificate=CertificateData( + 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, + ), + mode=self.mode, + grade=self.grade, + current_status=self.status, + download_url=self.download_url, + name=self.name, + ) + ) + class CertificateGenerationHistory(TimeStampedModel): """ diff --git a/lms/djangoapps/certificates/tests/test_events.py b/lms/djangoapps/certificates/tests/test_events.py new file mode 100644 index 000000000000..0d2a4c22ebd3 --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_events.py @@ -0,0 +1,227 @@ +""" +Test classes for the events sent in the certification process. + +Classes: + CertificateEventTest: Test event sent after creating, changing or deleting + certificates. +""" +from unittest.mock import Mock + +from openedx_events.learning.data import CertificateData, CourseData, UserData, UserPersonalData +from openedx_events.learning.signals import CERTIFICATE_CHANGED, CERTIFICATE_CREATED, CERTIFICATE_REVOKED +from openedx_events.tests.utils import OpenEdxEventsTestMixin + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory +from lms.djangoapps.certificates.models import GeneratedCertificate, CertificateStatuses +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 + + +@skip_unless_lms +class CertificateEventTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): + """ + Tests for the Open edX Events associated with the student's certification + process. + + This class guarantees that the following events are sent during the user's + certification process, with the exact Data Attributes as the event definition stated: + + - CERTIFICATE_CREATED: after the user's certificate generation has been + completed. + - CERTIFICATE_CHANGED: after the certificate update has been completed. + - CERTIFICATE_REVOKED: after the certificate revocation has been completed. + """ + + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.learning.certificate.created.v1", + "org.openedx.learning.certificate.changed.v1", + "org.openedx.learning.certificate.revoked.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 = CourseOverviewFactory() + self.user = UserFactory.create( + username="somestudent", + first_name="Student", + last_name="Person", + email="robot@robot.org", + is_active=True + ) + 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_send_certificate_created_event(self): + """ + Test whether the certificate created event is sent at the end of the + certificate creation process. + + Expected result: + - CERTIFICATE_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(side_effect=self._event_receiver_side_effect) + CERTIFICATE_CREATED.connect(event_receiver) + + certificate = GeneratedCertificateFactory.create( + status=CertificateStatuses.downloadable, + user=self.user, + course_id=self.course.id, + mode=GeneratedCertificate.MODES.honor, + name="Certificate", + grade="100", + download_url="https://certificate.pdf" + ) + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": CERTIFICATE_CREATED, + "sender": None, + "certificate": CertificateData( + user=UserData( + pii=UserPersonalData( + username=certificate.user.username, + email=certificate.user.email, + name=certificate.user.profile.name, + ), + id=certificate.user.id, + is_active=certificate.user.is_active, + ), + course=CourseData( + course_key=certificate.course_id, + ), + mode=certificate.mode, + grade=certificate.grade, + current_status=certificate.status, + download_url=certificate.download_url, + name=certificate.name, + ), + }, + event_receiver.call_args.kwargs + ) + + def test_send_certificate_changed_event(self): + """ + Test whether the certificate changed event is sent at the end of the + certificate update process. + + Expected result: + - CERTIFICATE_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. + """ + event_receiver = Mock(side_effect=self._event_receiver_side_effect) + CERTIFICATE_CHANGED.connect(event_receiver) + certificate = GeneratedCertificateFactory.create( + status=CertificateStatuses.downloadable, + user=self.user, + course_id=self.course.id, + mode=GeneratedCertificate.MODES.honor, + name="Certificate", + grade="100", + download_url="https://certificate.pdf" + ) + + certificate.grade = "50" + certificate.save() + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": CERTIFICATE_CHANGED, + "sender": None, + "certificate": CertificateData( + user=UserData( + pii=UserPersonalData( + username=certificate.user.username, + email=certificate.user.email, + name=certificate.user.profile.name, + ), + id=certificate.user.id, + is_active=certificate.user.is_active, + ), + course=CourseData( + course_key=certificate.course_id, + ), + mode=certificate.mode, + grade=certificate.grade, + current_status=certificate.status, + download_url=certificate.download_url, + name=certificate.name, + ), + }, + event_receiver.call_args.kwargs + ) + + def test_send_certificate_revoked_event(self): + """ + Test whether the certificate revoked event is sent at the end of the + user certificate's revoking process. + + Expected result: + - CERTIFICATE_REVOKED 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(side_effect=self._event_receiver_side_effect) + CERTIFICATE_REVOKED.connect(event_receiver) + certificate = GeneratedCertificateFactory.create( + status=CertificateStatuses.downloadable, + user=self.user, + course_id=self.course.id, + mode=GeneratedCertificate.MODES.honor, + name="Certificate", + grade="100", + download_url="https://certificate.pdf" + ) + + certificate.invalidate() + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": CERTIFICATE_REVOKED, + "sender": None, + "certificate": CertificateData( + user=UserData( + pii=UserPersonalData( + username=certificate.user.username, + email=certificate.user.email, + name=certificate.user.profile.name, + ), + id=certificate.user.id, + is_active=certificate.user.is_active, + ), + course=CourseData( + course_key=certificate.course_id, + ), + mode=certificate.mode, + grade=certificate.grade, + current_status=certificate.status, + download_url=certificate.download_url, + name=certificate.name, + ), + }, + event_receiver.call_args.kwargs + ) diff --git a/lms/djangoapps/certificates/tests/test_models.py b/lms/djangoapps/certificates/tests/test_models.py index 20e9a56526f4..28fd45231c55 100644 --- a/lms/djangoapps/certificates/tests/test_models.py +++ b/lms/djangoapps/certificates/tests/test_models.py @@ -17,6 +17,7 @@ from edx_name_affirmation.toggles import VERIFIED_NAME_FLAG from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.locator import CourseKey, CourseLocator +from openedx_events.tests.utils import OpenEdxEventsTestMixin from path import Path as path from common.djangoapps.course_modes.models import CourseMode @@ -55,7 +56,7 @@ TEST_DATA_ROOT = PLATFORM_ROOT / TEST_DATA_DIR -class ExampleCertificateTest(TestCase): +class ExampleCertificateTest(TestCase, OpenEdxEventsTestMixin): """Tests for the ExampleCertificate model. """ COURSE_KEY = CourseLocator(org='test', course='test', run='test') @@ -65,6 +66,19 @@ class ExampleCertificateTest(TestCase): DOWNLOAD_URL = 'https://www.example.com' ERROR_REASON = 'Kaboom!' + ENABLED_OPENEDX_EVENTS = [] + + @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): super().setUp() self.cert_set = ExampleCertificateSet.objects.create(course_key=self.COURSE_KEY) @@ -112,10 +126,24 @@ def test_latest_status_is_course_specific(self): assert result is None -class CertificateHtmlViewConfigurationTest(TestCase): +class CertificateHtmlViewConfigurationTest(TestCase, OpenEdxEventsTestMixin): """ Test the CertificateHtmlViewConfiguration model. """ + + ENABLED_OPENEDX_EVENTS = [] + + @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): super().setUp() self.configuration_string = """{ @@ -205,12 +233,25 @@ def test_asset_file_saving_with_actual_name(self): assert certificate_template_asset.asset == 'certificate_template_assets/1/picture2.jpg' -class EligibleCertificateManagerTest(SharedModuleStoreTestCase): +class EligibleCertificateManagerTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): """ Test the GeneratedCertificate model's object manager for filtering out ineligible certs. """ + ENABLED_OPENEDX_EVENTS = [] + + @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): super().setUp() self.user = UserFactory() @@ -250,10 +291,24 @@ def test_filter_certificates_for_nonexistent_courses(self): @ddt.ddt -class TestCertificateGenerationHistory(TestCase): +class TestCertificateGenerationHistory(TestCase, OpenEdxEventsTestMixin): """ Test the CertificateGenerationHistory model's methods """ + + ENABLED_OPENEDX_EVENTS = [] + + @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() + @ddt.data( ({"student_set": "allowlisted_not_generated"}, "For exceptions", True), ({"student_set": "allowlisted_not_generated"}, "For exceptions", False), @@ -308,11 +363,24 @@ def test_get_task_name(self, is_regeneration, expected): assert certificate_generation_history.get_task_name() == expected -class CertificateInvalidationTest(SharedModuleStoreTestCase): +class CertificateInvalidationTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): """ Test for the Certificate Invalidation model. """ + ENABLED_OPENEDX_EVENTS = [] + + @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): super().setUp() self.course = CourseFactory() @@ -365,11 +433,24 @@ def test_revoke_program_certificates(self, mock_issuance, mock_revoke_task): @ddt.ddt -class GeneratedCertificateTest(SharedModuleStoreTestCase): +class GeneratedCertificateTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): """ Test GeneratedCertificates """ + ENABLED_OPENEDX_EVENTS = [] + + @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): super().setUp() self.user = UserFactory() @@ -606,11 +687,24 @@ def test_unverified(self, mock_emit_certificate_event): self._assert_event_data(mock_emit_certificate_event, expected_event_data) -class CertificateAllowlistTest(SharedModuleStoreTestCase): +class CertificateAllowlistTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): """ Tests for the CertificateAllowlist model. """ + ENABLED_OPENEDX_EVENTS = [] + + @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): super().setUp() self.username = 'fun_username' diff --git a/openedx/core/djangoapps/course_groups/models.py b/openedx/core/djangoapps/course_groups/models.py index 5d7d850ef197..6a2e29d1fa0b 100644 --- a/openedx/core/djangoapps/course_groups/models.py +++ b/openedx/core/djangoapps/course_groups/models.py @@ -16,6 +16,9 @@ from openedx.core.djangolib.model_mixins import DeletableByUserValue +from openedx_events.learning.data import CohortData, CourseData, UserData, UserPersonalData +from openedx_events.learning.signals import COHORT_MEMBERSHIP_CHANGED + log = logging.getLogger(__name__) @@ -129,6 +132,24 @@ def assign(cls, cohort, user): def save(self, force_insert=False, force_update=False, using=None, update_fields=None): self.full_clean(validate_unique=False) + COHORT_MEMBERSHIP_CHANGED.send_event( + cohort=CohortData( + 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, + ), + name=self.course_user_group.name, + ) + ) + log.info("Saving CohortMembership for user '%s' in '%s'", self.user.id, self.course_id) return super().save( force_insert=force_insert, diff --git a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py index 1b10177fe126..f3760cb9879b 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py +++ b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py @@ -12,6 +12,7 @@ from django.test import TestCase from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator +from openedx_events.tests.utils import OpenEdxEventsTestMixin from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory @@ -25,11 +26,24 @@ @patch("openedx.core.djangoapps.course_groups.cohorts.tracker", autospec=True) -class TestCohortSignals(TestCase): +class TestCohortSignals(TestCase, OpenEdxEventsTestMixin): """ Test cases to validate event emissions for various cohort-related workflows """ + ENABLED_OPENEDX_EVENTS = [] + + @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): super().setUp() self.course_key = CourseLocator("dummy", "dummy", "dummy") diff --git a/openedx/core/djangoapps/course_groups/tests/test_events.py b/openedx/core/djangoapps/course_groups/tests/test_events.py new file mode 100644 index 000000000000..2a1cc040470b --- /dev/null +++ b/openedx/core/djangoapps/course_groups/tests/test_events.py @@ -0,0 +1,108 @@ +""" +Test classes for the events sent in the cohort assignment process. + +Classes: + CohortEventTest: Test event sent after cohort membership changes. +""" +from openedx.core.djangoapps.course_groups.models import CohortMembership +from unittest.mock import Mock + +from openedx_events.learning.data import CohortData, CourseData, UserData, UserPersonalData +from openedx_events.learning.signals import COHORT_MEMBERSHIP_CHANGED +from openedx_events.tests.utils import OpenEdxEventsTestMixin + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory + +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + + +@skip_unless_lms +class CohortEventTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): + """ + Tests for the Open edX Events associated with the cohort update process. + + This class guarantees that the following events are sent during the user's + certification process, with the exact Data Attributes as the event definition stated: + + - COHORT_MEMBERSHIP_CHANGED: when a cohort membership update ends. + """ + + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.learning.cohort_membership.changed.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 = CourseOverviewFactory() + self.user = UserFactory.create( + username="somestudent", + first_name="Student", + last_name="Person", + email="robot@robot.org", + is_active=True + ) + self.cohort = CohortFactory(course_id=self.course.id, name="FirstCohort") + 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_send_cohort_membership_changed_event(self): + """ + Test whether the COHORT_MEMBERSHIP_CHANGED event is sent when a cohort + membership update ends. + + Expected result: + - COHORT_MEMBERSHIP_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. + """ + event_receiver = Mock(side_effect=self._event_receiver_side_effect) + COHORT_MEMBERSHIP_CHANGED.connect(event_receiver) + + cohort_membership, _ = CohortMembership.assign( + cohort=self.cohort, + user=self.user, + ) + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": COHORT_MEMBERSHIP_CHANGED, + "sender": None, + "cohort": CohortData( + user=UserData( + pii=UserPersonalData( + username=cohort_membership.user.username, + email=cohort_membership.user.email, + name=cohort_membership.user.profile.name, + ), + id=cohort_membership.user.id, + is_active=cohort_membership.user.is_active, + ), + course=CourseData( + course_key=cohort_membership.course_id, + ), + name=cohort_membership.course_user_group.name, + ), + }, + event_receiver.call_args.kwargs + ) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 2b38ab25cc2f..b07e441f6499 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -705,7 +705,7 @@ oauthlib==3.0.1 # social-auth-core openedx-calc==2.0.1 # via -r requirements/edx/base.in -openedx-events==0.5.1 +openedx-events==0.6.0 # via -r requirements/edx/base.in ora2==3.6.22 # via -r requirements/edx/base.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 2d9c3914abab..70c278f686af 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -932,7 +932,7 @@ oauthlib==3.0.1 # social-auth-core openedx-calc==2.0.1 # via -r requirements/edx/testing.txt -openedx-events==0.5.1 +openedx-events==0.6.0 # via -r requirements/edx/testing.txt ora2==3.6.22 # via -r requirements/edx/testing.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index dae1c29a3179..271e8075fa7e 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -880,7 +880,7 @@ oauthlib==3.0.1 # social-auth-core openedx-calc==2.0.1 # via -r requirements/edx/base.txt -openedx-events==0.5.1 +openedx-events==0.6.0 # via -r requirements/edx/base.txt ora2==3.6.22 # via -r requirements/edx/base.txt