Migration runner ready and a sister site can deploy from a clean
checkout with one .env file.
ADRs relocated (migrations/adr/ -> docs/adr/):
- migrations/ is now Alembic territory, not docs.
- All cross-references updated: CLAUDE.md, docs/PLUGIN-HOOKS.md,
docs/PLUGIN-QUICKSTART.md.
Alembic initialized (migrations/):
- env.py, script.py.mako, alembic.ini copied from Flask-Migrate
templates so `flask db migrate` and `flask db upgrade` work without
a one-time `flask db init` (which would clash with the existing
migrations/ directory).
- Baseline migration generated via autogenerate, captures all 47
tables (core models + 6 plugins) as the upgrade target. Ready for
per-site `flask db upgrade` from an empty schema.
Deploy artifacts:
- Dockerfile: python:3.12-slim base, gunicorn server, non-root user,
healthcheck against /api/auth/login. Single image bundles all six
plugins; sites enable via `flask plugin install <name>`.
- docker-compose.yml: MySQL 8 + API container, healthcheck-gated
startup, env-driven secrets that fail loud on missing values
(`${SECRET_KEY:?}` form).
- .env.example: full env-var inventory with comments. Calls out
required vs optional. Matches what ProductionConfig.validate
enforces.
docs/DEPLOY.md:
- Step-by-step per-site runbook: clone, configure .env, bring up
stack, run migrations, seed reference data, install plugins,
create admin, front with TLS, backups, updates.
- Common-issues table.
- Cross-links to ADR-004 (per-site rationale), ADR-003 (plugin
distribution), and the config source.
Skills:
- migrating-asset-schema: Alembic + one-shot data migration policy.
Rules: additive first, renames are three steps, destructive ops
need rollback, equipment migration filter per ADR-001 + ADR-005.
- hardening-flask-config: production validation, CORS allowlist
policy, JWT cookie hardening, per-site deploy isolation per ADR-004.
CLAUDE.md updated to reflect the post-Phase-5 state. No tests added
this commit; the Alembic baseline is exercised by the existing
db.create_all-based test suite (tests do not touch the migration
runner; that's by design until per-plugin migrations land).
Test count unchanged: 101 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
294 lines
8.7 KiB
Markdown
294 lines
8.7 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 |
|
|
|
|
## 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.
|