diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 1f98d2f6df..0c3827ae79 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1439,6 +1439,7 @@ class CourseEnrollment(models.Model): CourseEnrollmentState(self.mode, self.is_active), ) + # .. event_implemented_name: COURSE_ENROLLMENT_CHANGED COURSE_ENROLLMENT_CHANGED.send_event( enrollment=CourseEnrollmentData( user=UserData( @@ -1465,6 +1466,7 @@ class CourseEnrollment(models.Model): self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED) self.send_signal(EnrollStatusChange.unenroll) + # .. event_implemented_name: COURSE_UNENROLLMENT_COMPLETED COURSE_UNENROLLMENT_COMPLETED.send_event( enrollment=CourseEnrollmentData( user=UserData( @@ -1671,7 +1673,7 @@ class CourseEnrollment(models.Model): enrollment.update_enrollment(is_active=True, mode=mode, enterprise_uuid=enterprise_uuid) enrollment.send_signal(EnrollStatusChange.enroll) - # Announce user's enrollment + # .. event_implemented_name: COURSE_ENROLLMENT_CREATED COURSE_ENROLLMENT_CREATED.send_event( enrollment=CourseEnrollmentData( user=UserData( diff --git a/docs/guides/extension_points.rst b/docs/guides/extension_points.rst index 5a178145fe..fe2d9315a4 100644 --- a/docs/guides/extension_points.rst +++ b/docs/guides/extension_points.rst @@ -137,6 +137,9 @@ Here are the different integration points that python plugins can use: * - Pluggable override (``edx_django_utils.plugins.pluggable_override.pluggable_override``) - Trial, Stable - This decorator allows overriding any function or method by pointing to an alternative implementation in settings. Read the |pluggable_override docstring|_ to learn more. + * - Open edX Events + - Adopt, Stable + - Events are part of the greater Hooks Extension Framework for open extension of edx-platform. Events are a stable way for plugin developers to react to learner or author events. They are defined by a `separate events library`_ that developers can include in their requirements to develop and test the code without creating a dependency on this large repo. For more information see the `hooks guide`_. .. _Application: https://docs.djangoproject.com/en/3.0/ref/applications/ .. _Django app plugin documentation: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst @@ -151,6 +154,8 @@ Here are the different integration points that python plugins can use: .. _UserPartition docstring: https://github.com/edx/edx-platform/blob/f8cc58618a39c9f7b8e9e1001eb2d7a10395797e/common/lib/xmodule/xmodule/partitions/partitions.py#L105-L120 .. |pluggable_override docstring| replace:: ``pluggable_override`` docstring .. _pluggable_override docstring: https://github.com/edx/edx-django-utils/blob/master/edx_django_utils/plugins/pluggable_override.py +.. _separate events library: https://github.com/eduNEXT/openedx-events/ +.. _hooks guide: https://github.com/edx/edx-platform/blob/master/docs/guides/hooks/index.rst Platform Look & Feel ==================== diff --git a/docs/guides/hooks/index.rst b/docs/guides/hooks/index.rst new file mode 100644 index 0000000000..cffc878526 --- /dev/null +++ b/docs/guides/hooks/index.rst @@ -0,0 +1,220 @@ +Openedx Hooks Extension Framework +================================= + +To sustain the growth of the Open edX ecosystem, the business rules of the +platform must be open for extension following the open-closed principle. This +framework allows developers to do just that without needing to fork and modify +the main edx-platform repository. + + +Context +------- + +Hooks are predefined places in the edx-platform core where externally defined +functions can take place. In some cases, those functions can alter what the user +sees or experiences in the platform. Other cases are informative only. All cases +are meant to be extended using Open edX plugins and configuration. + +Hooks can be of two types, events and filters. Events are in essence signals, in +that they are sent in specific application places and whose listeners can extend +functionality. On the other hand Filters are passed data and can act on it +before this data is put back in the original application flow. In order to allow +extension developers to use the Events and Filters definitions on their plugins, +both kinds of hooks are defined in lightweight external libraries. + +* `openedx filters`_ +* `openedx events`_ + +Hooks are designed with stability in mind. The main goal is that developers can +use them to change the functionality of the platform as needed and still be able +to migrate to newer open releases with very little to no development effort. In +the case of the events, this is detailed in the `versioning ADR`_ and the +`payload ADR`_. + +A longer description of the framework and it's history can be found in `OEP 50`_. + +.. _OEP 50: https://open-edx-proposals.readthedocs.io/en/latest/oep-0050-hooks-extension-framework.html +.. _versioning ADR: https://github.com/eduNEXT/openedx-events/blob/main/docs/decisions/0002-events-naming-and-versioning.rst +.. _payload ADR: https://github.com/eduNEXT/openedx-events/blob/main/docs/decisions/0003-events-payload.rst +.. _openedx filters: https://github.com/eduNEXT/openedx-filters +.. _openedx events: https://github.com/eduNEXT/openedx-events + +On the technical side events are implemented through django signals which makes +them run in the same python process as the lms or cms. Furthermore, events block +the running process. Listeners of an event are encouraged to monitor the +performance or use alternative arch patterns such as receiving the event and +defer to launching async tasks than do the slow processing. + + +How to use +---------- + +Using openedx-events in your code is very straight forward. We can consider the +two possible cases, sending or receiving an event. + + +Receiving events +^^^^^^^^^^^^^^^^ + +This is one of the most common use cases for plugins. The edx-platform will send +and event and you want to react to it in your plugin. + +For this you need to: + +1. Include openedx-events in your dependencies. +2. Connect your receiver functions to the signals being sent. + +Connecting signals can be done using regular django syntax: + +.. code-block:: python + + from openedx_events.learning.signals import SESSION_LOGIN_COMPLETED + + @receiver(SESSION_LOGIN_COMPLETED) + # your receiver function here + + +Or at the apps.py + +.. code-block:: python + + "signals_config": { + "lms.djangoapp": { + "relative_path": "your_module_name", + "receivers": [ + { + "receiver_func_name": "your_receiver_function", + "signal_path": "openedx_events.learning.signals.SESSION_LOGIN_COMPLETED", + }, + ], + } + }, + +In case you are listening to an event in the edx-platform repo, you can directly +use the django syntax since the apps.py method will not be available without the +plugin. + + +Sending events +^^^^^^^^^^^^^^ + +Sending events requires you to import both the event definition as well as the +attr data classes that encapsulate the event data. + +.. code-block:: python + + from openedx_events.learning.data import UserData, UserPersonalData + from openedx_events.learning.signals import STUDENT_REGISTRATION_COMPLETED + + STUDENT_REGISTRATION_COMPLETED.send_event( + user=UserData( + pii=UserPersonalData( + username=user.username, + email=user.email, + name=user.profile.name, + ), + id=user.id, + is_active=user.is_active, + ), + ) + +You can do this both from the edx-platform code as well as from an openedx +plugin. + + +Testing events +^^^^^^^^^^^^^^ + +Testing your code in CI, specially for plugins is now possible without having to +import the complete edx-platform as a dependency. + +To test your functions you need to include the openedx-events library in your +testing dependencies and make the signal connection in your test case. + +.. code-block:: python + + from openedx_events.learning.signals import STUDENT_REGISTRATION_COMPLETED + + def test_your_receiver(self): + STUDENT_REGISTRATION_COMPLETED.connect(your_function) + STUDENT_REGISTRATION_COMPLETED.send_event( + user=UserData( + pii=UserPersonalData( + username='test_username', + email='test_email@example.com', + name='test_name', + ), + id=1, + is_active=True, + ), + ) + + # run your assertions + + +Changes in the openedx-events library that are not compatible with your code +should break this kind of test in CI and let you know you need to upgrade your +code. + + +Live example +^^^^^^^^^^^^ + +For a complete and detailed example you can see the `openedx-events-2-zapier`_ +plugin. This is a fully functional plugin that connects to +``STUDENT_REGISTRATION_COMPLETED`` and ``COURSE_ENROLLMENT_CREATED`` and sends +the relevant information to zapier.com using a webhook. + +.. _openedx-events-2-zapier: https://github.com/eduNEXT/openedx-events-2-zapier + + +Index of Events +----------------- + +This list contains the events currently being sent by edx-platform. The provided +links target both the definition of the event in the openedx-events library as +well as the trigger location in this same repository. + + +.. list-table:: + :widths: 35 50 20 + + * - *Name* + - *Type* + - *Date added* + + * - `STUDENT_REGISTRATION_COMPLETED `_ + - org.openedx.learning.student.registration.completed.v1 + - `2021-09-02 `__ + + * - `SESSION_LOGIN_COMPLETED `_ + - org.openedx.learning.auth.session.login.completed.v1 + - `2021-09-02 `__ + + * - `COURSE_ENROLLMENT_CREATED `_ + - org.openedx.learning.course.enrollment.created.v1 + - `2021-09-02 `__ + + * - `COURSE_ENROLLMENT_CHANGED `_ + - org.openedx.learning.course.enrollment.changed.v1 + - `2021-09-22 `__ + + * - `COURSE_UNENROLLMENT_COMPLETED `_ + - org.openedx.learning.course.unenrollment.completed.v1 + - `2021-09-22 `__ + + * - `CERTIFICATE_CREATED `_ + - org.openedx.learning.certificate.created.v1 + - `2021-09-22 `__ + + * - `CERTIFICATE_CHANGED `_ + - org.openedx.learning.certificate.changed.v1 + - `2021-09-22 `__ + + * - `CERTIFICATE_REVOKED `_ + - org.openedx.learning.certificate.revoked.v1 + - `2021-09-22 `__ + + * - `COHORT_MEMBERSHIP_CHANGED `_ + - org.openedx.learning.cohort_membership.changed.v1 + - `2021-09-22 `__ diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 3c437dc1a2..6576368e4b 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -394,6 +394,7 @@ class GeneratedCertificate(models.Model): status=self.status, ) + # .. event_implemented_name: CERTIFICATE_REVOKED CERTIFICATE_REVOKED.send_event( certificate=CertificateData( user=UserData( @@ -472,6 +473,7 @@ class GeneratedCertificate(models.Model): status=self.status, ) + # .. event_implemented_name: CERTIFICATE_CHANGED CERTIFICATE_CHANGED.send_event( certificate=CertificateData( user=UserData( @@ -503,6 +505,7 @@ class GeneratedCertificate(models.Model): status=self.status, ) + # .. event_implemented_name: CERTIFICATE_CREATED CERTIFICATE_CREATED.send_event( certificate=CertificateData( user=UserData( diff --git a/openedx/core/djangoapps/course_groups/models.py b/openedx/core/djangoapps/course_groups/models.py index 6a2e29d1fa..7f58f14a43 100644 --- a/openedx/core/djangoapps/course_groups/models.py +++ b/openedx/core/djangoapps/course_groups/models.py @@ -132,6 +132,7 @@ class CohortMembership(models.Model): def save(self, force_insert=False, force_update=False, using=None, update_fields=None): self.full_clean(validate_unique=False) + # .. event_implemented_name: COHORT_MEMBERSHIP_CHANGED COHORT_MEMBERSHIP_CHANGED.send_event( cohort=CohortData( user=UserData( diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index c72b28154d..f2492555d7 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -303,7 +303,7 @@ def _handle_successful_authentication_and_login(user, request): request.session.set_expiry(604800 * 4) log.debug("Setting user session expiry to 4 weeks") - # Announce user's login + # .. event_implemented_name: SESSION_LOGIN_COMPLETED SESSION_LOGIN_COMPLETED.send_event( user=UserData( pii=UserPersonalData( diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index bfaf36ce55..6ec514eaf3 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -256,6 +256,7 @@ def create_account_with_params(request, params): # Announce registration REGISTER_USER.send(sender=None, user=user, registration=registration) + # .. event_implemented_name: STUDENT_REGISTRATION_COMPLETED STUDENT_REGISTRATION_COMPLETED.send_event( user=UserData( pii=UserPersonalData(