diff --git a/backend-plugin-sample/README.md b/backend-plugin-sample/README.md index 8191dd1..3e208e5 100644 --- a/backend-plugin-sample/README.md +++ b/backend-plugin-sample/README.md @@ -184,40 +184,62 @@ def perform_create(self, serializer): ### Event Handler Example +This plugin reacts to `COURSE_ENROLLMENT_CHANGED` to unarchive a course on the +learner's dashboard when they upgrade to the verified track. The idea: a learner +who has previously archived a course shouldn't have to dig it back out of their +"Archived" section after upgrading -- their renewed investment is a strong +signal that the course belongs back in their active list. + ```python -from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED +from openedx_events.learning.data import CourseEnrollmentData +from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED from django.dispatch import receiver -@receiver(COURSE_CATALOG_INFO_CHANGED) -def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs): - logging.info(f"{catalog_info.course_key} has been updated!") - # Add your custom business logic here +@receiver(COURSE_ENROLLMENT_CHANGED) +def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs): + if not enrollment.is_active or enrollment.mode != "verified": + return + CourseArchiveStatus.objects.filter( + user_id=enrollment.user.id, + course_id=enrollment.course.course_key, + is_archived=True, + ).update(is_archived=False, archive_date=None) ``` +**Why an event (not a filter)?** The unarchive is a *one-time nudge*: if the +learner re-archives the course later, we respect that. Implementing this as a +continuous rule in the filter pipeline (e.g. "any verified course is never +archived") would override the learner's intent. Events fire at the moment a +state change happens, which is exactly when this kind of one-shot reaction +belongs. + ### Available Events **Event Catalog**: [Open edX Events Reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html) **Common Events:** -- `COURSE_CATALOG_INFO_CHANGED` - Course information updated +- `COURSE_ENROLLMENT_CHANGED` - Enrollment becomes active/inactive or changes mode +- `COURSE_ENROLLMENT_CREATED` - Student newly enrolled in a course - `STUDENT_REGISTRATION_COMPLETED` - New user registered - `CERTIFICATE_CREATED` - Certificate generated for learner -- `ENROLLMENT_CREATED` - Student enrolled in course +- `COURSE_CATALOG_INFO_CHANGED` - Course catalog metadata updated ### Event Data Structure -Each event includes specific data. For `COURSE_CATALOG_INFO_CHANGED`: +Each event includes a specific data object. For `COURSE_ENROLLMENT_CHANGED`: ```python -def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs): - # catalog_info contains: - # - course_key: CourseKey object - # - name: Course display name - # - schedule: Course schedule information - # - hidden: Visibility status +def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs): + # enrollment contains: + # - user: UserData (with .id, .is_active, .pii) + # - course: CourseData (with .course_key, .display_name, .start, .end) + # - mode: str (e.g. "audit", "verified", "honor") + # - is_active: bool + # - creation_date: datetime + # - created_by: UserData (optional) ``` -**Key Point**: Check the [event definition](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html) to understand what data is available. +**Key Point**: Check the [event data reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html) to understand the exact fields available for each event. ### Signal Handler Registration @@ -471,15 +493,19 @@ const response = await client.get( ); ``` -### Events + API Integration +### Events + Models Integration ```python -@receiver(COURSE_CATALOG_INFO_CHANGED) -def sync_course_archive_on_change(signal, sender, catalog_info, **kwargs): - # Update archive statuses when course info changes +@receiver(COURSE_ENROLLMENT_CHANGED) +def unarchive_on_verified_upgrade(signal, sender, enrollment, **kwargs): + # React to a verified upgrade by clearing the learner's archive flag + if not enrollment.is_active or enrollment.mode != "verified": + return CourseArchiveStatus.objects.filter( - course_id=catalog_info.course_key - ).update(last_synced=timezone.now()) + user_id=enrollment.user.id, + course_id=enrollment.course.course_key, + is_archived=True, + ).update(is_archived=False, archive_date=None) ``` ### Filters + Settings Integration diff --git a/backend-plugin-sample/src/openedx_plugin_sample/apps.py b/backend-plugin-sample/src/openedx_plugin_sample/apps.py index de6346a..64c0a63 100644 --- a/backend-plugin-sample/src/openedx_plugin_sample/apps.py +++ b/backend-plugin-sample/src/openedx_plugin_sample/apps.py @@ -92,8 +92,8 @@ class SamplePluginConfig(AppConfig): # "lms.djangoapp": { # "relative_path": "signals", # "receivers": [{ - # "receiver_func_name": "log_course_info_changed", - # "signal_path": "openedx_events.content_authoring.signals.COURSE_CATALOG_INFO_CHANGED", + # "receiver_func_name": "unarchive_on_verified_upgrade", + # "signal_path": "openedx_events.learning.signals.COURSE_ENROLLMENT_CHANGED", # }] # } # } diff --git a/backend-plugin-sample/src/openedx_plugin_sample/signals.py b/backend-plugin-sample/src/openedx_plugin_sample/signals.py index c7934ee..c17ebde 100644 --- a/backend-plugin-sample/src/openedx_plugin_sample/signals.py +++ b/backend-plugin-sample/src/openedx_plugin_sample/signals.py @@ -1,132 +1,66 @@ """ Open edX Events signal handlers for the openedx_plugin_sample application. -This module demonstrates how to consume Open edX Events (signals) to react to -platform activities and integrate with external systems. Events are part of -the Hooks Extension Framework and provide a stable way to extend Open edX. - -What Are Open edX Events? -Events are signals sent when specific actions occur in the platform. Unlike -traditional Django signals, Open edX Events have standardized data structures -and are designed for external consumption. +This module demonstrates how to consume Open edX Events to react to platform +activity. Events are part of the Hooks Extension Framework and provide a +stable way to extend Open edX without modifying core code. Key Concepts: - Events are fired at specific points in the platform lifecycle -- Each event includes structured data (defined in openedx-events) -- Event handlers can perform actions but cannot modify the event data -- Events support both internal processing and external event bus integration +- Each event delivers a structured data object (defined in openedx-events) +- Event handlers can take action but cannot modify the event payload +- Handlers must be imported from apps.py ready() so @receiver registers them Official Documentation: - Events Overview: https://docs.openedx.org/projects/openedx-events/en/latest/ - Available Events: https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html - Consuming Events: https://docs.openedx.org/projects/openedx-events/en/latest/how-tos/consume-an-event.html -- Hooks Framework: https://docs.openedx.org/en/latest/developers/concepts/hooks_extension_framework.html - -Registration Process: -1. Import the event signal from openedx-events -2. Create handler function with correct signature -3. Decorate with @receiver -4. Import this module in apps.py ready() method - -Event Data Structure: -Each event defines specific data attributes. Check the event definition in the -official documentation to understand available data: -- Signal Reference: https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html -- Data Objects: https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html -- Example: COURSE_CATALOG_INFO_CHANGED provides catalog_info: CourseCatalogData - -Common Use Cases: -- Integration with external systems (CRM, analytics, notifications) -- Custom logging and audit trails -- Triggering workflows in other services -- Synchronizing data with external databases +- Event Data Objects: https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html """ import logging from django.dispatch import receiver -from openedx_events.content_authoring.data import CourseCatalogData -from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED +from openedx_events.learning.data import CourseEnrollmentData +from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED + +from .models import CourseArchiveStatus logger = logging.getLogger(__name__) -@receiver(COURSE_CATALOG_INFO_CHANGED) -def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs): # pylint: disable=unused-argument # noqa: E501 +@receiver(COURSE_ENROLLMENT_CHANGED) +def unarchive_on_verified_upgrade( + signal, sender, enrollment: CourseEnrollmentData, **kwargs +): # pylint: disable=unused-argument """ - Handle course catalog information changes. - - This function demonstrates how to consume the COURSE_CATALOG_INFO_CHANGED event, - which is fired whenever course catalog information is updated in the platform. - - Event Trigger Conditions: - - Course metadata is modified (name, description, etc.) - - Course schedule is updated - - Course visibility settings change - - Other catalog-related modifications - - Args: - signal: The signal instance that triggered this handler - sender: The model class that sent the signal - catalog_info (CourseCatalogData): Structured data about the course - **kwargs: Additional context parameters - - CourseCatalogData Attributes: - Based on the official data structure documentation: - https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html#openedx_events.content_authoring.data.CourseCatalogData + Unarchive a course on the learner's dashboard when they upgrade to verified. - - course_key (CourseKey): Unique course identifier - - name (str): Course display name - - schedule (CourseScheduleData): Start/end dates and pacing - - hidden (bool): Course visibility status + If a learner has previously archived a course (CourseArchiveStatus.is_archived=True) + and then upgrades to the verified track, the course shouldn't stay tucked away + in their "Archived" section -- their renewed investment in the course is a + strong signal that they want it back in their active list. - Real-World Use Cases: - - Sync course metadata with external systems (CRM, marketing sites) - - Update search indexes when course information changes - - Trigger email notifications to administrators - - Log changes for audit and compliance - - Update analytics dashboards with new course information + This is intentionally a one-time nudge, not a continuous rule: if the learner + re-archives the course later, we respect that choice. That's why we react to + the enrollment-change *event* rather than computing `isArchivedByLearner` + from enrollment mode in the filter pipeline. - Example Implementation:: - - # Send to external CRM system - external_api.update_course( - course_id=str(catalog_info.course_key), - name=catalog_info.name, - is_hidden=catalog_info.hidden - ) - - # Update internal tracking - CourseChangeLog.objects.create( - course_key=catalog_info.course_key, - change_type='catalog_updated', - timestamp=timezone.now() - ) - - Performance Considerations: - - Keep processing lightweight (events should not block platform operations) - - Use asynchronous tasks for heavy processing (Celery, etc.) - - Handle exceptions gracefully to prevent platform disruption + Event reference: + https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html#openedx_events.learning.signals.COURSE_ENROLLMENT_CHANGED """ - logging.info(f"Course catalog updated: {catalog_info.course_key}") - - # Access available data from the event - logging.debug(f"Course name: {catalog_info.name}") - logging.debug(f"Course hidden: {catalog_info.hidden}") - - # Example: Integrate with external systems - # try: - # # Send to external system - # external_system.notify_course_update( - # course_id=str(catalog_info.course_key), - # course_name=catalog_info.name, - # is_hidden=catalog_info.hidden - # ) - # except Exception as e: - # logging.error(f"Failed to notify external system: {e}") - - # Example: Update internal tracking - # from .models import CourseArchiveStatus - # CourseArchiveStatus.objects.filter( - # course_id=catalog_info.course_key - # ).update(last_catalog_update=timezone.now()) + if not enrollment.is_active or enrollment.mode != "verified": + return + + updated = CourseArchiveStatus.objects.filter( + user_id=enrollment.user.id, + course_id=enrollment.course.course_key, + is_archived=True, + ).update(is_archived=False, archive_date=None) + + if updated: + logger.info( + "Unarchived course %s for user %s after verified upgrade", + enrollment.course.course_key, + enrollment.user.id, + ) diff --git a/backend-plugin-sample/tests/test_signals.py b/backend-plugin-sample/tests/test_signals.py new file mode 100644 index 0000000..a82fe6a --- /dev/null +++ b/backend-plugin-sample/tests/test_signals.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +# pylint: disable=redefined-outer-name +""" +Tests for the `sample-plugin` Open edX Events signal handlers. +""" + +from datetime import datetime, timezone + +import pytest +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey +from openedx_events.learning.data import CourseData, CourseEnrollmentData, UserData, UserPersonalData +from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED + +from openedx_plugin_sample.models import CourseArchiveStatus + +User = get_user_model() + + +@pytest.fixture +def user(): + """ + Create and return a test user. + """ + return User.objects.create_user( + username="testuser", email="testuser@example.com", password="password123" + ) + + +@pytest.fixture +def course_key(): + """ + Create and return a test course key. + """ + return CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + + +def _build_enrollment(user, course_key, *, mode, is_active): + """ + Build a CourseEnrollmentData payload like the platform would emit. + """ + return CourseEnrollmentData( + user=UserData( + id=user.id, + is_active=user.is_active, + pii=UserPersonalData(username=user.username, email=user.email), + ), + course=CourseData(course_key=course_key, display_name="Demo Course"), + mode=mode, + is_active=is_active, + creation_date=datetime.now(timezone.utc), + ) + + +@pytest.mark.django_db +def test_verified_upgrade_unarchives_course(user, course_key): + """ + Test that firing COURSE_ENROLLMENT_CHANGED with is_active=True and + mode="verified" flips an existing archived CourseArchiveStatus back to + unarchived. + """ + archive_status = CourseArchiveStatus.objects.create( + course_id=course_key, + user=user, + is_archived=True, + archive_date=datetime.now(timezone.utc), + ) + + COURSE_ENROLLMENT_CHANGED.send_event( + enrollment=_build_enrollment( + user, course_key, mode="verified", is_active=True + ) + ) + + archive_status.refresh_from_db() + assert archive_status.is_archived is False + assert archive_status.archive_date is None