From b8f46f51f4e3ea98c7f2164875c35231ea182ef8 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Sun, 17 May 2026 16:59:48 -0400 Subject: [PATCH 1/2] docs: Rework READMEs Co-Authored-By: Claude Opus 4.7 (1M Context) --- README.md | 255 +++----------- backend-plugin-sample/README.md | 579 +----------------------------- brand-sample/README.md | 159 +++------ frontend-plugin-sample/README.md | 583 ++----------------------------- tutor-contrib-sample/README.md | 450 +----------------------- 5 files changed, 151 insertions(+), 1875 deletions(-) diff --git a/README.md b/README.md index 8a2f418..942c97f 100644 --- a/README.md +++ b/README.md @@ -1,243 +1,66 @@ # Open edX Sample Plugin -A comprehensive example demonstrating all major plugin interfaces available in the Open edX platform. This repository shows how to extend Open edX functionality without modifying core platform code. +A worked example of every major Open edX plugin interface, built around a small "course archiving" feature you can run end-to-end. Use this repo as a reference when building your own Open edX plugin. -## Table of Contents +This is a monorepo of four sub-packages, each demonstrating one extension point: -- [What This Repository Demonstrates](#what-this-repository-demonstrates) -- [Plugin Types & Official Documentation](#plugin-types--official-documentation) -- [Quick Start Guide](#quick-start-guide) -- [Learning Path for New Plugin Developers](#learning-path-for-new-plugin-developers) -- [Repository Structure](#repository-structure) -- [Development Workflows](#development-workflows) -- [Integration Examples](#integration-examples) -- [Troubleshooting](#troubleshooting) -- [Additional Resources](#additional-resources) +| Sub-package | Plugin type | What it does | +|---|---|---| +| [`backend-plugin-sample/`](./backend-plugin-sample/) | [Django app plugin](https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html) | Adds a `CourseArchiveStatus` model with a REST API, an [Open edX Events](https://docs.openedx.org/projects/openedx-events/en/latest/) handler, and an [Open edX Filters](https://docs.openedx.org/projects/openedx-filters/en/latest/) pipeline step | +| [`frontend-plugin-sample/`](./frontend-plugin-sample/) | [MFE plugin slot widget](https://docs.openedx.org/en/latest/site_ops/how-tos/use-frontend-plugin-slots.html) | Replaces the learner-dashboard course list with one that lets learners archive courses | +| [`brand-sample/`](./brand-sample/) | [Paragon brand package](https://github.com/openedx/paragon) | An autumn-inspired color palette | +| [`tutor-contrib-sample/`](./tutor-contrib-sample/) | [Tutor plugin](https://docs.tutor.edly.io/) | Installs and wires up the three above for a Tutor-based deployment | -## What This Repository Demonstrates +## Development with Tutor -This sample plugin showcases the **Open edX Hooks Extension Framework**, which allows you to extend the platform in a stable and maintainable way. The framework provides two main types of hooks: +Requires [Tutor](https://docs.tutor.edly.io/install.html) >= 20 with [tutor-mfe](https://github.com/overhangio/tutor-mfe), and an Open edX environment that supports design tokens (Paragon >= 23, "Teak" release or later). -- **Events**: React to things happening in the platform (e.g., when a course is published) -- **Filters**: Modify platform behavior (e.g., change where course about pages redirect) +### Running the demo as-is -**Key Concept**: All extensions are implemented as standard Django plugins that integrate seamlessly with edx-platform. - -**Official Documentation**: [Hooks Extension Framework Overview](https://docs.openedx.org/en/latest/developers/concepts/hooks_extension_framework.html) - -## Plugin Types & Official Documentation - -| Plugin Type | What It Does | Official Documentation | Sample Code | When To Use | -|-------------|--------------|------------------------|-------------|-------------| -| **Django App Plugin** | Add models, APIs, views, and business logic | [How to create a plugin app](https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html) | [`backend-plugin-sample/`](./backend-plugin-sample/) | Adding new functionality, APIs, or data models | -| **Events (Signals)** | React to platform events | [Open edX Events Guide](https://docs.openedx.org/projects/openedx-events/en/latest/) | [`backend-plugin-sample/openedx_plugin_sample/signals.py`](./backend-plugin-sample/openedx_plugin_sample/signals.py) | Integrating with external systems, audit logging | -| **Filters** | Modify platform behavior | [Using Open edX Filters](https://docs.openedx.org/projects/openedx-filters/en/latest/how-tos/using-filters.html) | [`backend-plugin-sample/openedx_plugin_sample/pipeline.py`](./backend-plugin-sample/openedx_plugin_sample/pipeline.py) | Customizing business logic, URL redirects | -| **Frontend Slots** | Customize MFE interfaces | [Frontend Plugin Slots](https://docs.openedx.org/en/latest/site_ops/how-tos/use-frontend-plugin-slots.html) | [`frontend-plugin-sample/`](./frontend-plugin-sample/) | UI customization, adding new components | -| **Brand Packages** | Customize theming | [Open edX Brand Package Interface](https://github.com/openedx/brand-openedx) | [`brand-sample/`](./brand-sample/) | UI theming | -| **Tutor Plugin** | Deploy plugins easily | [Tutor Plugin Development](https://docs.tutor.edly.io/) | [`tutor-contrib-sample/`](./tutor-contrib-sample/) | Simplified deployment and configuration | - -## Quick Start Guide - -### Prerequisites -1. **Platform Setup**: Follow the [Open edX Development Setup](https://docs.openedx.org/en/latest/developers/how-tos/get-ready-for-python-dev.html) -2. **Understanding**: Read the [Platform Overview](https://docs.openedx.org/en/latest/developers/concepts/platform_overview.html) - -### Option 1: Development with Tutor (Recommended) +The `tutor-contrib-sample` plugin in this repo installs the published backend, frontend, and brand packages and wires them into Tutor: ```bash -# Bind-mount backend source into Tutor image and containers. -tutor mounts add "$PWD/backend-plugin-sample" - -# Rebuild image, run migrations, reboot containers: -tutor dev launch - -# Frontend Plugin Setup (for learner-dashboard MFE development) -# Add env.config.jsx and module.config.js (see frontend-plugin-sample/README.md) -# Then, install and run. -cd path/to/frontend-app-learner-dashboard && npm ci && npm run dev -``` - -### Option 2: Development without Tutor - -```bash -# In your edx-platform directory -pip install -e /path/to/sample-plugin/backend-plugin-sample - -# Enable Learner Dashboard MFE -# Go to http://localhost:18000/admin/waffle/flag/ -# Create flag: learner_home_mfe.enabled = Yes - -# Run migrations -python manage.py lms migrate -``` - -### Verification - -1. **Backend**: Visit `http://localhost:18000/sample-plugin/api/v1/course-archive-status/` -2. **Frontend**: Check learner dashboard for archive/unarchive functionality -3. **Events**: Check logs for course catalog change events -4. **Filters**: Course about page URLs should redirect to example.com - -## Learning Path for New Plugin Developers - -### 1. Understand the Architecture -- **Start here**: [Hooks Extension Framework](https://docs.openedx.org/en/latest/developers/concepts/hooks_extension_framework.html) -- **Deep dive**: [OEP-50: Hooks Extension Framework](https://docs.openedx.org/projects/openedx-proposals/en/latest/architectural-decisions/oep-0050-hooks-extension-framework.html) - -### 2. Choose Your Plugin Type -Use the table above to identify which type of plugin matches your needs. You can combine multiple types in one plugin. - -### 3. Study the Sample Code -- **Backend**: Start with [`backend-plugin-sample/openedx_plugin_sample/apps.py`](./backend-plugin-sample/openedx_plugin_sample/apps.py) to understand plugin registration -- **Events**: Examine [`backend-plugin-sample/openedx_plugin_sample/signals.py`](./backend-plugin-sample/openedx_plugin_sample/signals.py) for event handling patterns -- **Filters**: Review [`backend-plugin-sample/openedx_plugin_sample/pipeline.py`](./backend-plugin-sample/openedx_plugin_sample/pipeline.py) for behavior modification -- **Frontend**: Explore [`frontend-plugin-sample/src/plugin.jsx`](./frontend-plugin-sample/src/plugin.jsx) for UI customization - -### 4. Run This Sample -Follow the [Quick Start Guide](#quick-start-guide) to see everything working together. - -### 5. Adapt for Your Use Case -Each directory contains detailed README.md files with adaptation guidance. - -## Repository Structure - -``` -sample-plugin/ -├── README.md # This file - overview and quick start -├── backend-plugin-sample/ -│ ├── README.md # Backend plugin detailed guide -│ ├── openedx_plugin_sample/ -│ │ ├── apps.py # Django plugin configuration -│ │ ├── models.py # Database models example -│ │ ├── views.py # REST API endpoints -│ │ ├── signals.py # Event handlers (Open edX Events) -│ │ ├── pipeline.py # Filter implementations (Open edX Filters) -│ │ ├── settings/ # Plugin settings configuration -│ │ └── urls.py # URL routing -│ └── tests/ # Comprehensive test examples -├── frontend-plugin-sample/ -│ ├── README.md # Frontend plugin detailed guide -│ ├── src/ -│ │ ├── plugin.jsx # React component for MFE slot -│ │ └── index.jsx # Export configuration -│ └── package.json # NPM package configuration -└── tutor-contrib-sample/ - ├── README.md # Tutor deployment guide - └── sample.py # Tutor plugin configuration +pip install -e ./tutor-contrib-sample +tutor plugins enable sample +tutor dev launch ``` -## Development Workflows - -### Backend Plugin Development - -1. **Setup**: Follow backend setup in [Quick Start](#quick-start-guide) -2. **Development**: - - Modify models in `models.py` - - Add API endpoints in `views.py` - - Implement event handlers in `signals.py` - - Create filters in `pipeline.py` -3. **Testing**: `cd backend-plugin-sample && make test` -4. **Quality**: `cd backend-plugin-sample && make quality` - -**Detailed Guide**: See [`backend-plugin-sample/README.md`](./backend-plugin-sample/README.md) - -### Frontend Plugin Development - -1. **Setup**: Follow frontend setup in [Quick Start](#quick-start-guide) -2. **Development**: - - Modify React components in `frontend-plugin-sample/src/` - - Test with local MFE development server -3. **Testing**: Integration testing with MFE - -**Detailed Guide**: See [`frontend-plugin-sample/README.md`](./frontend-plugin-sample/README.md) +This is enough to see everything working: visit the learner dashboard and you should see the customized course list rendered with the brand applied. See [`tutor-contrib-sample/README.md`](./tutor-contrib-sample/README.md) for what each piece of the plugin does. -### Full-Stack Plugin Development +### Hacking on the source -This sample shows how backend and frontend plugins work together: +To edit code in this repo and have your changes apply inside Tutor: -- **Backend** provides API endpoints for course archive status -- **Frontend** consumes these APIs to show archive/unarchive UI -- **Events** log when course information changes -- **Filters** modify course about page URLs - -## Integration Examples - -### Backend + Frontend Integration - -```python -# backend-plugin-sample/openedx_plugin_sample/views.py - Provides API -class CourseArchiveStatusViewSet(viewsets.ModelViewSet): - # API implementation -``` - -```jsx -// frontend-plugin-sample/src/plugin.jsx - Consumes API -const response = await client.get( - `${lmsBaseUrl}/sample-plugin/api/v1/course-archive-status/` -); -``` - -### Events + Filters Working Together - -```python -# Events: Log course changes -@receiver(COURSE_CATALOG_INFO_CHANGED) -def log_course_info_changed(signal, sender, catalog_info, **kwargs): - logging.info(f"{catalog_info.course_key} has been updated!") - -# Filters: Modify course about URLs -class ChangeCourseAboutPageUrl(PipelineStep): - def run_filter(self, url, org, **kwargs): - # Custom URL logic -``` +- **Backend** — `tutor-contrib-sample` registers `backend-plugin-sample` as a mounted directory, so a single command before launch is enough: -## Troubleshooting + ```bash + tutor mounts add "$PWD/backend-plugin-sample" + tutor dev launch + ``` -### Common Issues +- **Frontend** — bind-mount a local MFE checkout into `tutor-mfe`, then point its webpack at your local `frontend-plugin-sample` checkout. See [`frontend-plugin-sample/README.md`](./frontend-plugin-sample/README.md). -**Plugin not loading:** -- Verify `pyproject.toml` entry points are correct -- Check that plugin app is in INSTALLED_APPS (should be automatic) -- Review Django app plugin configuration in `apps.py` +- **Brand** — use [tutor-contrib-paragon](https://github.com/openedx/openedx-tutor-plugins/tree/main/plugins/tutor-contrib-paragon) to recompile and serve the brand from disk. See [`brand-sample/README.md`](./brand-sample/README.md). -**Events not firing:** -- Confirm signal receivers are imported in `apps.py` ready() method -- Check event is being sent by platform (some events only fire in specific contexts) -- Verify event data structure matches your handler signature +## Development without Tutor -**Filters not working:** -- Ensure filter is registered in Django settings -- Check that filter step class inherits from `PipelineStep` -- Verify `run_filter` method returns correct dictionary format +Assumes you already have edx-platform running locally (bare-metal or devstack-style venv) and at least one MFE checked out. -**Frontend plugin not appearing:** -- Check MFE slot configuration in `env.config.jsx` -- Verify plugin is installed (`npm install`) -- Ensure slot exists in target MFE (check MFE documentation) +- **Backend** — install editable into the edx-platform Python environment and migrate: -### Getting Help + ```bash + pip install -e ./backend-plugin-sample + python manage.py lms migrate openedx_plugin_sample + python manage.py cms migrate openedx_plugin_sample + ``` -1. **Documentation**: Start with official docs linked in the [Plugin Types table](#plugin-types--official-documentation) -2. **Community**: [Open edX Community Slack](https://openedx.org/slack) -3. **Forums**: [Open edX Discuss Forums](https://discuss.openedx.org) -4. **Issues**: Create issues in this repository for sample-specific problems +- **Frontend** — in your MFE checkout, add the `module.config.js` and `env.config.jsx` shown in [`frontend-plugin-sample/README.md`](./frontend-plugin-sample/README.md), then `npm ci && npm start`. -## Additional Resources +- **Brand** — set `PARAGON_THEME_URLS.variants.light.urls.brandOverride` in your MFE's `env.config.js[x]` (or `theme.variants.light.url` in a frontend-base `site.config.tsx`) to `https://cdn.jsdelivr.net/gh/openedx/sample-plugin@main/brand-sample/dist/light.min.css`. See [`brand-sample/README.md`](./brand-sample/README.md) for the full snippet. -### Official Documentation -- **Platform**: [Open edX Developer Documentation](https://docs.openedx.org/en/latest/developers/) -- **Architecture**: [OEP-49: Django App Patterns](https://docs.openedx.org/projects/openedx-proposals/en/latest/best-practices/oep-0049-django-app-patterns.html) -- **Events**: [Open edX Events Reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html) -- **Filters**: [Open edX Filters Reference](https://docs.openedx.org/projects/openedx-filters/en/latest/reference/filters.html) -- **Frontend**: [Available Frontend Plugin Slots](https://docs.openedx.org/en/latest/site_ops/references/frontend-plugin-slots.html) + > TODO: a fully local brand-development flow without Tutor (recompile + serve from disk) is not yet documented. -### Community Resources -- **Cookiecutter**: [Django App Template](https://github.com/openedx/cookiecutter-django-app) for creating new plugins -- **Examples**: Other Open edX plugins in the [openedx organization](https://github.com/openedx) -- **Best Practices**: [OEP Index](https://docs.openedx.org/projects/openedx-proposals/en/latest/) for architectural guidance +## Getting help -### What This Sample Provides That Official Docs Don't -- **Working Integration**: Complete example showing all plugin types working together -- **Real Business Logic**: Realistic course archiving functionality vs. hello-world examples -- **Development Workflow**: End-to-end development and testing process -- **Troubleshooting**: Common plugin development issues and solutions +- Open edX [community Slack](https://openedx.org/slack) and [discussion forums](https://discuss.openedx.org) +- Issues with this sample specifically: [openedx/sample-plugin issues](https://github.com/openedx/sample-plugin/issues) diff --git a/backend-plugin-sample/README.md b/backend-plugin-sample/README.md index 3e208e5..30843e6 100644 --- a/backend-plugin-sample/README.md +++ b/backend-plugin-sample/README.md @@ -1,577 +1,36 @@ -# Backend Plugin Implementation Guide +# backend-plugin-sample -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. +A Django app plugin for edx-platform that adds a small course-archiving feature: learners can mark courses as archived (hidden from their active list) and unarchive them later. It demonstrates three backend extension points working together: -## Table of Contents +- A model + REST API (`CourseArchiveStatus`), consumed by [`frontend-plugin-sample`](../frontend-plugin-sample/) +- An [Open edX Events](https://docs.openedx.org/projects/openedx-events/en/latest/) handler that auto-unarchives on verified upgrade +- An [Open edX Filters](https://docs.openedx.org/projects/openedx-filters/en/latest/) pipeline step that rewrites the course-about URL -- [Overview](#overview) -- [Django App Plugin Configuration](#django-app-plugin-configuration) -- [Models & Database](#models--database) -- [API Endpoints](#api-endpoints) -- [Events & Signals](#events--signals) -- [Filters & Pipeline Steps](#filters--pipeline-steps) -- [Settings Configuration](#settings-configuration) -- [Development Setup](#development-setup) -- [Testing Your Plugin](#testing-your-plugin) -- [Integration Examples](#integration-examples) -- [Adapting This Plugin](#adapting-this-plugin) +## How to use it -## Overview +See the root [README](../README.md) for setup instructions. With Tutor, [`tutor-contrib-sample`](../tutor-contrib-sample/) installs this plugin automatically (or bind-mounts your local checkout if you `tutor mounts add` it). Without Tutor, `pip install -e .` into your edx-platform environment and run migrations. -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. +## How it works -**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 +**Plugin registration.** [`apps.py`](./src/openedx_plugin_sample/apps.py) declares the Django app to edx-platform via the `plugin_app` config (URL routing, settings, signal registration). The entry points in [`pyproject.toml`](./pyproject.toml) make the platform discover the app automatically — no `INSTALLED_APPS` edit needed. See [How to create a plugin app](https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html). -**Official Documentation:** -- [Django App Plugins Overview](https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/readme.html) -- [How to create a plugin app](https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html) -- [Hooks Extension Framework](https://docs.openedx.org/en/latest/developers/concepts/hooks_extension_framework.html) +**Model.** [`models.py`](./src/openedx_plugin_sample/models.py) defines `CourseArchiveStatus(user, course_id, is_archived, archive_date)`, indexed for the lookups the API performs. Registered in Django admin via [`admin.py`](./src/openedx_plugin_sample/admin.py). -## Django App Plugin Configuration +**REST API.** [`views.py`](./src/openedx_plugin_sample/views.py) exposes the model as a DRF `ModelViewSet` at `/sample-plugin/api/v1/course-archive-status/`, with per-user permissions, throttling, and pagination. Serializer in [`serializers.py`](./src/openedx_plugin_sample/serializers.py); URLs in [`urls.py`](./src/openedx_plugin_sample/urls.py). Business logic (e.g. setting `archive_date` when `is_archived` flips true) lives in `perform_create`/`perform_update` rather than in the serializer. -**File**: [`openedx_plugin_sample/apps.py`](./openedx_plugin_sample/apps.py) +**Event handler.** [`signals.py`](./src/openedx_plugin_sample/signals.py) listens for `COURSE_ENROLLMENT_CHANGED` and unarchives a learner's course when they upgrade to the verified track. An event (not a filter) is the right shape here because we want a one-time nudge at the moment of upgrade — if the learner re-archives the course later, we respect that. A filter would re-impose the rule on every render. -### Plugin Registration +**Filter.** [`pipeline.py`](./src/openedx_plugin_sample/pipeline.py) implements `ChangeCourseAboutPageUrl`, a `PipelineStep` for `org.openedx.learning.course.about.render.started.v1` that rewrites course-about URLs to an external host. Registered via `OPEN_EDX_FILTERS_CONFIG` in [`settings/common.py`](./src/openedx_plugin_sample/settings/common.py). -The `SamplePluginConfig` class configures this app as an edx-platform plugin: +**Settings.** Per-environment settings live in [`settings/`](./src/openedx_plugin_sample/settings/) (`common.py`, `production.py`, `test.py`). The plugin app loads these via its `plugin_app` config in `apps.py`. -```python -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](https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html#plugin-urls) | -| **PluginSettings.CONFIG** | Load plugin settings | [Plugin Settings](https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html#plugin-settings) | -| **ready() method** | Initialize signal handlers | [Django AppConfig.ready()](https://docs.djangoproject.com/en/stable/ref/applications/#django.apps.AppConfig.ready) | - -### Entry Points Configuration - -In [`pyproject.toml`](./pyproject.toml), the plugin registers itself with edx-platform: - -```python -[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`](./openedx_plugin_sample/models.py) -**Official Docs**: [OEP-49: Django App Patterns](https://docs.openedx.org/projects/openedx-proposals/en/latest/best-practices/oep-0049-django-app-patterns.html) - -### CourseArchiveStatus Model - -```python -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 - -```bash -# 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/`](./openedx_plugin_sample/migrations/) - -### PII Annotations - -The model includes PII documentation: -```python -# .. 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`](./openedx_plugin_sample/views.py) -**URLs**: [`openedx_plugin_sample/urls.py`](./openedx_plugin_sample/urls.py) - -### REST API Implementation - -```python -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: - -```python -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](../CLAUDE.md#api-development-guidelines). - -## Events & Signals - -**File**: [`openedx_plugin_sample/signals.py`](./openedx_plugin_sample/signals.py) -**Official Docs**: [Open edX Events Guide](https://docs.openedx.org/projects/openedx-events/en/latest/) - -### 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.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](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html) - -**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`: - -```python -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](https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html) to understand the exact fields available for each event. - -### Signal Handler Registration - -Handlers are automatically registered via the `ready()` method in [`apps.py`](./openedx_plugin_sample/apps.py): - -```python -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`](./openedx_plugin_sample/pipeline.py) -**Official Docs**: [Using Open edX Filters](https://docs.openedx.org/projects/openedx-filters/en/latest/how-tos/using-filters.html) - -### Filter Implementation - -```python -from openedx_filters.filters import PipelineStep - -class ChangeCourseAboutPageUrl(PipelineStep): - def run_filter(self, url, org, **kwargs): - # Extract course ID from URL - pattern = r'(?Pcourse-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](https://docs.openedx.org/projects/openedx-filters/en/latest/reference/filters.html) - -**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](#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/`](./openedx_plugin_sample/settings/) - -### Settings Structure - -```python -# 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`: - -```python -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](https://docs.openedx.org/projects/openedx-filters/en/latest/reference/filters.html). - -### Plugin-Specific Settings - -Add custom configuration: - -```python -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](https://docs.openedx.org/en/latest/developers/how-tos/get-ready-for-python-dev.html) -2. **Python Environment**: Python 3.8+ with virtual environment - -### Installation Methods - -#### Option 1: With Tutor (Recommended) - -```bash -# 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 - -```bash -# 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**: - ```bash - 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 +## Testing and quality ```bash 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:** -- [`tests/test_models.py`](./tests/test_models.py) - Model functionality -- [`tests/test_api.py`](./tests/test_api.py) - API endpoint testing -- [`tests/test_plugin_integration.py`](./tests/test_plugin_integration.py) - Plugin integration - -### Writing Plugin Tests - -**Model Testing Pattern:** -```python -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:** -```python -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 - -```bash -# 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`): -```python -class CourseArchiveStatusViewSet(viewsets.ModelViewSet): - # Provides data for frontend consumption -``` - -**Frontend Consumption** (see [`../frontend-plugin-sample/src/plugin.jsx`](../frontend-plugin-sample/src/plugin.jsx)): -```javascript -const response = await client.get( - `${lmsBaseUrl}/sample-plugin/api/v1/course-archive-status/` -); -``` - -### Events + Models Integration - -```python -@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: -```python -# 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`](./openedx_plugin_sample/models.py) for your data structure -2. **APIs**: Update [`views.py`](./openedx_plugin_sample/views.py) and [`serializers.py`](./openedx_plugin_sample/serializers.py) -3. **Events**: Change event handlers in [`signals.py`](./openedx_plugin_sample/signals.py) -4. **Filters**: Implement your business logic in [`pipeline.py`](./openedx_plugin_sample/pipeline.py) -5. **Settings**: Configure plugin behavior in [`settings/`](./openedx_plugin_sample/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:** -```python -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:** -```python -class YourViewSet(viewsets.ModelViewSet): - # Follow the permission patterns from CourseArchiveStatusViewSet - permission_classes = [IsOwnerOrStaffSuperuser] - # ... your implementation -``` - -**Adding New Event Handlers:** -```python -@receiver(YOUR_CHOSEN_EVENT) -def handle_your_event(signal, sender, event_data, **kwargs): - # Your business logic - pass +make requirements # install test deps +make test # pytest +make quality # lint ``` -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. +Tests live in [`tests/`](./tests/). diff --git a/brand-sample/README.md b/brand-sample/README.md index f18df99..1f3559b 100644 --- a/brand-sample/README.md +++ b/brand-sample/README.md @@ -1,111 +1,30 @@ # brand-sample -**This is a simple example brand package that changes the `brand` colors to an autumn-inspired palette.** +A small Paragon brand package that recolors Open edX with an autumn-inspired palette, demonstrating how to use the [design tokens](https://github.com/openedx/paragon) interface to theme platform MFEs. -### Before -![Screenshot of the Authn MFE with this brand package enabled](./docs/images/authn-without-theme.png) - -### After -![Screenshot of the Authn MFE with this brand package enabled](./docs/images/authn-with-theme.png) - -## Using this brand package - -Here are 4 different approaches to using this brand package. +| Before | After | +|---|---| +| ![Authn MFE without theme](./docs/images/authn-without-theme.png) | ![Authn MFE with theme](./docs/images/authn-with-theme.png) | > [!IMPORTANT] -> These instructions assume that you have an Open edX environment that supports design tokens: -> * **Paragon >= 23** -> * **Open edX "Teak" release or later** -> * A provisioned Open edX dev environment, either: -> * **Tutor >= 20** -> * A "bare-metal" setup - -### Local brand dev using Tutor + Tutor Paragon Plugin - -This will allow you to hack on the brand, recompile it, and preview it in your local Tutor instance. - -First, set up the [Tutor Paragon Plugin](https://github.com/openedx/openedx-tutor-plugins/tree/main/plugins/tutor-contrib-paragon), which will reproducibly compile and serve brands for you: - -```bash -# Install and enable the Tutor Paragon plugin. -tutor plugins install https://github.com/openedx/openedx-tutor-plugins/tree/main/plugins/tutor-contrib-paragon -tutor plugins enable paragon - -# Build the paragon-builder image. -# With this built, the 'tutor local do paragon-build-tokens' command becomes available. -tutor images build paragon-builder - -# Ensure MFE container is running if it isn't already. -# The MFE image will serve the CSS that you compile with paragon-build-tokens. -tutor dev start -d lms cms mfe -``` - -Every time you edit a theme, you will need to copy it into your tutor root and re-run paragon-build-tokens. You can do so by running the following from the root of the sample-plugin repository: - -```bash -tutor_root="$(tutor config printroot)" -[ -n "$tutor_root" ] \ - && rm -rf "$tutor_root/env/plugins/paragon/theme-sources/themes" \ - && cp -r brand-sample/tokens/src/themes "$tutor_root/env/plugins/paragon/theme-sources" \ - && tutor local do paragon-build-tokens \ - && echo 'Compiled design tokens :)' \ - || echo 'Could not copy design token sources into tutor environment :(' -``` - -Note: If you are having issues building the tokens, check the contents of the paragon plugin folder within your tutor root. It should look like this: - -```bash -tree "$(tutor config printroot)/env/plugins/paragon" - ├── [...] - └── theme-sources - └── themes - └── light - └── global - └── color.json -``` - -### Local brand dev without Tutor +> Requires an Open edX environment with design-token support: Paragon >= 23 and the "Teak" release or later. -TODO write this section +## How to use it -### jsdeliver + Tutor +See the root [README](../README.md) for setup. The short version: -*tutor-contrib-sample ships with this approach, for your convenience.* +- **With Tutor** — [`tutor-contrib-sample`](../tutor-contrib-sample/) configures the brand override from jsDelivr automatically. +- **Without Tutor** — point your MFE at the published CSS via `env.config.js[x]` (see [Consuming the published brand](#consuming-the-published-brand) below). +- **Hacking on the brand locally with Tutor** — see [Local brand development with Tutor](#local-brand-development-with-tutor) below. +- **Hacking on the brand locally without Tutor** — these docs are coming soon. -This configures Tutor so that your frontend loads the brand-sample from the [`jsdelivr`](https://www.jsdelivr.com/) CDN. It assumes that the brand exists on GitHub. This does not support local brand development. +## How it works -Add this to a tutor plugin, and then enable the plugin and restart tutor: +The brand source is a small set of Paragon [design tokens](./tokens/src/themes/light/global/color.json). The npm `build` script (see [`package.json`](./package.json)) invokes Paragon's `build-tokens` and `build-scss` to compile `dist/light.min.css`, which consumer MFEs load as a Paragon `brandOverride` on top of the default light theme. -```py -import json -from tutor import hooks +## Consuming the published brand -paragon_theme_urls = { - "variants": { - "light": { - "urls": { - "default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css", - "brandOverride": "https://cdn.jsdelivr.net/gh/openedx/sample-plugin@main/brand-sample/dist/light.min.css" - } - } - } -} - -fstring = f""" -MFE_CONFIG["PARAGON_THEME_URLS"] = {json.dumps(paragon_theme_urls)} -""" - -hooks.Filters.ENV_PATCHES.add_item( - ( - "mfe-lms-common-settings", - fstring - ) -) -``` - -### jsdeliver without Tutor - -Within each MFE, configure its `env.config.js[x]` to install this theme: +For an MFE configured via `env.config.js[x]`: ```js const config = { @@ -113,8 +32,8 @@ const config = { variants: { light: { urls: { - "default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css", - "brandOverride": "https://cdn.jsdelivr.net/gh/openedx/sample-plugin@main/brand-sample/dist/light.min.css" + default: 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/gh/openedx/sample-plugin@main/brand-sample/dist/light.min.css', }, }, }, @@ -124,18 +43,52 @@ const config = { export default config; ``` -If you are running a frontend-base site, add to the `SiteConfig` object in your `site.config.ts[x]`: +For a frontend-base site, set the equivalent in `site.config.tsx`: ```tsx const siteConfig: SiteConfig = { - [...] + // ... theme: { variants: { light: { - url: 'https://cdn.jsdelivr.net/gh/openedx/sample-plugin@main/brand-sample/dist/light.min.css + url: 'https://cdn.jsdelivr.net/gh/openedx/sample-plugin@main/brand-sample/dist/light.min.css', }, }, }, - [...] -} +}; +``` + +## Local brand development with Tutor + +Use [tutor-contrib-paragon](https://github.com/openedx/openedx-tutor-plugins/tree/main/plugins/tutor-contrib-paragon) to recompile and serve the brand from your local checkout instead of jsDelivr. + +First, install the plugin and build its image: + +```bash +tutor plugins install https://github.com/openedx/openedx-tutor-plugins/tree/main/plugins/tutor-contrib-paragon +tutor plugins enable paragon +tutor images build paragon-builder +tutor dev start -d lms cms mfe +``` + +After each edit to the theme sources, copy them into the Tutor root and rebuild. From the root of this repo: + +```bash +tutor_root="$(tutor config printroot)" +[ -n "$tutor_root" ] \ + && rm -rf "$tutor_root/env/plugins/paragon/theme-sources/themes" \ + && cp -r brand-sample/tokens/src/themes "$tutor_root/env/plugins/paragon/theme-sources" \ + && tutor dev do paragon-build-tokens \ + && echo 'Your design tokens are ready to be built :)' \ + || echo 'Could not copy design token sources into tutor environment :(' +``` + +If the build fails, check that `"$(tutor config printroot)/env/plugins/paragon"` looks like: + +``` +└── theme-sources + └── themes + └── light + └── global + └── color.json ``` \ No newline at end of file diff --git a/frontend-plugin-sample/README.md b/frontend-plugin-sample/README.md index f44b193..a5b2d5b 100644 --- a/frontend-plugin-sample/README.md +++ b/frontend-plugin-sample/README.md @@ -1,347 +1,46 @@ -# Frontend Plugin Implementation Guide +# frontend-plugin-sample -This directory contains a React component that demonstrates how to customize Open edX micro-frontends (MFEs) using the Frontend Plugin Framework. The plugin replaces the default course list in the learner dashboard with a custom implementation that includes course archiving functionality. +A React component that replaces the learner-dashboard's course list with one that supports archiving courses. Wired into the `org.openedx.frontend.learner_dashboard.course_list.v1` plugin slot. Reads each course's archive state from a filter-injected slot prop and writes back to the [`backend-plugin-sample`](../backend-plugin-sample/) REST API on toggle. -## Table of Contents +## How to use it -- [Overview](#overview) -- [Frontend Plugin Framework](#frontend-plugin-framework) -- [CourseList Component Example](#courselist-component-example) -- [Slot Integration Patterns](#slot-integration-patterns) -- [API Integration](#api-integration) -- [Development Workflow](#development-workflow) -- [Deployment Considerations](#deployment-considerations) -- [Customizing This Example](#customizing-this-example) -- [Troubleshooting](#troubleshooting) +See the root [README](../README.md) for setup instructions. With Tutor, [`tutor-contrib-sample`](../tutor-contrib-sample/) installs the published npm package and wires it into the learner-dashboard slot. For local source development (with or without Tutor), the MFE-side files in [Local development setup](#local-development-setup) below are required. -## Overview +## How it works -This frontend plugin demonstrates **Open edX MFE customization** using the Frontend Plugin Framework to replace the course list component in the learner dashboard. +**The component.** [`src/plugin.jsx`](./src/plugin.jsx) exports `CourseList`, a Paragon-styled replacement for the learner-dashboard's default course list. It receives `courseListData` (`visibleList`, `filterOptions`, etc.) as a slot prop. -**What this plugin provides:** -- **Custom CourseList Component**: Enhanced course display with archive functionality -- **Backend API Integration**: Connects to the sample backend plugin APIs -- **Slot Replacement Pattern**: Shows how to replace existing MFE components -- **State Management**: React patterns for plugin development -- **Authentication Integration**: Uses Open edX authentication system +**Reading archive state without an extra API call.** The initial archive flag is read directly from each course run as `courseRun.isArchivedByLearner`. That field is injected into the learner-dashboard's `/init` response by the backend plugin's filter ([`pipeline.py`](../backend-plugin-sample/src/openedx_plugin_sample/pipeline.py)), which saves a round-trip on every dashboard load and keeps the archive state consistent with the rest of the course data from the same response. The REST API is still used for writes when the learner clicks archive/unarchive. -**Official Documentation:** -- [Frontend Plugin Slots](https://docs.openedx.org/en/latest/site_ops/how-tos/use-frontend-plugin-slots.html) -- [Available Plugin Slots Reference](https://docs.openedx.org/en/latest/site_ops/references/frontend-plugin-slots.html) -- [OEP-65: Frontend Composability](https://docs.openedx.org/projects/openedx-proposals/en/latest/architectural-decisions/oep-0065-arch-frontend-composability.html) +**Authentication and config.** Writes go through `getAuthenticatedHttpClient()` from `@edx/frontend-platform/auth`, and the LMS origin comes from `getConfig().LMS_BASE_URL`. UI components are from [Paragon](https://paragon-openedx.netlify.app/). -## Frontend Plugin Framework +## Local development setup -### What Are Plugin Slots? +Two files go in your MFE checkout root (e.g. `frontend-app-learner-dashboard/`), neither committed: -A "frontend plugin slot" is an area of a web page that can be customized with different visual elements without forking the codebase. This allows site operators to customize MFEs using configuration files. +**`module.config.js`** — tells the MFE's webpack to resolve `@openedx/plugin-sample` to your local source tree instead of `node_modules`: -**Key Concepts:** -- **Slot**: A predefined customization point in an MFE -- **Plugin**: Custom code that fills or modifies a slot -- **Operations**: Actions you can take on slots (Insert, Modify, Replace) - -### Plugin Operations - -| Operation | What It Does | When To Use | -|-----------|--------------|-------------| -| **Insert** | Add new components before/after existing ones | Adding new features alongside existing ones | -| **Modify** | Change properties of existing components | Tweaking existing functionality | -| **Replace** | Completely replace existing components | Major customization (like this example) | - -### Discovering Available Slots - -**Slot Documentation**: [Available Frontend Plugin Slots](https://docs.openedx.org/en/latest/site_ops/references/frontend-plugin-slots.html) - -**MFE-Specific Slots**: Each MFE documents its slots in `/src/plugin-slots/` directory: -- [Learner Dashboard Slots](https://github.com/openedx/frontend-app-learner-dashboard/tree/master/src/plugin-slots) -- [Course Authoring Slots](https://github.com/openedx/frontend-app-course-authoring/tree/master/src/plugin-slots) -- [Gradebook Slots](https://github.com/openedx/frontend-app-gradebook/tree/master/src/plugin-slots) - -## CourseList Component Example - -**File**: [`src/plugin.jsx`](./src/plugin.jsx) - -### Component Structure - -```jsx -const CourseList = ({ courseListData }) => { - const [archivedCourses, setArchivedCourses] = useState(new Set()); - const [loadingStates, setLoadingStates] = useState(new Map()); - - // Component implementation... -}; -``` - -### Key Features - -#### 1. Slot Data Integration - -The component receives `courseListData` from the learner dashboard slot: - -```jsx -// Safety check for slot data -if (!courseListData || !courseListData.visibleList) { - return
Loading courses...
; -} - -const courses = courseListData.visibleList; -``` - -**Slot Props**: Each slot provides specific data. For CourseListSlot, see the [slot documentation](https://github.com/openedx/frontend-app-learner-dashboard/tree/master/src/plugin-slots/CourseListSlot#plugin-props). - -#### 2. Backend Data via the Filter Pipeline - -Rather than firing an extra GET to `course-archive-status/` on every dashboard -load, the initial archive state is read directly off the slot props. The backend -plugin uses an Open edX filter (see [`pipeline.py`](../backend-plugin-sample/src/openedx_plugin_sample/pipeline.py)) -to inject `isArchivedByLearner` into each courseRun in the Learner Home `/init` -API response, so it arrives alongside the rest of the course data: - -```jsx -const [archivedCourses, setArchivedCourses] = useState(() => { - const initial = new Set(); - (courseListData?.visibleList || []).forEach((courseData) => { - if (courseData.courseRun?.isArchivedByLearner) { - initial.add(courseData.courseRun.courseId); - } - }); - return initial; -}); -``` - -**Why this pattern**: One fewer round-trip per dashboard load, and the archive -state is consistent with the rest of the course data from the same response. -The REST API is still used for writes (archive/unarchive) — see the toggle -handler below. - -**Key Patterns:** -- **Filter-injected data**: Read `courseRun.isArchivedByLearner` straight from slot props -- **Authentication** (for writes): `getAuthenticatedHttpClient()` handles Open edX auth -- **Configuration**: `getConfig().LMS_BASE_URL` gets platform URLs - -#### 3. Open edX UI Components - -The plugin uses **Paragon** (Open edX's design system): - -```jsx -import { - Card, - Container, - Row, - Col, - Badge, - Collapsible, - Button, - Spinner, - Dropdown, - IconButton, - Icon, -} from "@openedx/paragon"; -import { Archive, Unarchive, MoreVert } from "@openedx/paragon/icons"; -``` - -**Why Paragon**: Ensures consistent styling with the rest of Open edX interfaces. - -**Paragon Documentation**: [Paragon Design System](https://paragon-openedx.netlify.app/) - -### State Management - -#### Archive Status Management - -```jsx -const [archivedCourses, setArchivedCourses] = useState(new Set()); -const [loadingStates, setLoadingStates] = useState(new Map()); - -const handleArchiveToggle = async (courseId, isCurrentlyArchived) => { - setLoadingStates((prev) => new Map(prev).set(courseId, true)); - - try { - // API calls to backend - if (isCurrentlyArchived) { - // Unarchive logic - } else { - // Archive logic - } - - // Update local state - setArchivedCourses((prev) => { - const newSet = new Set(prev); - isCurrentlyArchived ? newSet.delete(courseId) : newSet.add(courseId); - return newSet; - }); - } catch (error) { - console.error("Archive operation failed:", error); - } finally { - setLoadingStates((prev) => { - const newMap = new Map(prev); - newMap.delete(courseId); - return newMap; - }); - } -}; -``` - -**Patterns Used:** -- **Optimistic Updates**: Update UI immediately, rollback on failure -- **Loading States**: Track loading per course for better UX -- **Immutable Updates**: Use functional setState for complex state - -## Slot Integration Patterns - -### CourseListSlot Integration - -**Target Slot**: `course_list_slot` in learner dashboard - -**Configuration Pattern** (for local development in `env.config.jsx`): - -```javascript -import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; -import { CourseList } from '@openedx/plugin-sample'; - -const config = { - pluginSlots: { - course_list_slot: { - keepDefault: false, // Hide original component - plugins: [ - { - op: PLUGIN_OPERATIONS.Insert, - widget: { - id: 'custom_course_list', - type: DIRECT_PLUGIN, - priority: 60, - RenderWidget: CourseList // Your custom component - }, - }, - ], - }, - }, -} -``` - -### Plugin Configuration Options - -| Option | Purpose | Values | -|--------|---------|--------| -| **keepDefault** | Show/hide original component | `true`, `false` | -| **op** | Plugin operation type | `Insert`, `Modify`, `Replace` | -| **priority** | Loading order | Higher numbers load later | -| **type** | Plugin implementation type | `DIRECT_PLUGIN`, `IFRAME_PLUGIN` | -| **RenderWidget** | Your React component | Component reference | - -### Slot Props and Data - -Each slot provides specific props. For CourseListSlot: - -```jsx -const CourseList = ({ - courseListData, // Course data from platform - // Other props depend on the slot -}) => { - // courseListData.visibleList - Array of course objects - // courseListData.course - Course metadata - // courseListData.courseRun - Course run information -}; -``` - -**Finding Slot Props**: Check the slot's README in the MFE repository, or examine the slot implementation in `/src/plugin-slots/`. - -## API Integration - -### Authentication Patterns - -**Open edX Authentication**: -```jsx -import { getAuthenticatedHttpClient } from "@edx/frontend-platform/auth"; - -const client = getAuthenticatedHttpClient(); -// Client automatically includes authentication headers -``` - -**Configuration Access**: -```jsx -import { getConfig } from "@edx/frontend-platform"; - -const lmsBaseUrl = getConfig().LMS_BASE_URL; -const apiUrl = `${lmsBaseUrl}/sample-plugin/api/v1/course-archive-status/`; -``` - -### Error Handling Best Practices - -```jsx -try { - const response = await client.post(url, data); - // Success handling -} catch (error) { - console.error("API Error:", { - status: error.response?.status, - statusText: error.response?.statusText, - data: error.response?.data, - message: error.message, - }); - - // User feedback - // Consider using toast notifications or error states -} -``` - -### API Response Handling - -```jsx -// Handle paginated responses -const response = await client.get(url); -const items = response.data.results || []; // DRF pagination format - -// Handle different response formats -if (response.data && Array.isArray(response.data)) { - // Direct array response -} else if (response.data.results) { - // Paginated response -} else { - // Single object response -} -``` - -## Development Workflow - -### Prerequisites - -1. **Tutor & Tutor-MFE Setup**: Tutor is installed and launched in `dev` mode. -2. **Backend Plugin**: Install the backend plugin (see [`../backend-plugin-sample/README.md`](../backend-plugin-sample/README.md)) -3. **Node.js**: Version 16+ with npm or yarn - -### Local Development Setup - -#### Step 1: Create module.config.js - -Create `module.config.js` in your MFE root, not committed to the repo. -This tells the MFE to load/use the `@openedx/sample-plugin` package -as a source (non-built) distribution . - -```javascript +```js module.exports = { localModules: [ { moduleName: '@openedx/plugin-sample', - dir: '/path/to/sample-plugin/frontend-plugin-sample', - dist: 'src' + dir: '/absolute/path/to/sample-plugin/frontend-plugin-sample', + dist: 'src', }, ], }; ``` -#### Step 2: Create env.config.jsx - -Create `env.config.jsx` in your MFE root, not committed to the repo. -This plugs the sample widget into the course list slot. +**`env.config.jsx`** — plugs the component into the slot: -```javascript +```jsx import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; import { CourseList } from '@openedx/plugin-sample'; const config = { pluginSlots: { - course_list_slot: { + 'org.openedx.frontend.learner_dashboard.course_list.v1': { keepDefault: false, plugins: [ { @@ -350,261 +49,27 @@ const config = { id: 'custom_course_list', type: DIRECT_PLUGIN, priority: 60, - RenderWidget: CourseList + RenderWidget: CourseList, }, }, ], }, }, -} +}; export default config; ``` -**Purpose**: Webpack uses your local plugin code instead of the installed package. - -#### Step 3: Start Development -Now, from the MFE repository root, install requirements and run the dev server. +Then, from the MFE checkout: ```bash -# Install requirements npm ci -# If running Tutor: -tutor mounts add . # Instruct tutor-mfe to redict requests to this local MFE devserver -tutor dev reboot -d mfe +# With Tutor — redirect tutor-mfe at your local MFE devserver: +tutor mounts add . +tutor dev reboot -d mfe npm run dev -# If not running Tutor: +# Without Tutor: npm start ``` - -### Development vs Production Configuration - -**Local Development**: -- Uses `env.config.jsx` for slot configuration -- Uses `module.config.js` for local code loading -- Hot reload for faster development - -**Production Deployment**: -- Configuration via Tutor plugins -- Plugin installed as npm package -- Optimized builds and caching - -### Testing Frontend Plugins - -#### Unit Testing - -```javascript -// Example test structure -import { render, screen } from '@testing-library/react'; -import { CourseList } from './plugin'; - -describe('CourseList Plugin', () => { - test('renders course list with archive functionality', () => { - const mockCourseData = { - visibleList: [/* mock course data */] - }; - - render(); - - expect(screen.getByText('Archive')).toBeInTheDocument(); - }); -}); -``` - -#### Integration Testing - -Test within the actual MFE environment: - -1. Set up MFE with plugin installed -2. Create test courses in platform -3. Verify plugin functionality -4. Test API integration -5. Check error handling - -## Deployment Considerations - -### Production Deployment with Tutor - -**Tutor Plugin Configuration** (see [`../tutor-contrib-sample/README.md`](../tutor-contrib-sample/README.md)): - -```python -# In tutor plugin -PLUGIN_SLOTS.add_items([ - ( - "learner-dashboard", - "custom_course_list", - """ - { - op: PLUGIN_OPERATIONS.Insert, - type: DIRECT_PLUGIN, - priority: 50, - RenderWidget: CourseList - }""" - ), -]) -``` - -### Performance Considerations - -**Bundle Size**: -- Frontend plugins are included in MFE bundles -- Minimize dependencies and use tree shaking -- Consider lazy loading for large plugins - -**API Performance**: -- Implement proper caching strategies -- Use pagination for large datasets -- Optimize backend API response times - -**User Experience**: -- Show loading states during API calls -- Handle errors gracefully -- Provide offline fallback behavior - -### Browser Compatibility - -- Follow MFE browser support requirements -- Test across different browsers -- Use polyfills if needed for newer JS features - -## Customizing This Example - -### For Different Slots - -1. **Identify Target Slot**: Check [available slots](https://docs.openedx.org/en/latest/site_ops/references/frontend-plugin-slots.html) -2. **Study Slot Props**: Examine slot documentation for available data -3. **Adapt Component**: Modify component to work with slot-specific data -4. **Update Configuration**: Change slot name in plugin configuration - -**Example - Adapting for Header Slot**: - -```jsx -// Original CourseList component -const CourseList = ({ courseListData }) => { /* ... */ }; - -// Adapted for header slot -const CustomHeader = ({ logo, mainMenu, userMenu }) => { - // Use header-specific props - return ( -
- {/* Your customizations */} -
- ); -}; -``` - -### Adding New Features - -**Common Extension Patterns**: - -```jsx -// Add new state -const [newFeatureData, setNewFeatureData] = useState([]); - -// Add new API calls -useEffect(() => { - const fetchNewFeatureData = async () => { - // Your API integration - }; -}, []); - -// Add new UI elements -return ( - - {/* Existing course list */} - {/* Your new feature */} - - -); -``` - -### Component Composition - -**Reusable Components**: -```jsx -// Create reusable sub-components -const ArchiveButton = ({ courseId, isArchived, onToggle }) => ( - -); - -// Use in main component -const CourseList = ({ courseListData }) => ( -
- {courses.map(course => ( - - {/* Course info */} - - - ))} -
-); -``` - -## Troubleshooting - -### Common Issues - -**Plugin Not Loading**: -- Check `env.config.jsx` slot name matches target slot -- Verify plugin is installed (`npm list @openedx/plugin-sample`) -- Ensure MFE supports the plugin framework version -- Check browser console for JavaScript errors - -**Slot Data Issues**: -- Console.log slot props to understand data structure -- Check if slot provides expected data (some slots may not provide certain props) -- Verify slot exists in the MFE version you're using - -**API Integration Problems**: -- Verify backend plugin is installed and running -- Check API URLs match backend configuration -- Ensure CORS settings allow frontend-backend communication -- Test API endpoints directly in browser/Postman - -**Styling Issues**: -- Use Paragon components for consistent styling -- Check CSS specificity conflicts -- Verify theme variables are available -- Test across different screen sizes - -**Development Setup Issues**: -- Ensure `module.config.js` path is correct -- Check that both `env.config.jsx` and `module.config.js` are in MFE root -- Verify file permissions and syntax - -### Debugging Techniques - -**Console Debugging**: -```jsx -// Add debug logging -console.log("DEBUG: CourseList props:", { courseListData }); -console.log("DEBUG: API response:", response.data); -console.log("DEBUG: Archive states:", Array.from(archivedCourses)); -``` - -**React Developer Tools**: -- Use React DevTools to inspect component state -- Check component hierarchy and props -- Monitor state changes during interactions - -**Network Debugging**: -- Use browser DevTools Network tab -- Check API request/response details -- Verify authentication headers are present - -### Getting Help - -1. **Documentation**: Start with [official frontend plugin documentation](https://docs.openedx.org/en/latest/site_ops/how-tos/use-frontend-plugin-slots.html) -2. **MFE-Specific Help**: Check individual MFE repositories for slot documentation -3. **Community**: [Open edX Slack #frontend-platform channel](https://openedx.org/slack) -4. **Issues**: Report bugs in relevant MFE repositories or this sample repository - -This frontend plugin demonstrates the power and flexibility of the Open edX Frontend Plugin Framework. By following these patterns, you can create rich customizations that integrate seamlessly with the Open edX ecosystem. diff --git a/tutor-contrib-sample/README.md b/tutor-contrib-sample/README.md index a4acfa0..258de4e 100644 --- a/tutor-contrib-sample/README.md +++ b/tutor-contrib-sample/README.md @@ -1,453 +1,29 @@ -# Tutor Plugin Configuration Guide +# tutor-contrib-sample -This directory contains Tutor plugin configuration for easy deployment of both backend and frontend plugins in a Tutor-based Open edX deployment. +A Tutor plugin that installs and wires up the three other sub-packages in this repo ([`backend-plugin-sample`](../backend-plugin-sample/), [`frontend-plugin-sample`](../frontend-plugin-sample/), and [`brand-sample`](../brand-sample/)) into a Tutor-based Open edX deployment. Enabling this plugin is the simplest way to see all three working together. -## Table of Contents +## How to use it -- [Overview](#overview) -- [Plugin Configuration](#plugin-configuration) -- [Installation Steps](#installation-steps) -- [Development vs Production](#development-vs-production) -- [Configuration Options](#configuration-options) -- [Troubleshooting](#troubleshooting) -- [Advanced Configuration](#advanced-configuration) - -## Overview - -This Tutor plugin simplifies the deployment of the sample plugin by: - -- **Backend Integration**: Automatically installs the Django app plugin -- **Frontend Integration**: Configures MFE slots for the custom components -- **Environment Setup**: Handles configuration across different deployment environments -- **Dependency Management**: Ensures all required packages are installed - -**What is Tutor?**: Tutor is the official Docker-based deployment method for Open edX, providing simple commands for installation, configuration, and maintenance. - -**Official Documentation:** -- [Tutor Documentation](https://docs.tutor.edly.io/) -- [Tutor Plugin Development](https://docs.tutor.edly.io/plugins/index.html) - -## Plugin Configuration - -**File**: [`sample.py`](./sample.py) - -### Current Configuration - -The current configuration demonstrates basic Tutor plugin structure: - -```python -from tutormfe.hooks import PLUGIN_SLOTS - -PLUGIN_SLOTS.add_items([ - # Replace the course_list - ( - "learner-dashboard", - "custom_course_list", - """ - { - op: PLUGIN_OPERATIONS.Insert, - type: DIRECT_PLUGIN, - priority: 50, - RenderWidget: CourseList - }""" - ), -]) -``` - -**Note**: The current implementation is a basic template. For full functionality, this needs to be expanded with proper backend installation and frontend package management. - -### Complete Plugin Structure - -A fully functional Tutor plugin should include: - -```python -from tutor import hooks - -# Plugin metadata -__version__ = "1.0.0" - -# Backend plugin installation -@hooks.Filters.IMAGES_BUILD_MOUNTS.add() -def _mount_openedx_plugin_sample(mounts): - """Mount the sample plugin source code for development.""" - mounts.append(("sample-plugin-backend", "/openedx/sample-plugin-backend")) - return mounts - -@hooks.Filters.LMS_ENV.add() -@hooks.Filters.CMS_ENV.add() -def _add_plugin_settings(env): - """Add plugin-specific environment variables.""" - env["SAMPLE_PLUGIN_ENABLED"] = True - return env - -# Install backend plugin -@hooks.Filters.IMAGES_BUILD.add() -def _install_backend_plugin(build_config): - """Install the backend plugin during image build.""" - build_config.add_dockerfile_commands( - "RUN pip install -e /openedx/sample-plugin-backend" - ) - return build_config - -# Frontend plugin configuration -from tutormfe.hooks import PLUGIN_SLOTS - -PLUGIN_SLOTS.add_items([ - ( - "learner-dashboard", - "course_list_slot", - """ - { - op: PLUGIN_OPERATIONS.Replace, - widget: { - id: 'custom_course_list', - type: DIRECT_PLUGIN, - priority: 50, - RenderWidget: CourseList - } - }""" - ), -]) -``` - -## Installation Steps - -### Prerequisites - -1. **Tutor Installation**: Follow [Tutor installation guide](https://docs.tutor.edly.io/install.html) -2. **Plugin Source**: Have the sample plugin source code available -3. **Tutor MFE Plugin**: Install tutor-mfe plugin if customizing frontend - -### Step 1: Install Tutor Plugin - -```bash -# Method 1: Install from local directory -pip install -e /path/to/sample-plugin/tutor-contrib-sample/ - -# Method 2: Copy plugin file (simpler for development) -mkdir -p "$(tutor plugins printroot)" -cp sample.py "$(tutor plugins printroot)/sample.py" -``` - -### Step 2: Enable Plugin +See the root [README](../README.md) for the full setup. The minimum: ```bash -# Enable the plugin +pip install -e ./tutor-contrib-sample tutor plugins enable sample - -# Verify plugin is enabled -tutor plugins list -``` - -### Step 3: Deploy Backend Plugin - -```bash -# For development deployment tutor dev launch - -# For production deployment -tutor local launch ``` -### Step 4: Configure Frontend (if using MFE customization) - -```bash -# If using tutor-mfe plugin for frontend customization -tutor plugins enable mfe -tutor local launch -``` +[`tutor-mfe`](https://github.com/overhangio/tutor-mfe) is required for the frontend slot configuration to apply; the plugin degrades gracefully (backend + brand only) if it isn't installed. -### Step 5: Verify Installation +## How it works -```bash -# Check backend plugin -tutor dev exec lms python manage.py shell -c "from openedx_plugin_sample.models import CourseArchiveStatus; print('Backend plugin loaded')" - -# Check frontend plugin (visit learner dashboard in browser) -# Should see custom course list with archive functionality -``` +The plugin is a single file: [`tutorsample/plugin.py`](./tutorsample/plugin.py). Each section below is independent of the others. -## Development vs Production +**Backend.** Installs the published `openedx-plugin-sample` package from PyPI into the LMS and CMS images via the `openedx-dockerfile-post-python-requirements` patch. Also registers `backend-plugin-sample` as a `MOUNTED_DIRECTORIES` entry, so that running `tutor mounts add "$PWD/backend-plugin-sample"` bind-mounts your local checkout and pip-installs that instead. -### Development Mode - -**Characteristics:** -- Uses `tutor dev` commands -- Mounts source code for live editing -- Faster iteration cycles -- Debug logging enabled - -**Setup Pattern:** -```bash -# Mount backend plugin source -tutor dev mount /path/to/sample-plugin/backend:/openedx/sample-plugin-backend - -# Start development environment -tutor dev launch - -# Install plugin in development mode -tutor dev exec lms pip install -e ../sample-plugin-backend -tutor dev exec lms python manage.py migrate -tutor dev restart lms -``` - -### Production Mode - -**Characteristics:** -- Uses `tutor local` commands -- Builds plugins into Docker images -- Optimized for performance -- Production logging levels - -**Setup Pattern:** -```bash -# Enable plugin -tutor plugins enable sample - -# Build and deploy -tutor local launch -``` +**Migrations.** Adds `manage.py lms migrate openedx_plugin_sample` (and the CMS equivalent) to the `tutor … do init` task list. -### Key Differences +**Frontend.** (Only when `tutor-mfe` is installed.) Installs the published `@openedx/plugin-sample` npm package into every MFE image, injects an `import { CourseList } from '@openedx/plugin-sample'` into the generated `env.config.jsx`, and wires `CourseList` into the `org.openedx.frontend.learner_dashboard.course_list.v1` slot (hiding the default contents). -| Aspect | Development | Production | -|--------|-------------|------------| -| **Installation** | `pip install -e` (editable) | Built into image | -| **Code Changes** | Live reload | Requires rebuild | -| **Performance** | Slower (debug mode) | Optimized | -| **Database** | SQLite/development DB | Production database | -| **Logging** | Verbose | Production level | - -## Configuration Options - -### Backend Plugin Configuration - -```python -# In tutor plugin -@hooks.Filters.LMS_ENV.add() -def _add_backend_settings(env): - """Configure backend plugin settings.""" - env.update({ - # Plugin-specific settings - "SAMPLE_PLUGIN_API_RATE_LIMIT": "100/minute", - "SAMPLE_PLUGIN_ARCHIVE_RETENTION": "365", - - # Open edX Filters configuration - "OPEN_EDX_FILTERS_CONFIG": { - "org.openedx.learning.course.about.render.started.v1": { - "pipeline": [ - "openedx_plugin_sample.pipeline.ChangeCourseAboutPageUrl" - ], - "fail_silently": False, - } - } - }) - return env -``` - -### Frontend Plugin Configuration - -```python -# Configure MFE slots -PLUGIN_SLOTS.add_items([ - ( - "learner-dashboard", # Target MFE - "course_list_slot", # Slot identifier - """ - { - op: PLUGIN_OPERATIONS.Replace, // Operation type - widget: { - id: 'custom_course_list', // Unique widget ID - type: DIRECT_PLUGIN, // Plugin type - priority: 50, // Load priority - RenderWidget: CourseList // Component reference - } - }""" - ), -]) -``` - -### Environment-Specific Configuration - -```python -# Different settings for different environments -@hooks.Filters.LMS_ENV.add() -def _configure_by_environment(env): - """Apply environment-specific configuration.""" - if env.get("TUTOR_DEV", False): - # Development settings - env["SAMPLE_PLUGIN_DEBUG"] = True - env["SAMPLE_PLUGIN_API_RATE_LIMIT"] = "1000/minute" - else: - # Production settings - env["SAMPLE_PLUGIN_DEBUG"] = False - env["SAMPLE_PLUGIN_API_RATE_LIMIT"] = "60/minute" - - return env -``` - -## Troubleshooting - -### Common Issues - -**Plugin Not Loading:** -```bash -# Check if plugin is enabled -tutor plugins list - -# Check plugin syntax -python -m py_compile sample.py - -# Verify plugin location -tutor plugins printroot -ls -la "$(tutor plugins printroot)/" -``` - -**Backend Plugin Not Installing:** -```bash -# Check build logs -tutor images build lms - -# Manual installation for debugging -tutor dev exec lms pip install -e ../sample-plugin-backend -tutor dev exec lms python -c "import openedx_plugin_sample; print('Success')" - -# Check migrations -tutor dev exec lms python manage.py showmigrations openedx_plugin_sample -``` - -**Frontend Plugin Not Appearing:** -```bash -# Check MFE configuration -tutor dev exec learner-dashboard env | grep PLUGIN - -# Verify plugin slots -tutor dev logs learner-dashboard - -# Check browser console for JavaScript errors -``` - -**Settings Not Applied:** -```bash -# Check environment variables -tutor dev exec lms env | grep SAMPLE_PLUGIN - -# Verify Django settings -tutor dev exec lms python manage.py shell -c "from django.conf import settings; print(getattr(settings, 'SAMPLE_PLUGIN_DEBUG', 'Not set'))" -``` - -### Debug Commands - -```bash -# View plugin configuration -tutor plugins show sample - -# Check generated configuration -tutor config printvalue PLUGINS - -# Inspect environment variables -tutor dev exec lms env | grep -E "(SAMPLE_PLUGIN|OPEN_EDX)" - -# Check plugin installation -tutor dev exec lms pip list | grep sample - -# View logs -tutor dev logs lms -tutor dev logs learner-dashboard -``` - -### Getting Help - -1. **Tutor Documentation**: [Plugin Development Guide](https://docs.tutor.edly.io/plugins/intro.html) -2. **Community**: [Tutor Community Forum](https://discuss.openedx.org/c/ops-and-deployment/tutor/) -3. **GitHub**: [Tutor Repository Issues](https://github.com/overhangio/tutor/issues) - -## Advanced Configuration - -### Multi-MFE Plugin Configuration - -```python -# Configure multiple MFEs -PLUGIN_SLOTS.add_items([ - # Learner Dashboard - ( - "learner-dashboard", - "course_list_slot", - """{ /* CourseList configuration */ }""" - ), - - # Course Authoring (if applicable) - ( - "course-authoring", - "course_outline_slot", - """{ /* Course outline customization */ }""" - ), -]) -``` - -### Custom Image Building - -```python -@hooks.Filters.IMAGES_BUILD.add() -def _build_custom_image(build_config): - """Build custom image with additional dependencies.""" - - # Add system packages - build_config.add_dockerfile_commands( - "RUN apt-get update && apt-get install -y your-package" - ) - - # Install Python packages - build_config.add_dockerfile_commands( - "RUN pip install your-python-package" - ) - - # Copy additional files - build_config.add_dockerfile_commands( - "COPY custom-config.yml /openedx/config/" - ) - - return build_config -``` - -### Database Migrations - -```python -@hooks.Actions.LMS_READY.add() -@hooks.Actions.CMS_READY.add() -def _run_plugin_migrations(): - """Run plugin migrations when platform is ready.""" - from django.core.management import call_command - call_command("migrate", "openedx_plugin_sample") -``` - -### Plugin Dependencies - -```python -# In setup.py or pyproject.toml for your Tutor plugin -dependencies = [ - "tutor>=15.0.0", - "tutor-mfe", # If using MFE customization -] -``` - -### Environment Validation - -```python -@hooks.Filters.CONFIG_UNIQUE.add() -def _validate_plugin_config(config): - """Validate plugin configuration.""" - - # Check required settings - if not config.get("SAMPLE_PLUGIN_API_KEY"): - raise ValueError("SAMPLE_PLUGIN_API_KEY is required") - - # Validate setting values - rate_limit = config.get("SAMPLE_PLUGIN_API_RATE_LIMIT", "60/minute") - if not re.match(r"^\d+/(minute|hour|day)$", rate_limit): - raise ValueError(f"Invalid rate limit format: {rate_limit}") - - return config -``` +**Brand.** Sets `MFE_CONFIG["PARAGON_THEME_URLS"]` to load Paragon's default light theme overlaid with the compiled `brand-sample/dist/light.min.css` served from jsDelivr. -This Tutor plugin configuration provides a foundation for deploying the sample plugin in production Open edX environments. The modular approach allows you to adapt the configuration for different deployment scenarios while maintaining consistency across environments. \ No newline at end of file +> TODO: the brand override currently assumes `brand-sample` has been pushed to jsDelivr from `main`. For a source-hacking workflow, this should be swapped for the [tutor-contrib-paragon](https://github.com/openedx/openedx-tutor-plugins/tree/main/plugins/tutor-contrib-paragon) flow described in [`brand-sample/README.md`](../brand-sample/README.md). From b51b811dc4d9c70c3f0f54cb35d804c48bc86155 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Mon, 18 May 2026 10:14:27 -0600 Subject: [PATCH 2/2] docs: style/grammar pass Co-Authored-By: Claude Opus 4.7 (1M Context) --- README.md | 2 +- backend-plugin-sample/README.md | 2 +- brand-sample/README.md | 4 ++-- frontend-plugin-sample/README.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 942c97f..36929ec 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ To edit code in this repo and have your changes apply inside Tutor: ## Development without Tutor -Assumes you already have edx-platform running locally (bare-metal or devstack-style venv) and at least one MFE checked out. +This path assumes you already have edx-platform running locally (bare-metal or devstack-style venv) and at least one MFE checked out. - **Backend** — install editable into the edx-platform Python environment and migrate: diff --git a/backend-plugin-sample/README.md b/backend-plugin-sample/README.md index 30843e6..b8eaed9 100644 --- a/backend-plugin-sample/README.md +++ b/backend-plugin-sample/README.md @@ -16,7 +16,7 @@ See the root [README](../README.md) for setup instructions. With Tutor, [`tutor- **Model.** [`models.py`](./src/openedx_plugin_sample/models.py) defines `CourseArchiveStatus(user, course_id, is_archived, archive_date)`, indexed for the lookups the API performs. Registered in Django admin via [`admin.py`](./src/openedx_plugin_sample/admin.py). -**REST API.** [`views.py`](./src/openedx_plugin_sample/views.py) exposes the model as a DRF `ModelViewSet` at `/sample-plugin/api/v1/course-archive-status/`, with per-user permissions, throttling, and pagination. Serializer in [`serializers.py`](./src/openedx_plugin_sample/serializers.py); URLs in [`urls.py`](./src/openedx_plugin_sample/urls.py). Business logic (e.g. setting `archive_date` when `is_archived` flips true) lives in `perform_create`/`perform_update` rather than in the serializer. +**REST API.** [`views.py`](./src/openedx_plugin_sample/views.py) exposes the model as a DRF `ModelViewSet` at `/sample-plugin/api/v1/course-archive-status/`, with per-user permissions, throttling, and pagination. Serializer in [`serializers.py`](./src/openedx_plugin_sample/serializers.py); URLs in [`urls.py`](./src/openedx_plugin_sample/urls.py). Business logic (e.g. setting `archive_date` when `is_archived` becomes true) lives in `perform_create`/`perform_update` rather than in the serializer. **Event handler.** [`signals.py`](./src/openedx_plugin_sample/signals.py) listens for `COURSE_ENROLLMENT_CHANGED` and unarchives a learner's course when they upgrade to the verified track. An event (not a filter) is the right shape here because we want a one-time nudge at the moment of upgrade — if the learner re-archives the course later, we respect that. A filter would re-impose the rule on every render. diff --git a/brand-sample/README.md b/brand-sample/README.md index 1f3559b..81ba44a 100644 --- a/brand-sample/README.md +++ b/brand-sample/README.md @@ -79,8 +79,8 @@ tutor_root="$(tutor config printroot)" && rm -rf "$tutor_root/env/plugins/paragon/theme-sources/themes" \ && cp -r brand-sample/tokens/src/themes "$tutor_root/env/plugins/paragon/theme-sources" \ && tutor dev do paragon-build-tokens \ - && echo 'Your design tokens are ready to be built :)' \ - || echo 'Could not copy design token sources into tutor environment :(' + && echo 'Your design tokens are built :)' \ + || echo 'Something went wrong while copying or building your design tokens :(' ``` If the build fails, check that `"$(tutor config printroot)/env/plugins/paragon"` looks like: diff --git a/frontend-plugin-sample/README.md b/frontend-plugin-sample/README.md index a5b2d5b..b7d6d64 100644 --- a/frontend-plugin-sample/README.md +++ b/frontend-plugin-sample/README.md @@ -65,7 +65,7 @@ Then, from the MFE checkout: ```bash npm ci -# With Tutor — redirect tutor-mfe at your local MFE devserver: +# With Tutor — point tutor-mfe at your local MFE devserver: tutor mounts add . tutor dev reboot -d mfe npm run dev