Skip to content

Latest commit

 

History

History
577 lines (436 loc) · 19.4 KB

File metadata and controls

577 lines (436 loc) · 19.4 KB

Backend Plugin Implementation Guide

This directory contains a comprehensive Django app plugin that demonstrates all major backend plugin interfaces available in Open edX. The plugin implements a course archiving system to show real-world usage patterns.

Table of Contents

Overview

This backend plugin demonstrates the Open edX Django App Plugin pattern, which allows you to add new functionality to edx-platform without modifying core platform code.

What this plugin provides:

  • Models: Course archive status tracking
  • APIs: REST endpoints for frontend integration
  • Events: React to course catalog changes
  • Filters: Modify course about page URLs
  • Settings: Plugin configuration management

Official Documentation:

Django App Plugin Configuration

File: openedx_plugin_sample/apps.py

Plugin Registration

The SamplePluginConfig class configures this app as an edx-platform plugin:

class SamplePluginConfig(AppConfig):
    name = "openedx_plugin_sample"
    plugin_app = {
        "url_config": {
            # Register URLs for both LMS and CMS
            "lms.djangoapp": {
                PluginURLs.NAMESPACE: "openedx_plugin_sample",
                PluginURLs.REGEX: r"^sample-plugin/",
                PluginURLs.RELATIVE_PATH: "urls",
            },
            # ... CMS configuration
        },
        PluginSettings.CONFIG: {
            # Configure settings for different environments
            "lms.djangoapp": {
                "common": {PluginURLs.RELATIVE_PATH: "settings.common"},
                "production": {PluginURLs.RELATIVE_PATH: "settings.production"},
            },
            # ... CMS configuration
        }
    }

Key Configuration Options

Option Purpose Official Docs
url_config Register plugin URLs with platform Plugin URLs
PluginSettings.CONFIG Load plugin settings Plugin Settings
ready() method Initialize signal handlers Django AppConfig.ready()

Entry Points Configuration

In pyproject.toml, the plugin registers itself with edx-platform:

[project.entry-points."lms.djangoapp"]
openedx_plugin_sample = "openedx_plugin_sample.apps:SamplePluginConfig"

[project.entry-points."cms.djangoapp"]
openedx_plugin_sample = "openedx_plugin_sample.apps:SamplePluginConfig"

Why this works: The platform automatically discovers and loads any Django app registered in these entry points.

Models & Database

File: openedx_plugin_sample/models.py Official Docs: OEP-49: Django App Patterns

CourseArchiveStatus Model

class CourseArchiveStatus(models.Model):
    course_id = CourseKeyField(max_length=255, db_index=True)
    user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
    is_archived = models.BooleanField(default=False, db_index=True)
    archive_date = models.DateTimeField(null=True, blank=True)
    # ... timestamps

Key Features:

  • CourseKeyField: Uses Open edX's opaque keys for course identification
  • User Reference: Links to platform's user model via get_user_model()
  • Database Indexes: Performance optimization on frequently queried fields
  • Unique Constraints: Prevents duplicate records per user-course combination

Database Migration

# After modifying models.py
cd backend-plugin-sample
python manage.py makemigrations openedx_plugin_sample
python manage.py migrate

Migration files: Generated in openedx_plugin_sample/migrations/

PII Annotations

The model includes PII documentation:

# .. no_pii: This model does not store PII directly, only references to users via foreign keys.

Best Practice: Always document PII handling for Open edX compliance.

API Endpoints

File: openedx_plugin_sample/views.py URLs: openedx_plugin_sample/urls.py

REST API Implementation

class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
    serializer_class = CourseArchiveStatusSerializer
    permission_classes = [IsOwnerOrStaffSuperuser]
    pagination_class = CourseArchiveStatusPagination
    throttle_classes = [CourseArchiveStatusThrottle]
    # ... filtering and ordering

API Features

Feature Implementation Why It Matters
Authentication IsOwnerOrStaffSuperuser permission Users only see their own data; staff see all
Pagination Custom pagination class Performance with large datasets
Throttling Rate limiting (60/minute) Prevents API abuse
Filtering DjangoFilterBackend Query by course_id, user, archive status
Validation Course ID format checking Prevents injection attacks

API Endpoints

  • GET /sample-plugin/api/v1/course-archive-status/ - List archive statuses
  • POST /sample-plugin/api/v1/course-archive-status/ - Create new status
  • GET /sample-plugin/api/v1/course-archive-status/{id}/ - Get specific status
  • PUT/PATCH /sample-plugin/api/v1/course-archive-status/{id}/ - Update status
  • DELETE /sample-plugin/api/v1/course-archive-status/{id}/ - Delete status

Business Logic

The viewset includes custom business logic:

def perform_create(self, serializer):
    # Set archive_date when creating archived status
    data = {}
    if serializer.validated_data.get("is_archived", False):
        data["archive_date"] = timezone.now()
    instance = serializer.save(**data)

Pattern: Use perform_create() and perform_update() for business logic, following the pattern documented in CLAUDE.md.

Events & Signals

File: openedx_plugin_sample/signals.py Official Docs: Open edX Events Guide

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.

from openedx_events.learning.data import CourseEnrollmentData
from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED
from django.dispatch import receiver

@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

Common Events:

  • 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
  • COURSE_CATALOG_INFO_CHANGED - Course catalog metadata updated

Event Data Structure

Each event includes a specific data object. For COURSE_ENROLLMENT_CHANGED:

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 data reference to understand the exact fields available for each event.

Signal Handler Registration

Handlers are automatically registered via the ready() method in apps.py:

def ready(self):
    # Import handlers to register signal receivers
    from . import signals

Real-World Use Cases

  • Integration: Send course updates to external systems
  • Analytics: Track course lifecycle events
  • Notifications: Email administrators about important changes
  • Auditing: Log sensitive operations for compliance

Filters & Pipeline Steps

File: openedx_plugin_sample/pipeline.py Official Docs: Using Open edX Filters

Filter Implementation

from openedx_filters.filters import PipelineStep

class ChangeCourseAboutPageUrl(PipelineStep):
    def run_filter(self, url, org, **kwargs):
        # Extract course ID from URL
        pattern = r'(?P<course_id>course-v1:[^/]+)'
        match = re.search(pattern, url)

        if match:
            course_id = match.group('course_id')
            new_url = f"https://example.com/new_about_page/{course_id}"
            return {"url": new_url, "org": org}

        # Return original data if no match
        return {"url": url, "org": org}

Filter Requirements

Essential Elements:

  • Inherit from PipelineStep
  • Implement run_filter() method
  • Return dictionary with same parameter names as input
  • Handle all possible input scenarios

Available Filters

Filter Catalog: Open edX Filters Reference

Common Filters:

  • Course enrollment filters
  • Authentication filters
  • Certificate generation filters
  • Course discovery filters

Filter Registration

Filters must be registered in Django settings. This happens automatically via the plugin settings system (see Settings Configuration).

Real-World Use Cases

  • URL Redirection: Send users to custom course pages
  • Access Control: Implement custom enrollment restrictions
  • Data Transformation: Modify course data before display
  • Integration: Add custom fields to API responses

Settings Configuration

Files: openedx_plugin_sample/settings/

Settings Structure

# settings/common.py
def plugin_settings(settings):
    """Add plugin settings to main settings object."""
    # Add your custom settings here
    # settings.SAMPLE_PLUGIN_API_KEY = "your-key"
    pass

Environment-Specific Settings

  • common.py: Settings for all environments
  • production.py: Production-only settings
  • test.py: Test-specific settings (faster database, etc.)

Filter Registration via Settings

To register the URL filter, add to common.py:

def plugin_settings(settings):
    # Register the course about page URL filter
    settings.OPEN_EDX_FILTERS_CONFIG = {
        "org.openedx.learning.course.about.render.started.v1": {
            "pipeline": [
                "openedx_plugin_sample.pipeline.ChangeCourseAboutPageUrl"
            ],
            "fail_silently": False,
        }
    }

Filter Name Discovery: Filter names are found in the official filters documentation.

Plugin-Specific Settings

Add custom configuration:

def plugin_settings(settings):
    # Plugin-specific settings
    settings.SAMPLE_PLUGIN_ARCHIVE_RETENTION_DAYS = 365
    settings.SAMPLE_PLUGIN_API_RATE_LIMIT = "60/minute"
    settings.SAMPLE_PLUGIN_EXTERNAL_API_URL = "https://api.example.com"

Development Setup

Prerequisites

  1. Platform Setup: Open edX Development Guide
  2. Python Environment: Python 3.8+ with virtual environment

Installation Methods

Option 1: With Tutor (Recommended)

# Mount the backend plugin
tutor mounts add lms:$PWD:/openedx/sample-plugin-backend

# Launch and install
tutor dev launch
tutor dev exec lms pip install -e ../sample-plugin-backend
tutor dev exec lms python manage.py lms migrate
tutor dev restart lms

Option 2: Direct Installation

# In your edx-platform directory
pip install -e /path/to/sample-plugin/backend-plugin-sample

# Run migrations
python manage.py lms migrate
python manage.py cms migrate

Verification Steps

  1. Check Installation:

    python manage.py lms shell
    >>> from openedx_plugin_sample.models import CourseArchiveStatus
    >>> print("Plugin installed successfully!")
  2. Test API: Visit http://localhost:18000/sample-plugin/api/v1/course-archive-status/

  3. Check Admin: Go to http://localhost:18000/admin/ and look for "Course Archive Statuses"

Testing Your Plugin

Running Tests

cd backend-plugin-sample

# Install test dependencies
make requirements

# Run all tests
make test

# Run specific test
pytest tests/test_models.py::test_course_archive_status_creation

# Run with coverage
make test-coverage

Test Structure

Test Files:

Writing Plugin Tests

Model Testing Pattern:

from django.test import TestCase
from openedx_plugin_sample.models import CourseArchiveStatus

class TestCourseArchiveStatus(TestCase):
    def test_create_archive_status(self):
        # Test model creation and validation
        pass

API Testing Pattern:

from rest_framework.test import APITestCase
from django.contrib.auth import get_user_model

class TestCourseArchiveStatusAPI(APITestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(username="testuser")

    def test_list_archive_statuses(self):
        # Test API endpoints
        pass

Quality Checks

# Run linting and quality checks
make quality

# Individual tools
pylint openedx_plugin_sample/
isort --check-only openedx_plugin_sample/
black --check openedx_plugin_sample/

Integration Examples

Backend + Frontend Integration

API Endpoint (views.py):

class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
    # Provides data for frontend consumption

Frontend Consumption (see ../frontend-plugin-sample/src/plugin.jsx):

const response = await client.get(
  `${lmsBaseUrl}/sample-plugin/api/v1/course-archive-status/`
);

Events + Models Integration

@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(
        user_id=enrollment.user.id,
        course_id=enrollment.course.course_key,
        is_archived=True,
    ).update(is_archived=False, archive_date=None)

Filters + Settings Integration

Settings configure filter behavior:

# settings/common.py
def plugin_settings(settings):
    settings.SAMPLE_PLUGIN_REDIRECT_DOMAIN = "custom-domain.com"

# pipeline.py - Uses setting
class ChangeCourseAboutPageUrl(PipelineStep):
    def run_filter(self, url, org, **kwargs):
        redirect_domain = getattr(settings, 'SAMPLE_PLUGIN_REDIRECT_DOMAIN', 'example.com')
        new_url = f"https://{redirect_domain}/course/{course_id}"
        return {"url": new_url, "org": org}

Adapting This Plugin

For Your Use Case

  1. Models: Modify models.py for your data structure
  2. APIs: Update views.py and serializers.py
  3. Events: Change event handlers in signals.py
  4. Filters: Implement your business logic in pipeline.py
  5. Settings: Configure plugin behavior in settings/

Plugin Development Checklist

  • Update pyproject.toml with your plugin name and dependencies
  • Modify apps.py with your app configuration
  • Design your models in models.py
  • Create and run database migrations
  • Implement API endpoints in views.py
  • Add event handlers in signals.py
  • Create filters in pipeline.py
  • Configure settings in settings/
  • Write comprehensive tests
  • Update documentation

Common Customization Patterns

Adding New Models:

class YourModel(models.Model):
    # Use Open edX field types when possible
    course_id = CourseKeyField(max_length=255)
    user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
    # ... your fields

Adding New API Endpoints:

class YourViewSet(viewsets.ModelViewSet):
    # Follow the permission patterns from CourseArchiveStatusViewSet
    permission_classes = [IsOwnerOrStaffSuperuser]
    # ... your implementation

Adding New Event Handlers:

@receiver(YOUR_CHOSEN_EVENT)
def handle_your_event(signal, sender, event_data, **kwargs):
    # Your business logic
    pass

This backend plugin provides a solid foundation for any Open edX extension. Focus on adapting the business logic while keeping the proven patterns for authentication, permissions, and integration.