Plugin System¶
Simple Aircraft Manager supports out-of-tree plugins — standalone Django apps that extend the UI, API, and dashboard without modifying core source files.
How Plugins Work¶
Plugins are ordinary Django AppConfig subclasses that inherit from SAMPluginConfig (in core/plugins.py). SAM discovers them at startup, adds them to INSTALLED_APPS, and wires up their extension-point declarations automatically.
A plugin can:
Add navigation links and management views to the global navbar
Add new tabs (primary or sub-tabs) to the aircraft detail page
Register new API endpoints via the DRF router
Contribute Alpine.js mixins to the aircraft detail page
Add dashboard tiles (per-aircraft or global)
Serve its own Django URL patterns
Plugin Discovery¶
SAM finds plugins via two mechanisms, checked every time settings are loaded:
Mechanism |
Env var |
Description |
|---|---|---|
Directory scan |
|
Scans this directory for packages (subdirs with |
Explicit list |
|
Comma-separated Python module names to add directly to |
Both mechanisms can be used simultaneously. Duplicate entries are ignored.
In a container environment (recommended), set SAM_PLUGIN_PACKAGES to install plugin packages from PyPI (or a private registry) at startup before migrations and collectstatic run:
SAM_PLUGIN_PACKAGES=my-sam-plugin==1.2.0,another-plugin>=0.5
See configuration.md for all plugin-related environment variables.
Writing a Plugin¶
1. Create the Django app¶
my_plugin/
├── __init__.py
├── apps.py # SAMPluginConfig subclass
├── models.py # optional
├── urls.py # page URL patterns (optional)
├── api_urls.py # DRF router registrations (optional)
├── templates/
│ └── my_plugin/
│ └── includes/
│ └── detail_engine_monitor.html
└── static/
└── js/
└── my-plugin-mixin.js
2. Declare the AppConfig¶
# my_plugin/apps.py
from core.plugins import SAMPluginConfig
class MyPluginConfig(SAMPluginConfig):
name = 'my_plugin'
verbose_name = 'My Plugin'
default_auto_field = 'django.db.models.BigAutoField'
# Extension points (all optional):
nav_items = [
{
'label': 'Engine Monitor',
'url': '/engine-monitor/',
'icon': 'fas fa-tachometer-alt',
# 'staff_only': True, # hide from non-staff users
},
]
aircraft_tabs = [
{
'key': 'engine-monitor', # unique tab key / activeTab value
'label': 'Engine Monitor',
'primary_group': 'engine-monitor', # same as key = standalone tab
'template': 'my_plugin/includes/detail_engine_monitor.html',
# 'visibility': 'featureFlightTracking', # Alpine.js expression
},
]
aircraft_js_files = ['js/my-plugin-mixin.js']
aircraft_features = [
{
# Prefix slugs with your plugin name to avoid collisions with
# other plugins. Convention: <plugin_name>_<feature>.
'name': 'engine_monitor_alerts', # slug used in DB, API, and JS
'label': 'Engine Monitor Alerts', # shown in the Settings tab
'description': 'EGT/CHT threshold alerts and notifications',
},
]
global_dashboard_tiles = [
{'template': 'my_plugin/includes/dashboard_fleet_summary.html'},
]
Set default_app_config = 'my_plugin.apps.MyPluginConfig' in my_plugin/__init__.py, or use the AppConfig auto-discovery mechanism (Django 3.2+).
3. Extension points reference¶
management_views¶
Adds items under the staff “Manage” dropdown in the navbar.
Key |
Type |
Required |
Description |
|---|---|---|---|
|
str |
Yes |
Display text |
|
str |
Yes |
Absolute URL path |
aircraft_tabs¶
Adds tabs to the aircraft detail page. Two modes:
Standalone tab — set
primary_groupequal tokey. The tab appears as a new top-level tab alongside built-in tabs.Sub-tab — set
primary_groupto an existing built-in group key (e.g.'consumables','compliance','logbook','records'). The tab appears as a sub-tab inside that group via the{% plugin_sub_tab_buttons %}/{% plugin_sub_tab_panels %}template tags.
Key |
Type |
Required |
Description |
|---|---|---|---|
|
str |
Yes |
Unique identifier; used as the Alpine.js |
|
str |
Yes |
Tab display label |
|
str |
Yes |
|
|
str |
Yes |
Django template path for the tab content |
|
str |
No |
Alpine.js expression for |
aircraft_features¶
Per-aircraft feature flags contributed by the plugin. Each entry is a dict with:
Key |
Type |
Required |
Description |
|---|---|---|---|
|
str |
Yes |
Unique slug. Must be globally unique across builtins and all plugins. Used in the DB, API, and JS ( |
|
str |
Yes |
Human-readable name shown in the Settings tab toggle list. |
|
str |
Yes |
One-line description shown below the label in the Settings tab. |
Namespace your slugs. Feature names share a single global namespace across all installed plugins and built-in features. A generic name like
limitsoralertswill collide silently with another plugin that chose the same name, causing one plugin to control the other’s toggle. Always prefix with your plugin identifier:engine_monitor_limits,engine_monitor_alerts, etc. The convention is<plugin_name>_<feature>using the same snake_case name as your Django app.
Registered features behave identically to built-in features:
All features default to enabled. Owners can toggle them on the aircraft Settings tab.
Admins can globally disable a feature via the
DISABLED_FEATURESenvironment variable.Use
feature_available('engine_monitor_alerts', aircraft)in Python for server-side checks.Access the boolean state in Alpine.js via
this.features['engine_monitor_alerts'](returnsundefined— truthy — until features are loaded, so!== falseis the safe guard).Plugin tab
visibilityexpressions can reference feature state:'features["engine_monitor_alerts"] !== false'.
aircraft_js_files¶
List of static file paths (relative to STATIC_ROOT) loaded on the aircraft detail page before aircraft-detail.js. Use this to register Alpine.js mixins and tab mappings.
aircraft_dashboard_tiles¶
Per-aircraft tiles rendered on each aircraft card on the dashboard.
Key |
Type |
Required |
Description |
|---|---|---|---|
|
str |
Yes |
Django template path |
global_dashboard_tiles¶
Sections rendered at the bottom of the fleet dashboard (not per-aircraft).
Key |
Type |
Required |
Description |
|---|---|---|---|
|
str |
Yes |
Django template path |
url_prefix¶
If set, SAM includes the plugin’s urls.urlpatterns at /<url_prefix>/.
api_url_prefix¶
If set, SAM registers ROUTER_REGISTRATIONS from api_urls.py in the DRF router. Alternatively, place ROUTER_REGISTRATIONS in urls.py (same format as core/urls.py).
Frontend Integration (Alpine.js)¶
Registering a mixin¶
Plugin JS files are loaded before aircraft-detail.js. Push mixin factory functions onto window.SAMPluginMixins:
// my_plugin/static/js/my-plugin-mixin.js
window.SAMPluginMixins = window.SAMPluginMixins || [];
window.SAMPluginMixins.push(function myPluginMixin() {
return {
// reactive data
engineData: [],
// lifecycle — called when the detail page initialises
async initMyPlugin() {
const { ok, data } = await apiRequest(`/api/aircraft/${this.aircraftId}/engine-data/`);
if (ok) this.engineData = data;
},
// getters, methods, etc.
get hasEngineData() {
return this.engineData.length > 0;
},
};
});
The composer (aircraft-detail.js) merges all plugin mixins via mergeMixins() before the built-in mixins. Plugin state, getters, and methods are available on this inside any other mixin.
Registering tab mappings¶
All plugin tabs — both standalone and sub-tabs — must register in window.SAMPluginTabMappings. This allows the aircraft detail page to resolve activeTab back to the correct primary group, and enables hash-based deep-linking (e.g. /aircraft/42/#my-tab-key).
Standalone tab (primary_group equals key): map the key to itself.
window.SAMPluginTabMappings = window.SAMPluginTabMappings || {};
window.SAMPluginTabMappings['engine-monitor'] = 'engine-monitor';
Sub-tab inside an existing primary group: map the key to the group name.
window.SAMPluginTabMappings = window.SAMPluginTabMappings || {};
window.SAMPluginTabMappings['my-sub-tab-key'] = 'consumables';
Without this registration, the tab will render correctly when clicked, but hash-based navigation (e.g. links that include #tab-key in the URL) will silently fail.
Accessing core state¶
All core reactive properties are available on this inside plugin mixins:
Property |
Type |
Description |
|---|---|---|
|
str |
Aircraft UUID |
|
str |
Currently active tab key |
|
str |
|
|
bool |
User is owner or admin |
|
bool |
User is pilot or above |
|
bool |
User can write (owner-level actions) |
|
bool |
Viewed via share link (no auth) |
|
object |
Dict of |
|
array |
Ordered list of |
|
bool |
Flight Tracking feature enabled |
|
bool |
Oil Consumption feature enabled |
|
bool |
Fuel Consumption feature enabled |
|
bool |
Oil Analysis feature enabled |
|
bool |
Public Sharing feature enabled |
|
bool |
Airworthiness Enforcement enabled |
For plugin-defined features, read the boolean from this.features:
// In a plugin mixin or x-show expression:
get engineMonitorEnabled() {
return this.features['engine_monitor_alerts'] !== false;
},
Or in a template aircraft_tabs entry:
aircraft_tabs = [
{
'key': 'engine-monitor',
'label': 'Engine Monitor',
'primary_group': 'engine-monitor',
'template': 'my_plugin/includes/detail_engine_monitor.html',
'visibility': 'features["engine_monitor_alerts"] !== false',
},
]
Use these to conditionally show/hide content via x-show in your templates.
Template Integration¶
Accessing the plugin registry in templates¶
The plugin_registry context variable is available in all templates (injected by core/context_processors.py):
{% for item in plugin_registry.nav_items %}
<a href="{{ item.url }}">{{ item.label }}</a>
{% endfor %}
Adding API Endpoints¶
Define ROUTER_REGISTRATIONS in api_urls.py (or urls.py):
# my_plugin/api_urls.py
from rest_framework.routers import DefaultRouter
from .views import EngineDataViewSet
ROUTER_REGISTRATIONS = [
('engine-data', EngineDataViewSet, {'basename': 'engine-data'}),
]
This registers the viewset at /api/engine-data/. Use AircraftScopedMixin and EventLoggingMixin from core/mixins.py for consistent RBAC and event logging:
from core.mixins import AircraftScopedMixin, EventLoggingMixin
from rest_framework import viewsets
from .models import EngineReading
from .serializers import EngineReadingSerializer
class EngineDataViewSet(AircraftScopedMixin, EventLoggingMixin, viewsets.ModelViewSet):
serializer_class = EngineReadingSerializer
aircraft_fk_path = 'aircraft'
event_category = 'engine'
def get_queryset(self):
return EngineReading.objects.filter(
aircraft__in=self.get_accessible_aircraft()
)
Serializer
urlfield gotcha. When usingHyperlinkedModelSerializer, DRF derives theurlfield’s view name from the model name (e.g.enginereading-detailforEngineReading). This will not match the view name the router actually registered, which is derived from your basename (e.g.engine-data-detail). Attempting to create or update a record will raiseNoReverseMatch. Fix: declareextra_kwargsin the serializer’sMeta:class Meta: model = EngineReading fields = ['url', 'id', ...] extra_kwargs = { 'url': {'view_name': 'engine-data-detail'}, }Always include
idexplicitly too — see the main CLAUDE.md gotcha #2.
Packaging as a Python Package¶
A plugin can be packaged as a standard Python distribution (pyproject.toml / setup.cfg). Set the default_app_config (or use entry-point auto-discovery). Distribute via PyPI or a private index.
Install at container startup via SAM_PLUGIN_PACKAGES:
SAM_PLUGIN_PACKAGES=my-sam-plugin==1.2.0
SAM_PLUGINS=my_plugin # module name inside the package
The entrypoint script pip-installs the packages, runs collectstatic to pick up static assets, and then starts the server.
Plugin Checklist¶
[ ] Subclass
SAMPluginConfig(notAppConfig)[ ] Set
name,verbose_name,default_auto_field[ ] Declare extension points as class attributes (only what you need)
[ ]
aircraft_featuresentries have unique slugs and includename,label, anddescription[ ] JS mixin pushed to
window.SAMPluginMixins; all tab keys (standalone and sub-tab) registered inwindow.SAMPluginTabMappings[ ] Static files in
<app>/static/; template files in<app>/templates/<app>/[ ] API viewsets use
AircraftScopedMixin+EventLoggingMixin[ ] Models use UUID primary keys
[ ] Migrations included in the package
[ ]
collectstaticruns at startup (handled by entrypoint whenSAM_PLUGIN_PACKAGESorSAM_PLUGIN_DIRis set)