Goal: an LLM or script can migrate an entire legacy database using only the HTTP API - original history preserved, safely re-runnable. - X-Import-Mode header (admin only): create/update endpoints across 15 timestamped entity types accept original createddate/modifieddate; helper exposed via shopdb.api (contract 0.7.0 -> 0.8.0). - Exact-match natural-key lookup filters on 13 list endpoints for the lookup-then-upsert recipe. - Selfhosted USB checkout/checkin accept backdated event times in import mode. - docs/IMPORT-API.md: operator manual grounded in the real legacy schema - order of operations, full table-by-table mapping including the machines fan-out, idempotent Python importer with dry-run, parity checks, and decided dispositions for unmigrated tables (DNC config stays live-fed via the collector; supportteams/appowners map to the upcoming supportteams model). 635 tests pass; naming green; frontend untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
526 lines
18 KiB
Markdown
526 lines
18 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.8.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_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`.
|
|
|
|
```python
|
|
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.
|
|
|
|
```python
|
|
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.
|
|
|
|
```python
|
|
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.
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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 |
|
|
|
|
```python
|
|
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](../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`
|
|
- Authorization: `require_permission`, `require_role`
|
|
- Helpers: `audit_log`, `resolve_asset_position`
|
|
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
|
`parse_import_datetime`
|
|
- 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.
|
|
|
|
### 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.
|
|
|
|
```python
|
|
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](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](../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.
|