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
68 changes: 47 additions & 21 deletions backend-plugin-sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions backend-plugin-sample/src/openedx_plugin_sample/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
# }]
# }
# }
Expand Down
148 changes: 41 additions & 107 deletions backend-plugin-sample/src/openedx_plugin_sample/signals.py
Original file line number Diff line number Diff line change
@@ -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,
)
77 changes: 77 additions & 0 deletions backend-plugin-sample/tests/test_signals.py
Original file line number Diff line number Diff line change
@@ -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
Loading