Skip to main content

A sample backend plugin for the Open edX Platform

Project description

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

openedx_plugin_sample-3.3.0.tar.gz (157.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

openedx_plugin_sample-3.3.0-py2.py3-none-any.whl (29.5 kB view details)

Uploaded Python 2Python 3

File details

Details for the file openedx_plugin_sample-3.3.0.tar.gz.

File metadata

  • Download URL: openedx_plugin_sample-3.3.0.tar.gz
  • Upload date:
  • Size: 157.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for openedx_plugin_sample-3.3.0.tar.gz
Algorithm Hash digest
SHA256 562a538706e3744104eced59523910e6ee737fc25dab63ea73d0a87c4c6e8502
MD5 e7fe10daab8edcdebc13459c541b8d2e
BLAKE2b-256 793728cdae9379d22de04e936520e7e4160620a2689ef9399cad757e7e5c5427

See more details on using hashes here.

File details

Details for the file openedx_plugin_sample-3.3.0-py2.py3-none-any.whl.

File metadata

File hashes

Hashes for openedx_plugin_sample-3.3.0-py2.py3-none-any.whl
Algorithm Hash digest
SHA256 11bb6fbf964947835f77fd403ffbef4e0cf3cbd32e72e8ee5c3f55eb0d92cb96
MD5 f3921dfabeb675e09f0f9858057b00e5
BLAKE2b-256 edb73d0aebda1156a952cf5190b7814cd3bc201b64fc30a71a205a21cc226664

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page