Skip to main content

Plugin system

A plugin adds features to CMDX without changing the code in core/ or modules/. It is built from the same parts as a module: routes, tables, settings, capabilities and hook handlers. It is packaged so that an administrator can install, enable, upgrade and remove it on a running deployment. This page explains where plugins come from, the states a plugin moves through, what it can add, and how it is kept apart from the rest of CMDX.

Plugins and modules​

ModulePlugin
Lives inbackend/modules/backend/plugins/, an external folder, or a Python package
InstalledAlways, as part of CMDXBy an administrator, one plugin at a time
Can be switched offNoYes, for the whole installation or per tenant
Imports fromAnything in core/core.plugin_api only

The built-in learning item types, such as quiz and SCORM, are modules, not plugins. The repository includes two plugins, announcements and gamification, and example code under backend/plugins/examples/.

Where plugins come from​

When the backend starts, it looks for plugins in three places:

  1. The backend/plugins/ folder in the repository.
  2. Each absolute path in the EXTERNAL_PLUGINS_DIRS environment variable, separated by colons.
  3. Installed Python packages that declare an entry point in the cmdx.plugins group.

The code has to be there before the process starts: baked into the image, mounted as a volume, or installed with pip. Every plugin found is recorded in the plugin registry, a table in the database.

The manifest​

Each plugin has a manifest.py that says what it is:

  • name: matches the plugin's folder name
  • type: a category such as auth, report, integration or local
  • version and db_version: the code version, and the schema version its upgrade steps reach
  • dependencies: other parts it needs, written as core.<name>, module:<name> or plugin:<name>, each with a version range
  • personal_data: each of its tables that holds rows about a user, with the column that names the user (default user_id). A user's data export includes these rows and erasure deletes them. An entry can keep rows through erasure, giving the reason in keep, or leave them out of the export with export=False, for example for a per-user token.
  • capabilities: the permissions it adds, named <plugin-name>.<action>. While the plugin is installed, they join the capability catalog.
  • settings namespace: where its settings live
  • scope: global (on or off for the whole installation) or per_tenant (on or off for each tenant). The default is per_tenant.

Lifecycle​

A plugin moves through four states, which the registry records:

StateWhat it meansHow it gets there
DiscoveredThe code is present and the manifest is valid.Found at start-up.
InstalledThe plugin's tables exist.An administrator installs it, which runs its install steps. Later upgrades run its upgrade steps up to db_version.
EnabledThe plugin is active.An administrator enables it: once for a global plugin, or for each tenant for a per_tenant plugin. Only an installed plugin can be enabled; enabling never installs it.
DisabledThe plugin is inactive.An administrator disables it. A plugin that another enabled plugin depends on cannot be disabled.

Enabling changes the state in the database. The running backend loads the plugin the next time it starts, or straight away when an administrator calls the plugin's reload endpoint.

Managing plugins needs these capabilities:

CapabilityAllows
plugin:readListing plugins and reading their settings
plugin:tenant_toggleEnabling and disabling a per_tenant plugin for the caller's own tenant
plugin:manageInstalling, upgrading, uninstalling and reloading plugins, changing their settings, enabling or disabling a global plugin, and enabling a per_tenant plugin for another tenant

On a development machine, the python -m scripts.plugin command does the same from the backend folder: list, validate, install, enable, disable, uninstall and reload.

What a plugin adds​

When the backend loads an enabled plugin, it:

  1. Registers the plugin's tables. A plugin describes them once as portable table definitions, named plg_<plugin-name>_*, and reads and writes them through the DB facade.
  2. Registers its settings schema, so administrators can configure it.
  3. Registers its hook handlers, so it can react to events in CMDX.
  4. Mounts its API routes under /api/v1/plugins/<plugin-name>/.

Plugins load in dependency order, so a plugin loads after the plugins it depends on.

Per-tenant plugins​

A per_tenant plugin runs only in tenants where it is enabled. Its routes refuse requests from any other tenant, and its hook handlers skip events from any other tenant.

An event's tenant is the tenant_id or organization_id in its payload, or else the tenant of the request that raised it. An event with no tenant, such as user.registered from self-registration, never reaches a per_tenant plugin. If your plugin must see those events, give it the global scope.

If CMDX can't check whether the plugin is enabled, a handler with failure_mode="continue" is skipped. A handler with failure_mode="abort" raises an error instead, as the handler itself would have.

How plugins are kept apart​

  • One public API. Plugin code imports only from core.plugin_api. That package is kept compatible across minor CMDX releases; anything else in core/ or modules/ can change without notice. If a plugin needs something core.plugin_api doesn't offer, that is a request for the public API to grow.
  • Own tables. A plugin's tables carry its name as a prefix and are created and upgraded by its own steps, so they never collide with CMDX tables or another plugin's.
  • Own capabilities. A plugin's capabilities are named after the plugin, so they never collide with another plugin's.
  • Failures stay contained. A plugin that fails to load is logged and skipped, and the backend and the other plugins keep running.
  • Checked before install. python -m scripts.plugin validate checks that the folder name matches the manifest, that db_version matches the highest upgrade step, that capabilities and settings use the plugin's name, that dependencies are well formed, and that every table with a user column is declared in personal_data and exists with that column.

This keeps plugins apart by contract, not by sandbox. A plugin runs inside the backend process with the same database connection as CMDX, so install only plugins you trust.

Next​