Files
shopdb-flask/docs/PLUGIN-HOOKS.md
cproudlock 22e623c1f6 Plugin framework maturation, reports overhaul, theming, and USB frontend repair
Framework:
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin
  carries its own chain with a stamp-only anchor at the ownership cutover;
  new plugin schema lands in plugins/<name>/migrations/, never the core
  chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the
  shared alembic template (engine URL resolution) and taught the metadata
  filter to include FK-referenced core tables.
- Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin;
  a disabled plugin's pages redirect to the dashboard via a cached,
  fail-open check against the new public GET /api/plugins/enabled.
- get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute
  report cards; warranty and toner cards moved off the hardcoded list.

Reports:
- Hub grouped by category with search; inline reports render at the top,
  are URL-backed (?report=id, back-button and deep links work), expose
  their server-side filter params as controls, and export CSV. Warranty
  and Toner pages gained CSV export.
- Deleted the dead legacy Warranty Status report (always-zero buckets
  from a retired column).

Theming and fonts:
- Inter (variable) bundled locally via @fontsource, replacing the Google
  Fonts Roboto import - air-gapped installs now render correctly; tables
  use tabular numerals.
- Optional brand_primary_dark_color, brand_accent_color,
  brand_sidebar_color settings applied to CSS vars at bootstrap.

USB frontend repair (views were reading a dead legacy shape):
- List/detail/form and the employee profile USB panels remapped to the
  real API shape (device_id/device_desc/checkinoutlog); employee panels
  now use /usb/checkouts endpoints; external-mode /usb/checkouts/active
  honors the badge filter; dead client methods pruned.

Also: warranties list page no longer requires login (matches app
convention); collector doc rewritten with a GE-Enforce integration guide
and paste-ready PowerShell reporter; ADR index and CHANGELOG updated.

Verified: 323 tests pass, naming/style green, frontend builds, plugin
migration dry-run green on scratch MySQL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:01:47 -04:00

363 lines
12 KiB
Markdown

# 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](../docs/adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](../docs/adr/ADR-002-plugin-versioning.md).
## Contract version
The framework declares its contract version in `shopdb/__init__.py`:
```python
__contract_version__ = '0.6.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
```json
{
"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.
```json
{
"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`:
```python
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.
```python
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.
```python
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.
```python
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.
```python
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).
```python
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.
```python
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.
```python
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).
```python
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_collector_schema() -> Optional[Dict]`
Declares the JSON Schema for an external collector pushing to `/api/collector/<pluginname>`. See [ADR-006](../docs/adr/ADR-006-collector-contract.md) for the contract.
```python
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.
```python
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`
- Helpers: `audit_log`, `resolve_asset_position`
- Legacy employee directory: `employee_connection`
```python
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
```python
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
```python
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
```python
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](../docs/adr/ADR-001-asset-as-platform-contract.md) 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](../docs/adr/ADR-002-plugin-versioning.md): major for removals or signature changes, minor for additive optional hooks, patch for docs.
2. Update [ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md) 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.