Plugins¶
Danbyte has a first-class plugin system: a trusted, in-process extension
model. A plugin is an ordinary Python package that an
operator installs and lists in the PLUGINS setting; on the next restart it is
discovered, version-checked, and wired into RBAC, custom fields, tags,
import/export, audit, monitoring, automation, and the UI - with no core
changes and no frontend rebuild.
Trust model
Plugins run in-process with full Django access, deployment-wide. Only install plugins you trust - treat them like any dependency you add to the environment. Each tenant can then enable/disable an installed plugin independently.
Installing a plugin¶
# as the service user, in the app directory
.venv/bin/pip install danbyte-acme-plugin # or: pip install -e ./my-plugin
echo 'PLUGINS=danbyte_acme_plugin' >> .env # comma-separate multiple
Then apply it. Either from the UI - Settings → Integrations → Plugins → Apply changes (runs migrations and restarts Danbyte; superuser only) - or by hand:
Plugins live on the Integrations page, as cards beside the sync
integrations - to an operator both are the same thing, something Danbyte can do
that is off until you turn it on, so both answer "what is switched on here?" in
one place. Each card carries the plugin's load state (loaded /
incompatible / error), flags unapplied migrations, and holds the per-tenant
switch. A broken or version-incompatible plugin is reported there and skipped -
it never blocks boot.
A plugin that ships inside Danbyte (settings.BUILTIN_PLUGINS) appears the
same way with a Built-in tag: there is nothing to upload, apply or
uninstall, only the switch.
Disable ≠ uninstall
Disabling a plugin (per tenant or deployment-wide) hides its API/UI but
keeps its tables. To remove it, drop it from PLUGINS, restart, and (if you
want the data gone) handle its tables yourself.
Offline / airgapped install (upload an archive)¶
For a box that can't reach PyPI, a superuser can upload the plugin source
instead of pip install: Settings → Integrations → Plugins → Upload
plugin, and pick a .tar.gz / .tgz / .tar / .zip of the plugin
(a git archive, a GitHub source download, or an sdist all work). Danbyte
extracts the package into DANBYTE_PLUGIN_DIR (default plugins_local/, a
writable dir kept on sys.path) and records its module name in
installed.json; on the next Apply changes (migrate + restart) it loads
exactly like a PLUGINS entry. Uploaded plugins show an uploaded tag and an
Uninstall action.
Uploading a plugin runs its code
An uploaded plugin executes in-process with full access on the next restart
- it is remote code execution by design, so upload/uninstall are
superuser-only. Extraction is hardened (path-traversal blocked, size and
member caps), but only upload plugins you trust. Keep DANBYTE_PLUGIN_DIR
outside the app tree in production so upgrades don't wipe uploaded plugins.
This path installs the plugin's source (no dependency resolution) - a
plugin needing extra PyPI packages still needs those installed separately.
POST /api/plugins/upload/ (multipart archive) /
DELETE /api/plugins/<module>/uploaded/ back the UI.
Built-in plugins¶
Some optional features ship inside Danbyte as plugins (listed in
settings.BUILTIN_PLUGINS). They pass through the same loader and appear in
the same list with a built-in badge, and the per-tenant toggle works the
same way - but there is nothing to install, apply, or uninstall. A built-in
plugin that defaults to off stays off until an admin turns it on.
Anatomy of a plugin¶
Start from the template
Don't build this layout by hand - clone danbyte-net/danbyte-plugin-template, a working starter with a model, API, monitoring check, automation hook, and server-driven UI already wired. Rename the package and go.
A plugin package looks like a small Django app plus one registration module:
danbyte_acme_plugin/
├── __init__.py
├── apps.py # DanbytePluginConfig subclass (metadata)
├── danbyte_plugin.py # THE registration entry point (autodiscovered)
├── models.py # your domain models (optional)
├── migrations/
├── serializers.py # DRF serializers (optional)
├── viewsets.py # DRF viewsets (optional)
├── api_urls.py # mounted at /api/plugins/<slug>/ (optional)
└── checks.py # monitoring check kinds (optional)
apps.py - metadata¶
Subclass plugins.base.DanbytePluginConfig instead of AppConfig. The extra
attributes are read by the loader at settings-import time, so apps.py and
the package __init__ must be import-safe - no model imports at module top
level (the same rule Django already imposes on apps.py).
from plugins.base import DanbytePluginConfig
class AcmePluginConfig(DanbytePluginConfig):
name = "danbyte_acme_plugin" # the Django app path
verbose_name = "Acme Plugin"
slug = "acme" # /api/plugins/acme/ + /p/acme/… + nav
version = "1.0.0"
author = "Acme Corp"
description = "Adds Acme widgets."
min_version = "0.8.0" # Danbyte version window (inclusive)
max_version = None
default_enabled = True # active unless a tenant/deployment turns it off
danbyte_plugin.py - the registration entry point¶
plugins.apps.PluginsConfig.ready() autodiscovers this module in every
installed plugin (the same idiom as api/apps.py's autodiscover_modules). It
runs after all core apps are ready, so calling the core registration hooks is
safe. Do all your registrations here (or in modules it imports).
What a plugin can register¶
| Capability | Hook | Result |
|---|---|---|
| Domain model | auth_api.object_types.register_object_type("app.Model", "Label", "Group") |
RBAC (default-closed) + import/export + webhook/automation eventing |
| Custom fields | mix core.models.CustomFieldsMixin into the model |
auto-appears in /api/customization/meta/ |
| Tags | mix core.models.TaggableMixin into the model |
tag support, no extra call |
| Object-reference target | customization.object_registry.register_reference_model(ReferenceModel(...)) |
custom fields can point at your model |
| Audit / change log | audit.register_audited_model("app.Model") |
create/update/delete recorded |
| REST API | ship api_urls.py |
mounted at /api/plugins/<slug>/ |
| Automation runner | integrations.providers.register_automation_provider(kind, runner) |
new deploy target kind |
| Import source | integrations.providers.register_import_source(kind, handler) |
new importer |
| Notification channel | integrations.providers.register_notification_channel(kind, sender) |
new alert transport |
| Monitoring check kind | @danbyte_checks.base.register on a Checker |
validates + runs via core & Outposts |
| Secret store | monitoring.secret_store.register_secret_store(kind, label, factory, fields=…) |
selectable under Settings → Security with its own fields |
| Nav item | plugins.ui_registry.register_nav_item(NavItemSpec(...)) |
sidebar entry (RBAC-gated) |
| Page (list/detail) | plugins.ui_registry.register_page(PageSpec(...)) |
server-driven page at /p/<slug>/… |
| Dashboard panel | plugins.ui_registry.register_dashboard_panel(PanelSpec(...)) |
dashboard tile |
Models, API, RBAC¶
Registering the object type is what makes your model default-closed - every
action then demands a <model_name>.* grant. Reuse
api.viewsets.TenantScopedViewSet for a tenant-scoped, RBAC-enforced CRUD
viewset, and plugins.viewsets.PluginEnabledMixin (set plugin_slug) so the
viewset 404s when the plugin is disabled for the active tenant.
# viewsets.py
from api.viewsets import TenantScopedViewSet
from plugins.viewsets import PluginEnabledMixin
from .models import Widget
from .serializers import WidgetSerializer
class WidgetViewSet(PluginEnabledMixin, TenantScopedViewSet):
plugin_slug = "acme"
queryset = Widget.objects.all()
serializer_class = WidgetSerializer
Every routed list viewset also gets table columns for free: the list
page's Columns menu offers each field its list serializer returns (see
GET /api/list-fields/ in the API reference). Two
serializer knobs keep that honest:
@detail_only(default)fromapi.serializerson aget_*method that only the detail page needs - on a list it returnsdefaultwithout running, and the field is not offered as a column (it would read 0 on every row). Branching onview.action == "list"by hand is rejected by a test.list_columns_exclude = ("field", …)on the serializer for real fields that are render settings, flags or sets of figures rather than something to read in a table - and for a getter that answers only for a single object (isinstance(self.instance, …), kept where the serializer is also nestedmany=True), which reads 0 on a list. A test rejects such a getter that is in neither place.
A nested list of records none of which has a name (a circuit's terminations) is not offered either, and the table drops a column whose values render no text on the rows it has seen.
To add a column, add the field to the list serializer (with the joins that keep the list at a fixed number of queries) - the table picks it up.
Tenant isolation is a hard boundary
Scope every queryset to the active tenant (the base does this) and never
trust a client-supplied id to belong to the current tenant. New endpoints
are default-closed; only use AllowAny for an explicitly public flow.
Server-driven UI (no plugin JavaScript)¶
The frontend renders plugin UI from metadata - you ship no React. Register
a NavItemSpec, PageSpec (list or detail, with columns/fields/tabs),
and optional PanelSpec; the generic renderer builds the standard
DataTable / DetailShell from them, served under the reserved /p/<slug>/…
route. Nav items carry an object_type/perm so they're hidden from users who
can't view the target, exactly like core nav.
See the bundled reference plugin danbyte_example_plugin/ - it exercises every
capability above and is the recommended starting point.
How it fits together¶
- Discovery -
danbyte/plugin_loader.pyruns at settings-import time, reads each plugin's metadata, version-gates againstdanbyte.__version__(packaging), and appends the compatible ones toINSTALLED_APPS. The framework app (plugins) is appended last so itsready()autodiscovers every plugin'sdanbyte_pluginmodule after all apps have loaded. - Inventory -
GET /api/plugins/lists installed plugins, their state, and unapplied migrations.GET /api/plugins/ui/returns the nav/pages/panels for the plugins enabled in the caller's tenant. - Enablement -
plugins.PluginConfigstores per-tenant/deployment enable state;plugins.resolve.plugin_enabled()resolves the cascade (tenant → deployment default →default_enabled). See Tenant settings. - Service control - applying a plugin (migrate + restart) and restarting
services reuse the upgrade flow's detached
systemd-run --usermechanism; these actions are superuser only.