Files
shopdb-flask/docs/PLUGIN-HOOKS.md
cproudlock f663cc5bbe Enforce plugin contract purity: single import surface via shopdb.api
Plugins were reaching into internal core paths (shopdb.core.models.*,
shopdb.extensions, shopdb.utils.*), coupling them to core's file layout and
violating the ADR-001 contract. Consolidate onto one versioned surface.

- shopdb.api: expand from 2 helpers to the full plugin import surface -
  db, cache; BaseModel, AuditMixin; core models (Asset, AssetType,
  AssetStatus, Vendor, Model, Communication, CommunicationType, Location,
  Setting, AuditLog, Application, AppVersion, OperatingSystem); response +
  pagination helpers; employee_connection. Documented in PLUGIN-HOOKS.md.
- Migrate all 22 plugin source files to import only from shopdb.api (plus
  shopdb.plugins.base for the ABC).
- Drop the printers plugin's legacy MachineType dependency: remove
  _ensure_legacy_machine_types and the seed_supplies machinetypeid lookup
  (Model.machinetypeid is nullable; printers carry type via PrinterType).
- Guard test test_plugins_only_import_contract_surface scans plugin source
  and fails on any core import outside shopdb.api / shopdb.plugins.base.
- Scaffold templates updated so generated plugins are contract-pure.
- Bump __contract_version__ 0.2.0 -> 0.3.0 (additive surface expansion;
  manifests pin <1.0.0 so they still satisfy).

145 tests pass, naming/style green, app factory boots all 6 plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:45:06 -04:00

322 lines
9.8 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.2.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. Other plugins can request services via the plugin manager.
```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,
}]
```
### `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,
}]
```
### `get_searchable_fields() -> List[Dict]`
Declares fields the plugin contributes to global search.
```python
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](../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.
## 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.