Files
shopdb-flask/docs/PLUGIN-HOOKS.md
cproudlock d85b33bd68
All checks were successful
CI / backend (push) Successful in 1m36s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Build GE-Enforce manifest-store plugin (P0/P1): model, importer, parity gate
First execution phases of docs/proposals/ge-enforce-plugin.md. The GE-Enforce
manifest becomes shopdb data.

P0 scaffold: new geenforce plugin (api_prefix /api/geenforce, default_enabled
false, core_version >=0.7.0). Registered in PLUGIN_TABLE_OWNERS (ADR-008); its
0001 baseline really creates the tables.

P1a model: one wide manifestentries table + entrytype discriminator (not STI,
not JSON blob), manifestscopes (UNIQUE scopename+phase), the three multi-value
filter child tables, inusechecks + processes, immutable manifestpublishedversions
(frozen rendered JSON), manifestpayloads (inline, capped), pctypealiases
(mirror of the engine lib's alias graph). regvalue stored as its raw JSON
literal so DWord typing survives.

P1c importer + exporter: parse common + gea-shopfloor-* + preinstall.json into
draft rows and rebuild the JSON verbatim from rows in sortorder.

P1d parity harness (GATE A): filters.py mirrors the engine's four filter
functions + alias graph; parity.py proves import+export is behaviorally lossless
(field-identical + same-entries-fire across 18 machine-profile fixtures) WITHOUT
byte-diffing. Verified PASS against all 11 real reference manifests (64 entries)
and a synthetic site-neutral fixture covering every type/filter (the CI gate).

First slice (gea-shopfloor-cmm shape): service layer (import/publish/rollback/
export-to-share), CLI (parity, import-share, publish, export-share), and the
client endpoint GET /api/geenforce/manifest serving the current published
snapshot (never the draft) with ETag/304. Split permissions
geenforce.manage/publish/fetch. Tests prove import->publish->serve, draft edits
never change served bytes, publish+rollback, and auth (401 unauth/wrong-scope).

Contract 0.11.0: added service_token_authorized(scope) to shopdb.api so plugin
service endpoints authorize a scoped managed token without importing core token
internals. Documented in PLUGIN-HOOKS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:53:18 -04:00

21 KiB

Plugin Hooks Reference

This is the canonical reference for the shopdb-flask plugin contract. Plugin authors implement BasePlugin and override the hooks they care about. Hooks marked required must be implemented; hooks marked optional have sensible defaults and can be left alone.

The contract is locked in ADR-001 and versioned per ADR-002.

Contract version

The framework declares its contract version in shopdb/__init__.py:

__contract_version__ = '0.11.0'

Each plugin's manifest.json declares the range of contract versions it supports:

{
    "name": "yourplugin",
    "version": "1.0.0",
    "core_version": ">=0.1.0,<1.0.0",
    "dependencies": []
}

The plugin loader checks this at load time and refuses to load plugins outside the supported range.

Plugin metadata

Each plugin ships a manifest.json. The dataclass PluginMeta is constructed from it.

{
    "name": "computers",
    "version": "1.0.0",
    "description": "Tracks shop-floor PCs and engineering workstations",
    "author": "shopdb-flask",
    "dependencies": [],
    "core_version": ">=0.1.0,<1.0.0",
    "api_prefix": "/api/computers"
}
Field Required Notes
name Yes Lowercase concatenated, no underscores or dashes
version Yes Plugin's own semver
description Yes One sentence
dependencies No List of plugin names that must load first
core_version Yes Range of framework __contract_version__ this plugin supports
api_prefix No Defaults to /api/<name>

Required hooks

meta -> PluginMeta

Returns the plugin's metadata. Convention is to construct from manifest.json:

from pathlib import Path
import json
from shopdb.plugins.base import BasePlugin, PluginMeta

class ComputersPlugin(BasePlugin):
    def __init__(self):
        manifestpath = Path(__file__).parent / 'manifest.json'
        with open(manifestpath) as f:
            self._manifest = json.load(f)

    @property
    def meta(self) -> PluginMeta:
        return PluginMeta(
            name=self._manifest['name'],
            version=self._manifest['version'],
            description=self._manifest['description'],
            dependencies=self._manifest.get('dependencies', []),
            core_version=self._manifest.get('core_version', '>=0.1.0'),
            api_prefix=self._manifest.get('api_prefix'),
        )

get_blueprint() -> Optional[Blueprint]

Returns a Flask Blueprint with the plugin's API routes, or None if the plugin has no HTTP routes. The loader registers the blueprint at the api_prefix from the manifest.

from flask import Blueprint
from .api import computers_bp

class ComputersPlugin(BasePlugin):
    def get_blueprint(self):
        return computers_bp

get_models() -> List[Type]

Returns the SQLAlchemy model classes the plugin defines. Used by the migration runner and admin tooling.

from .models import Computer, ComputerSoftware

class ComputersPlugin(BasePlugin):
    def get_models(self):
        return [Computer, ComputerSoftware]

Optional hooks

init_app(app, db) -> None

Custom initialization. Called by the loader after the blueprint is registered and models are known. Use for Marshmallow schema registration, Caching configuration, secondary blueprint registration, or anything else the plugin needs.

class PrintersPlugin(BasePlugin):
    def init_app(self, app, db):
        from .api import printers_legacy_bp
        app.register_blueprint(printers_legacy_bp, url_prefix='/api/printers/legacy')

get_cli_commands() -> List

Returns a list of Click commands or command groups to register on the Flask CLI.

import click

@click.group()
def computers_cli():
    pass

@computers_cli.command()
def reset_computers():
    """Reset all computer status flags."""
    ...

class ComputersPlugin(BasePlugin):
    def get_cli_commands(self):
        return [computers_cli]

get_services() -> Dict[str, Type]

Returns a dict of service-name to service-class. Another plugin obtains one via plugin_manager.get_service('<name>'), which searches enabled plugins and returns the registered class/factory (or None).

from .services import ZabbixService

class PrintersPlugin(BasePlugin):
    def get_services(self):
        return {'zabbix': ZabbixService}

get_dashboard_widgets() -> List[Dict]

Returns dashboard widget definitions for the home page.

class NotificationsPlugin(BasePlugin):
    def get_dashboard_widgets(self):
        return [{
            'name': 'recent_notifications',
            'component': 'NotificationsWidget',
            'endpoint': '/api/notifications/recent',
            'size': 'medium',
            'position': 1,
        }]

Consumed by GET /api/dashboard/widgets, which merges widgets from all enabled plugins sorted by position (disabled plugins are skipped; a broken plugin is isolated in prod, re-raised in dev/test).

get_navigation_items() -> List[Dict]

Returns navigation menu items.

class ComputersPlugin(BasePlugin):
    def get_navigation_items(self):
        return [{
            'name': 'Computers',
            'icon': 'desktop',
            'route': '/computers',
            'position': 10,
        }]

Removed in contract 0.4.0: get_searchable_fields. Global search (/api/search) is a core concern that queries the asset model directly and already covers every bundled asset type; no plugin ever implemented the hook. Search honors runtime plugin enable/disable.

get_reports() -> List[Dict]

Returns report card definitions for the Reports hub. Added in contract 0.6.0.

Each entry has id, name, description, category, plus EXACTLY ONE of route (a frontend path for a dedicated report page) or endpoint (an API endpoint the hub renders inline).

class WarrantyPlugin(BasePlugin):
    def get_reports(self):
        return [{
            'id': 'warranty',
            'name': 'Warranty Report',
            'description': 'Assets bucketed by coverage: expired, expiring soon, active',
            'category': 'warranty',
            'route': '/reports/warranty',
        }]

Consumed by GET /api/reports, which merges plugin cards after the static core reports sorted into category groups by the frontend (disabled plugins are skipped; a broken plugin is isolated in prod, re-raised in dev/test).

get_permissions() -> List

Returns the RBAC permissions this plugin owns. Added in contract 0.10.0. A plugin declares the permission names its own routes enforce via require_permission, instead of core accumulating every plugin's permissions in one catalog (plugin-is-the-product).

Each entry is a (name, description, category) tuple, matching the core permission catalog shape (dicts with those keys are also accepted). Names follow the naming convention (lowercase dotted, e.g. machines.edit).

class MachinesPlugin(BasePlugin):
    def get_permissions(self):
        return [
            ('machines.view', 'View machines', 'machines'),
            ('machines.create', 'Create machines', 'machines'),
            ('machines.edit', 'Edit machines', 'machines'),
            ('machines.delete', 'Delete machines', 'machines'),
        ]

Consumed by the core helper full_permission_catalog() (core permissions plus every ENABLED plugin's get_permissions()), which backs three consumers:

  • flask seed permissions seeds the full catalog.
  • The role-management grid (GET /api/users/permissions) lists it, grouped by category.
  • API-token scope validation (ApiToken.unknown_scope_names) accepts a plugin permission as a scope only while that plugin is enabled.

Plugin install and enable also seed the plugin's own permissions idempotently, so enabling a fresh plugin creates its Permission rows without a separate seed pass.

Disabled-plugin edge case: a disabled plugin is skipped by the catalog, so its permissions are no longer offered for new scope grants or new role assignments. The Permission ROWS already in the database are NOT deleted, so roles that already reference them keep working until an admin edits the role. A broken plugin is isolated in prod and re-raised in dev/test.

get_settings_cards() -> List[Dict]

Returns settings-catalog card definitions. Added in contract 0.7.0 (ADR-010). Each card is merged into the settings rail and landing overview without the plugin hand-editing the core settingsNav.js catalog. icon is a string key mapped to a Lucide component core-side, exactly like get_navigation_items.

class MeasuringToolsPlugin(BasePlugin):
    def get_settings_cards(self):
        return [{
            'group': 'Measuring Tools',        # rail group title (created if new)
            'to': '/settings/measuringtooltypes',
            'icon': 'ruler',                   # string key, mapped core-side
            'title': 'Measuring Tool Types',
            'description': 'Manage measuring-tool subtypes + map colors',
            'position': 22,                    # order within the group
        }]

Consumed by GET /api/pluginui/settings-cards, which merges enabled plugins' cards into the core catalog (disabled plugins are skipped; a broken plugin is isolated in prod, re-raised in dev/test).

get_asset_panels() -> List[Dict]

Returns asset-detail extension-panel definitions. Added in contract 0.7.0 (ADR-010). A generic core AssetPanel component renders each panel on the matching detail pages, fetching the panel's endpoint. This replaces hand-composing a plugin panel component into each detail view.

class WarrantyPlugin(BasePlugin):
    def get_asset_panels(self):
        return [{
            'id': 'warranty',
            'title': 'Warranty',
            'assettypes': ['*'],               # detail pages it appears on; ['*'] = all
            'endpoint': '/api/warranty/asset/{assetid}',
            'render': 'table',                 # 'keyvalue' | 'table' | 'badge'
            'position': 30,
        }]

Consumed by GET /api/pluginui/asset-panels?assetid=<id>, which returns the panels whose assettypes match that asset's type (disabled plugins skipped; broken plugin isolated in prod, re-raised in dev/test). A panel that needs bespoke UI (a chart) is out of scope for this data-only hook.

get_map_overlays() -> List[Dict]

Returns shop-floor map overlay/decoration definitions. Added in contract 0.7.0 (ADR-010). The map stays data-driven off asset types + positions; an overlay adds decoration data (a badge or ring) plus an optional legend entry, with no plugin-side map code.

class MeasuringToolsPlugin(BasePlugin):
    def get_map_overlays(self):
        return [{
            'id': 'calibration-due',
            'label': 'Calibration due',        # legend label
            'endpoint': '/api/measuringtools/map-overlay',  # -> [{assetid, color, label}]
            'style': 'badge',                  # 'badge' | 'ring'
            'legend': True,
        }]

Consumed by GET /api/pluginui/map-overlays (disabled plugins skipped; broken plugin isolated in prod, re-raised in dev/test).

get_asset_presentation() -> List[Dict]

Returns asset-type presentation/routing definitions. Added in contract 0.7.0 (ADR-010). Declares how a plugin-owned asset type renders in global-search rows and cross-links (which icon, which detail route), so core never hardcodes a plugin's route or icon.

class MeasuringToolsPlugin(BasePlugin):
    def get_asset_presentation(self):
        return [{
            'assettype': 'measuring_tool',     # AssetType.assettype key the plugin owns
            'icon': 'ruler',
            'label': 'Measuring Tool',
            'route': '/measuringtools/{assetid}',
        }]

Consumed by GET /api/pluginui/asset-presentation (disabled plugins skipped; broken plugin isolated in prod, re-raised in dev/test).

get_provisioning_note() -> Optional[Dict]

Transparency note the setup wizard shows the moment a site checks this plugin during setup. Return None (the default) for plugins that need no special setup. Plugins that create extra tables beyond their asset-extension table (e.g. a self-hosted directory) return:

class EmployeesPlugin(BasePlugin):
    def get_provisioning_note(self):
        return {
            'tables': ['directoryemployees'],
            'note': 'Creates a local employee directory table in the shopdb database.',
            'docs': 'plugins/employees/README.md',
        }

get_config_schema() -> List[Dict]

Declares the config fields this plugin needs, so the setup wizard can prompt for them. Return [] (the default) if the plugin needs no configuration. Each field is a dict:

Key Meaning
key the Setting key (non-secret) it maps to
label human label shown in the wizard
type 'text' / 'number' / 'password'
secret True for credentials; NOT stored in the DB - the wizard emits an .env line for the operator instead
envvar (secret only) the .env variable name to emit
default optional placeholder
help optional hint
class PrintersPlugin(BasePlugin):
    def get_config_schema(self):
        return [
            {'key': 'zabbix_url', 'label': 'Zabbix URL', 'type': 'text',
             'help': 'Base URL of the Zabbix server for supply lookups'},
            {'key': 'zabbix_token', 'label': 'Zabbix API token', 'type': 'password',
             'secret': True, 'envvar': 'ZABBIX_TOKEN'},
        ]

get_collector_schema() -> Optional[Dict]

Declares the JSON Schema for an external collector pushing to /api/collector/<pluginname>. See ADR-006 for the contract.

class ComputersPlugin(BasePlugin):
    def get_collector_schema(self):
        return {
            'identityfield': 'hostname',
            'fields': {
                'hostname': {'type': 'string', 'required': True},
                'macaddress': {'type': 'string'},
                'osname': {'type': 'string'},
                'osversion': {'type': 'string'},
                'currentuser': {'type': 'string'},
                'ipaddress': {'type': 'string'},
            }
        }

If the hook returns None (the default), no collector endpoint is registered.

apply_collector_payload(payload: Dict) -> Dict

Companion to get_collector_schema (ADR-006). The generic /api/collector/<pluginname> endpoint calls this after the payload passes identity validation, to idempotently upsert an asset. Return a dict with at least action (created | updated | noop), assetid, and warnings (list).

This is a CONDITIONAL hook: it is only required when get_collector_schema returns non-None. The BasePlugin default raises NotImplementedError (the dispatcher turns that into a 500), so a plugin that declares a schema but forgets the upsert fails loud. Plugins with no collector schema never need it. The test_schema_declaring_plugins_implement_apply contract test enforces the pairing.

def apply_collector_payload(self, payload):
    host = payload['hostname']
    comp = Computer.query.filter(Computer.hostname.ilike(host)).first()
    action = 'updated' if comp else 'created'
    # ... create-or-update Asset + extension ...
    db.session.commit()
    return {'action': action, 'assetid': comp.assetid, 'warnings': []}

Lifecycle hooks

These run when the plugin's installation state changes. All optional.

Hook When Use case
on_install(app) First time the plugin is installed via flask plugin install Seed reference data, run plugin-specific migrations, register webhooks
on_uninstall(app) When the plugin is removed via flask plugin uninstall Clean up reference data, deregister webhooks
on_enable(app) When the plugin is enabled at runtime Subscribe to events, warm caches
on_disable(app) When the plugin is disabled at runtime Unsubscribe, drain queues

The import surface (shopdb.api)

shopdb.api is the ONLY core module a plugin may import from (besides shopdb.plugins.base for BasePlugin / PluginMeta). Importing internal paths like shopdb.core.models.*, shopdb.extensions, or shopdb.utils.* is a contract violation and fails the test tests/test_plugin_contract.py::test_plugins_only_import_contract_surface.

What shopdb.api exposes:

  • Infrastructure: db, cache
  • Model bases: BaseModel, AuditMixin
  • Core models: Asset, AssetType, AssetStatus, Vendor, Model, Communication, CommunicationType, Location, Setting, AuditLog, Application, AppVersion, OperatingSystem
  • Responses: success_response, error_response, paginated_response, ErrorCodes
  • Pagination: get_pagination_params, paginate_query
  • Authorization: require_permission, require_role, service_token_authorized (service_token_authorized(scope) returns True when the request carries a managed service token scoped for scope whose owner holds that permission - for unattended plugin endpoints like the GE-Enforce fetch API)
  • Helpers: audit_log, resolve_asset_position, resolve_dualpath_pairs, dualpath_single_machine_enabled
  • Import mode: apply_import_timestamps, import_mode_active, parse_import_datetime
  • Legacy employee directory: employee_connection
from shopdb.api import db, Asset, AssetType, success_response, paginate_query

Adding a name to shopdb.api is an additive (minor) contract bump; removing one is breaking (major). See ADR-002.

Helpers exposed to plugins

The framework provides helper APIs in shopdb.api (the public namespace).

Audit logging

from shopdb.api import audit_log

audit_log(
    action='created',
    entitytype='Computer',
    entityid=computer.assetid,
    entityname=computer.hostname,
    changes={'before': {}, 'after': computer.to_dict()},
)

Plugin-scoped settings

class PrintersPlugin(BasePlugin):
    def init_app(self, app, db):
        zabbix_url = self.get_setting('zabbix_url')
        if not zabbix_url:
            self.set_setting('zabbix_url', 'http://zabbix.example.com')

Settings persist to the core Setting model and survive restarts.

Position resolution

from shopdb.api import resolve_asset_position

position = resolve_asset_position(asset)
# Returns dict: {'mapx': 234, 'mapy': 567, 'positionsource': 'self' | 'related' | 'location' | None}

See ADR-001 for the position resolution algorithm.

Dualpath single-machine collapse

A Dualpath relationship pair is one physical dual-bay machine recorded as two asset rows. When the site setting dualpath_single_machine is on (default), the machines list, dashboard/report counts, and the floor map show the pair as a single machine (the SECONDARY bay is hidden); the data model always keeps both rows and detail pages stay per-bay.

from shopdb.api import resolve_dualpath_pairs, dualpath_single_machine_enabled

collapse = resolve_dualpath_pairs()
# collapse.secondaryassetids: set of the non-primary bay asset ids to hide
# collapse.partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for every
#                          pair member (primary and secondary), for banners

if dualpath_single_machine_enabled():
    # exclude the hidden bays and annotate the visible (primary) bay
    ...

PRIMARY is the pair member with the lower natural-sort assetnumber. resolve_dualpath_pairs ignores the toggle (so a detail-page sibling banner can show always); gate the collapse itself on dualpath_single_machine_enabled().

Import mode (legacy timestamp passthrough)

Bulk imports from the classic ASP shopdb need to preserve each row's original createddate / modifieddate instead of stamping "now". apply_import_timestamps does this, gated so it never affects normal traffic: it only acts when the caller is an admin AND sent the X-Import-Mode: true request header.

from shopdb.api import apply_import_timestamps

asset = Asset(assetnumber=data['assetnumber'], ...)
db.session.add(asset)
# In import mode, stamp legacy createddate/modifieddate from the payload.
# No-op for normal callers, or when the payload omits the fields.
apply_import_timestamps(asset, data)
db.session.commit()

import_mode_active() returns the same admin-plus-header predicate, for guarding other backdated behavior (for example accepting a historical checkouttime). parse_import_datetime(value) parses both ISO 2020-01-05T12:00:00 and legacy YYYY-MM-DD HH:MM:SS into naive UTC. See docs/IMPORT-API.md for the full migration operator manual.

Removed hooks

The following hooks existed in early drafts and have been removed for v1:

Hook Reason
get_event_handlers Event bus deferred indefinitely. No real use case yet. Add via new ADR if needed.

Versioning your changes

When you change anything documented here, you must:

  1. Bump __contract_version__ per ADR-002: major for removals or signature changes, minor for additive optional hooks, patch for docs.
  2. Update ADR-001 if the contract surface itself changed (or supersede with a new ADR).
  3. Add or update the test in tests/test_plugin_contract.py that asserts the new behavior.

The skill defining-asset-contract walks through the full checklist.