An Open edX Django plugin that gives corporate partners their own course catalog, learner invitation flow, and enrollment licensing — all running inside a standard Tutor-based Open edX installation without forking the LMS.
Table of Contents
Corporate partners need more than the standard Open edX enrollment model. They need to control which learners can access which courses, track who was invited and when, enforce seat limits, and see functional analytics across the full learner journey.
This plugin adds that layer on top of the LMS without modifying the LMS itself. It
integrates via the Open edX plugin architecture (lms.djangoapp entry point) and
keeps all partner-specific data in its own models.
The domain is built around five core concepts:
- Partner
- A corporate organization that has contracted access to a set of courses. Backed by
an
organizations.Organizationrecord already present in the LMS. - PartnerCatalog
- A time-bounded collection of courses belonging to a Partner. Controls who can access
it (via email regex patterns or explicit invitation), how many learners can join
(
user_limit), and how many paid course seats are available (course_enrollments_limit). A catalog is active only between itsavailable_start_dateandavailable_end_date. - Learner invitation flow
Access to a catalog is granted through an invitation:
- A
CatalogLearnerInvitationis created (status:SENT). - The learner accepts → status becomes
ACCEPTED. - A
CatalogLearnerrecord is created/activated, linking the user to the catalog. - The learner can now enroll in courses inside that catalog.
Invitations can also be declined or removed. Status is derived from timestamps, not stored as a free-form string, so it is always consistent.
- A
- CatalogCourse
- The join table between a
PartnerCatalogand a course (CourseOverviewin LMS terms). Carries apositionfield for ordering within the catalog. - CatalogCourseEnrollment
- A license that says "this user has been granted paid access to this course by this catalog." One record per user+course across the entire platform — the catalog that first granted the license owns it. See Enrollment License Model below for the reasoning behind this design.
When a learner requests access to a catalog course the enrollment service:
- Verifies the user is an active catalog learner and the catalog is active.
- Checks the current LMS enrollment mode for the user/course pair.
- For paid courses:
- If the user already has a paid LMS enrollment (e.g.
verified) → grant access, no changes needed. - If the catalog has remaining bag capacity → create or reactivate a
CatalogCourseEnrollmentand upgrade/create the LMS enrollment toverified. - If the user is already in
audit/honorand the bag is full → return a warning (access is kept at the open mode, no exception raised). - If there is no LMS enrollment and the bag is full → raise
CourseLimitReached.
- If the user already has a paid LMS enrollment (e.g.
- For open courses (audit/honor only) → enroll in
audit, no license record created.
Deactivation works symmetrically: the license is set to inactive and the LMS enrollment
is downgraded to audit.
All invitation and enrollment lifecycle events are emitted as Open edX tracking events and transformed to xAPI statements for the Aspects analytics pipeline. See docs/decisions/0001-aspects-in-openedx-corporate.rst for the full analytics ADR.
CatalogCourseEnrollment represents a license, not an enrollment. The LMS already
has its own enrollment record (CourseEnrollment). The catalog license tracks which
catalog granted paid access and whether that access is currently active.
The uniqueness constraint is on (user, course_overview) — not on
(user, catalog_course) — because a user can only hold one license per course
regardless of how many catalogs contain that course. The first catalog to grant access
becomes the owner. See docs/decisions/0002-enrollment-license-model.rst for details.
A catalog's course_enrollments_limit field sets the maximum number of active paid
licenses owned by that catalog. Zero means unlimited. The quota is checked before
creating or reactivating a license. It counts distinct active paid-course enrollments
owned by the catalog, not total learners.
user_limit caps the number of active CatalogLearner records. Zero means
unlimited. Checked before accepting an invitation.
When PartnerCatalog.is_self_enrollment is True, any authenticated user can
access the catalog without an invitation. Email regex patterns and learner limits still
apply.
- Open edX Sumac (Tutor 19) or later
- Python 3.11
- Django 4.2 or 5.2
git clone <repository-url>
cd openedx-corporate
pip install -e ".[dev]"
python manage.py migrate --settings=test_settings# Unit + integration tests (SQLite, no LMS needed)
pytest --ds=test_settings tests/ -v
# Full pipeline (requires Tutor)
bash scripts/run_tests.sh
# Quality checks
tox -e qualitypartner_catalog/
├── models.py # All domain models
├── exceptions.py # Domain exceptions (CourseLimitReached, etc.)
├── policies/
│ ├── catalogs.py # Access control (can_access_catalog, email regex)
│ ├── enrollments.py # Enrollment eligibility checks
│ └── limits.py # Quota checks (user_limit, course_enrollments_limit)
├── services/
│ ├── enrollments.py # CatalogCourseEnrollmentService
│ ├── invitations.py # CatalogLearnerInvitationService
│ └── platform_enrollment.py # LMS enrollment sync helpers
└── xapi/
├── constants.py # Event name constants
├── emitter.py # Tracking event emission
└── transformers.py # xAPI statement builders
tests/
├── factories.py # Test data helpers
├── test_catalog_course_enrollment_service.py # No-DB unit tests
├── test_invitation_service.py
├── test_catalog_access_policies.py
└── ... # DB-backed integration tests
AGPL 3.0. See LICENSE.txt for details.