Files
shopdb-flask/docs/PLUGIN-HOOKS.md
cproudlock 5fefb53bca Phase 2: define plugin contract surface and add compliance tests
Locks the public surface plugin authors at sister sites depend on.

Contract version (shopdb/__init__.py):
- __contract_version__ = '0.1.0'. Per ADR-002, plugins declare a
  compatible range in manifest.json `core_version`. Pre-1.0 signals
  the contract is still settling; sister sites should pin tightly.

BasePlugin hook changes (shopdb/plugins/base.py):
- Add get_collector_schema() per ADR-006. Returns JSON Schema (with
  identityfield + fields) describing the payload of an external
  collector pushing to /api/collector/<pluginname>. Defaults to None
  (no auto-registered endpoint).
- Remove get_event_handlers(). Event bus deferred indefinitely per
  ADR-001 (no real use case yet; add via new ADR if it appears).

Hook reference (docs/PLUGIN-HOOKS.md):
- Canonical reference for the contract: required hooks (meta,
  get_blueprint, get_models), optional hooks (init_app,
  get_cli_commands, get_services, get_dashboard_widgets,
  get_navigation_items, get_searchable_fields, get_collector_schema),
  lifecycle hooks (on_install, on_uninstall, on_enable, on_disable),
  helpers exposed in shopdb.api (audit_log, Setting,
  resolve_asset_position).
- Versioning rules + change-classification guidance.

Compliance tests (tests/test_plugin_contract.py):
- 8 distinct contract assertions parametrized over 6 bundled plugins
  (computers, equipment, network, notifications, printers, usb).
- Asserts: subclasses BasePlugin; manifest has required fields; meta
  returns valid PluginMeta; get_blueprint returns Blueprint or None;
  get_models returns model classes; get_collector_schema returns
  None or {identityfield, fields}; get_navigation_items and
  get_searchable_fields return list.
- Plus 3 framework-level: __contract_version__ is valid semver,
  get_event_handlers absent, get_collector_schema present.

Test count: 15 -> 66 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:48:45 -04:00

8.8 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.1.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. Other plugins can request services via the plugin manager.

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,
        }]

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,
        }]

get_searchable_fields() -> List[Dict]

Declares fields the plugin contributes to global search.

from .models import Computer

class ComputersPlugin(BasePlugin):
    def get_searchable_fields(self):
        return [{
            'model': Computer,
            'search_fields': ['hostname', 'serialnumber', 'currentuser'],
            'result_type': 'computer',
            'url_template': '/computers/{id}',
            'title_field': 'hostname',
            'subtitle_field': 'currentuser',
        }]

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.

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

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.

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.