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.
- Overview
- Django App Plugin Configuration
- Models & Database
- API Endpoints
- Events & Signals
- Filters & Pipeline Steps
- Settings Configuration
- Development Setup
- Testing Your Plugin
- Integration Examples
- Adapting This Plugin
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:
File: sample_plugin/apps.py
The SamplePluginConfig class configures this app as an edx-platform plugin:
class SamplePluginConfig(AppConfig):
name = "sample_plugin"
plugin_app = {
"url_config": {
# Register URLs for both LMS and CMS
"lms.djangoapp": {
PluginURLs.NAMESPACE: "sample_plugin",
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
}
}| 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() |
In pyproject.toml, the plugin registers itself with edx-platform:
[project.entry-points."lms.djangoapp"]
sample_plugin = "sample_plugin.apps:SamplePluginConfig"
[project.entry-points."cms.djangoapp"]
sample_plugin = "sample_plugin.apps:SamplePluginConfig"Why this works: The platform automatically discovers and loads any Django app registered in these entry points.
File: sample_plugin/models.py
Official Docs: OEP-49: Django App Patterns
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)
# ... timestampsKey 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
# After modifying models.py
cd backend
python manage.py makemigrations sample_plugin
python manage.py migrateMigration files: Generated in sample_plugin/migrations/
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.
File: sample_plugin/views.py
URLs: sample_plugin/urls.py
class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
serializer_class = CourseArchiveStatusSerializer
permission_classes = [IsOwnerOrStaffSuperuser]
pagination_class = CourseArchiveStatusPagination
throttle_classes = [CourseArchiveStatusThrottle]
# ... filtering and ordering| 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 |
- 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
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.
File: sample_plugin/signals.py
Official Docs: Open edX Events Guide
from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_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 hereEvent Catalog: Open edX Events Reference
Common Events:
COURSE_CATALOG_INFO_CHANGED- Course information updatedSTUDENT_REGISTRATION_COMPLETED- New user registeredCERTIFICATE_CREATED- Certificate generated for learnerENROLLMENT_CREATED- Student enrolled in course
Each event includes specific data. For COURSE_CATALOG_INFO_CHANGED:
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 statusKey Point: Check the event definition to understand what data is available.
Handlers are automatically registered via the ready() method in apps.py:
def ready(self):
# Import handlers to register signal receivers
from . import signals- Integration: Send course updates to external systems
- Analytics: Track course lifecycle events
- Notifications: Email administrators about important changes
- Auditing: Log sensitive operations for compliance
File: sample_plugin/pipeline.py
Official Docs: Using Open edX Filters
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}Essential Elements:
- Inherit from
PipelineStep - Implement
run_filter()method - Return dictionary with same parameter names as input
- Handle all possible input scenarios
Filter Catalog: Open edX Filters Reference
Common Filters:
- Course enrollment filters
- Authentication filters
- Certificate generation filters
- Course discovery filters
Filters must be registered in Django settings. This happens automatically via the plugin settings system (see Settings Configuration).
- 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
Files: sample_plugin/settings/
# 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"
passcommon.py: Settings for all environmentsproduction.py: Production-only settingstest.py: Test-specific settings (faster database, etc.)
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": [
"sample_plugin.pipeline.ChangeCourseAboutPageUrl"
],
"fail_silently": False,
}
}Filter Name Discovery: Filter names are found in the official filters documentation.
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"- Platform Setup: Open edX Development Guide
- Python Environment: Python 3.8+ with virtual environment
# 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# In your edx-platform directory
pip install -e /path/to/sample-plugin/backend
# Run migrations
python manage.py lms migrate
python manage.py cms migrate-
Check Installation:
python manage.py lms shell >>> from sample_plugin.models import CourseArchiveStatus >>> print("Plugin installed successfully!") -
Test API: Visit
http://localhost:18000/sample-plugin/api/v1/course-archive-status/ -
Check Admin: Go to
http://localhost:18000/admin/and look for "Course Archive Statuses"
cd backend
# 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-coverageTest Files:
tests/test_models.py- Model functionalitytests/test_api.py- API endpoint testingtests/test_plugin_integration.py- Plugin integration
Model Testing Pattern:
from django.test import TestCase
from sample_plugin.models import CourseArchiveStatus
class TestCourseArchiveStatus(TestCase):
def test_create_archive_status(self):
# Test model creation and validation
passAPI 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# Run linting and quality checks
make quality
# Individual tools
pylint sample_plugin/
isort --check-only sample_plugin/
black --check sample_plugin/API Endpoint (views.py):
class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
# Provides data for frontend consumptionFrontend Consumption (see ../frontend/src/plugin.jsx):
const response = await client.get(
`${lmsBaseUrl}/sample-plugin/api/v1/course-archive-status/`
);@receiver(COURSE_CATALOG_INFO_CHANGED)
def sync_course_archive_on_change(signal, sender, catalog_info, **kwargs):
# Update archive statuses when course info changes
CourseArchiveStatus.objects.filter(
course_id=catalog_info.course_key
).update(last_synced=timezone.now())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}- Models: Modify
models.pyfor your data structure - APIs: Update
views.pyandserializers.py - Events: Change event handlers in
signals.py - Filters: Implement your business logic in
pipeline.py - Settings: Configure plugin behavior in
settings/
- Update
pyproject.tomlwith your plugin name and dependencies - Modify
apps.pywith 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
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 fieldsAdding New API Endpoints:
class YourViewSet(viewsets.ModelViewSet):
# Follow the permission patterns from CourseArchiveStatusViewSet
permission_classes = [IsOwnerOrStaffSuperuser]
# ... your implementationAdding New Event Handlers:
@receiver(YOUR_CHOSEN_EVENT)
def handle_your_event(signal, sender, event_data, **kwargs):
# Your business logic
passThis 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.