Architectural pass from the skill review ("plugin is the product" boundary).
Core no longer imports plugin models at module load (was a hard import-time
dependency that broke core if the computers plugin was absent/disabled):
- collector.py, applications.py, reports.py: lazy + guarded imports of the
computers plugin models. Endpoints that need install-tracking now return 503
when the plugin is absent instead of failing at import.
Search honors runtime enable/disable:
- search.py: _require_enabled(name) raises ImportError for a disabled plugin,
so each plugin-scoped block skips it (a disabled plugin's rows leave search).
- Replace hardcoded root/rootpassword employee-DB connection in _search_employees
with the shared env-backed employee_connection helper.
Plugin hooks (integrating-plugin-hooks: every hook needs a consumer):
- get_dashboard_widgets: add the consumer GET /api/dashboard/widgets (5 plugins
already implemented the hook; it had none). Skips disabled, isolates in prod.
- get_searchable_fields: REMOVED. Zero plugins implemented it and there was no
consumer; global search is a core concern over the asset model. Contract
reduction, __contract_version__ 0.3.0 -> 0.4.0.
Docs/contract: PLUGIN-HOOKS.md (widgets consumer note, searchable-fields removal,
0.4.0), PLUGIN-QUICKSTART.md, ADR-001 hook list. Tests: widgets endpoint
aggregate + disabled-skip; contract tests for the removed/added hooks.
151 tests pass, naming/style green, app boots all 6 plugins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 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.4.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,
}]
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.
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_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.
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.
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
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
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:
- Bump
__contract_version__per ADR-002: major for removals or signature changes, minor for additive optional hooks, patch for docs. - Update ADR-001 if the contract surface itself changed (or supersede with a new ADR).
- Add or update the test in
tests/test_plugin_contract.pythat asserts the new behavior.
The skill defining-asset-contract walks through the full checklist.