Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption import, a versioned agent binary store with upload and rollback, remote-BuildKit CI, tray version reporting, and UI fixes. Applied as a patch again - the published mirror rewrites hashes, so the branches have unrelated histories and git refuses to merge them. Reviewed by three agents before merging. Four things fixed here. THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in publish-images.yml sits at column 0 instead of indented under steps:, so the whole file is invalid YAML and GitHub would refuse it. Their entire GHCR distribution channel - which Start-ShopDBDev.ps1 and the compose files consume - was broken at the source and clearly never run. Indented; all five workflows now parse. THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the approval packet from the same upload and then asked the writer whether they matched, so packet_matches_plan compared a hash against itself. The documented contract - "a human approves a specific, frozen plan, and the writer can prove nothing drifted" - did not exist in the web path: an admin could preview file A and commit file B, gated only by the needs-create group NAMES they ticked. The preview's planhash is now required at commit and checked against a freshly rebuilt plan. Their tests committed without previewing, which is what let this through; they go through the real flow now, and three new tests pin it - all confirmed to fail with the check stubbed out. endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a ZIP"; with the upload routes it also means "choose the executable every future package installs and runs as SYSTEM". Before this, staging needed filesystem access to the server. The permission description still said only "generate and download a package", so an admin granting it could not see what they were granting. Reworded. NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size and timestamp; re-uploading a version replaces the bytes in place under the same label. A sha256 of the stored file is now recorded, and the upload is audit-logged with it - after an incident there was previously no answer to who put that binary on the fleet. Also documented the nine new routes in docs/api-inventory.json (that gate cannot run on the mirror, so it falls to us at promotion time again), regenerated the spec and the project map, and kept our own docs where they conflicted: docs/ is scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file rather than our 737-line canonical one. Their journal section is appended to ours. Contract version comes across as 0.30.0 for a genuinely additive surface. Suite 2369, frontend 324, gate green. Scope: product
776 lines
31 KiB
Markdown
776 lines
31 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` as
|
|
`__contract_version__`. The current value is in
|
|
[PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code - this page
|
|
does not restate it, because a version copied into prose is stale within a
|
|
fortnight and a plugin pinned against a stale one is refused at startup.
|
|
|
|
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 card definitions for the home page.
|
|
|
|
A card declares DATA AND SHAPE, never a component name. Core owns a small set of
|
|
generic renderers and draws the card; the plugin says what to show, where it
|
|
comes from, and how to link it.
|
|
|
|
**Changed in contract 0.19.0.** The previous shape named a Vue component per
|
|
widget (`'component': 'NotificationsWidget'`). That cannot survive a lean build,
|
|
because a plugin's component may never be staged into the frontend bundle
|
|
(ADR-013), and in practice five plugins declared widgets pointing at components
|
|
nobody had written - so they rendered as nothing. A card using the old shape is
|
|
ignored. This is the same correction ADR-010 already made for asset panels.
|
|
|
|
```python
|
|
class GeEnforcePlugin(BasePlugin):
|
|
def get_dashboard_widgets(self):
|
|
return [{
|
|
'id': 'geenforce-failures', # stable, unique across plugins
|
|
'title': 'Enforcement failures',
|
|
'endpoint': '/api/geenforce/dashboard/failures',
|
|
'render': 'exceptions', # a core renderer, not a component
|
|
'severity': 'critical', # orders cards on the page
|
|
'permission': 'geenforce.manage', # hidden without it
|
|
'empty': 'hide', # say nothing when there is nothing
|
|
'position': 10,
|
|
'viewall': '/geenforce', # optional link behind the heading
|
|
'map': {'title': 'hostname', 'detail': 'entryname'},
|
|
}]
|
|
```
|
|
|
|
`empty: 'hide'` is not cosmetic. A card that reports "nothing wrong" every day
|
|
teaches people to stop reading the page.
|
|
|
|
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. A plugin owns its own sidebar entry here, so it
|
|
appears when the plugin is installed and disappears when it is not (including in
|
|
a lean per-site build that omits the plugin).
|
|
|
|
```python
|
|
class ComputersPlugin(BasePlugin):
|
|
def get_navigation_items(self):
|
|
return [{
|
|
'name': 'Computers',
|
|
'icon': 'desktop',
|
|
'route': '/computers',
|
|
'position': 10,
|
|
}]
|
|
```
|
|
|
|
**Placement.** `position` (int) sets both the sort order and which section the
|
|
item lands in - the core sidebar (`AppLayout.vue:buildNavItems`) assigns section
|
|
headers by position range:
|
|
|
|
| `position` | section |
|
|
|-----------|---------|
|
|
| `< 10` | top, above any header (Dashboard is 0, Map is 4) |
|
|
| `10-29` | **Assets** |
|
|
| `30-49` | **Information** |
|
|
| `>= 50` | trailing, below Information |
|
|
|
|
Lower number sorts higher within a section. An explicit `'section':
|
|
'information'` forces the Information group regardless of position. Only these
|
|
two named sections exist; a new section needs a core edit to `buildNavItems`.
|
|
The **Displays** group (kiosk/TV links) is hardcoded in `AppLayout.vue`, not
|
|
plugin-driven.
|
|
|
|
`icon` is a string key mapped to a Lucide component core-side (same idea as
|
|
`get_settings_cards`); an unknown key renders with no icon.
|
|
|
|
> 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_permissions() -> List`
|
|
|
|
Returns the RBAC permissions this plugin owns. Added in contract 0.10.0. A
|
|
plugin declares the permission names its own routes enforce via
|
|
`require_permission`, instead of core accumulating every plugin's permissions in
|
|
one catalog (plugin-is-the-product).
|
|
|
|
Each entry is a `(name, description, category)` tuple, matching the core
|
|
permission catalog shape (dicts with those keys are also accepted). Names follow
|
|
the naming convention (lowercase dotted, e.g. `machines.edit`).
|
|
|
|
```python
|
|
class MachinesPlugin(BasePlugin):
|
|
def get_permissions(self):
|
|
return [
|
|
('machines.view', 'View machines', 'machines'),
|
|
('machines.create', 'Create machines', 'machines'),
|
|
('machines.edit', 'Edit machines', 'machines'),
|
|
('machines.delete', 'Delete machines', 'machines'),
|
|
]
|
|
```
|
|
|
|
Consumed by the core helper `full_permission_catalog()` (core permissions plus
|
|
every ENABLED plugin's `get_permissions()`), which backs three consumers:
|
|
|
|
- `flask seed permissions` seeds the full catalog.
|
|
- The role-management grid (`GET /api/users/permissions`) lists it, grouped by
|
|
category.
|
|
- API-token scope validation (`ApiToken.unknown_scope_names`) accepts a plugin
|
|
permission as a scope only while that plugin is enabled.
|
|
|
|
Plugin install and enable also seed the plugin's own permissions idempotently,
|
|
so enabling a fresh plugin creates its `Permission` rows without a separate seed
|
|
pass.
|
|
|
|
Disabled-plugin edge case: a disabled plugin is skipped by the catalog, so its
|
|
permissions are no longer offered for new scope grants or new role assignments.
|
|
The `Permission` ROWS already in the database are NOT deleted, so roles that
|
|
already reference them keep working until an admin edits the role. A broken
|
|
plugin is isolated in prod and re-raised in dev/test.
|
|
|
|
### `get_guest_grantable_permissions() -> List[str]`
|
|
|
|
Names the WRITE permissions this plugin allows a site to grant to the `guest`
|
|
role. Added in contract 0.21.0.
|
|
|
|
Anyone with no account holds the `guest` role. It is seeded with nothing, and a
|
|
site opens a door by granting it a permission in the role grid - which replaced
|
|
the per-feature booleans (`securesend_allow_guests`, one per kiosk) that used to
|
|
answer the same question one feature at a time.
|
|
|
|
Any `.view` or `.read` permission is grantable without a plugin saying anything.
|
|
This hook is only for the exception: a write that is genuinely meant to work for
|
|
somebody who never signs in. Naming one here does not open it - it makes the
|
|
checkbox available, and a site still has to tick it.
|
|
|
|
```python
|
|
class ToolsPlugin(BasePlugin):
|
|
def get_guest_grantable_permissions(self):
|
|
# Sending a password to a vendor: the recipient has no account here,
|
|
# and often neither does the sender. The unguessable token is the
|
|
# authorisation and the link is one-view.
|
|
return ['tools.secrets']
|
|
```
|
|
|
|
The bar for naming one: could this action be attributed to nobody, forever, and
|
|
still be defensible in the audit log? A guest write records no creator - the
|
|
trail says when and from where, never who. Return `[]` (the default) and none of
|
|
this plugin's writes can ever be made anonymous; anything not named is refused
|
|
by the API when an admin tries to grant it, not merely warned about.
|
|
|
|
Consumed by `guest_grantable_permissions()`, which backs the role API's guard on
|
|
the guest role and the greyed-out checkboxes in the role grid. A plugin may only
|
|
bless permissions that already exist in the catalog, so a typo widens nothing.
|
|
|
|
### `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).
|
|
|
|
An overlay keys on `assetid` and never on coordinates, so it needs to know
|
|
nothing about buildings or levels (ADR-017): it decorates whichever marker that
|
|
asset already has, on whatever level that marker is on. Do not return `mapx` /
|
|
`mapy` / `levelid` from an overlay endpoint - a position belongs to the asset,
|
|
and a second copy of it in an overlay is one that can disagree.
|
|
|
|
### `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_settings_defaults() -> List[Dict]` (0.16.0)
|
|
|
|
Declares the `Setting` rows this plugin owns. Return `[]` (the default) if it
|
|
owns none. Each entry is a dict:
|
|
|
|
| Key | Meaning |
|
|
|-----|---------|
|
|
| `key` | the Setting key |
|
|
| `value` | default value in string form |
|
|
| `valuetype` | `'string'` / `'boolean'` / `'integer'` / `'json'` |
|
|
| `category` | grouping the plugin's settings page filters on |
|
|
| `description` | what the setting does |
|
|
| `public` | `True` if an unauthenticated caller may read it; default `False` |
|
|
|
|
```python
|
|
class PrintedpartsPlugin(BasePlugin):
|
|
def get_settings_defaults(self):
|
|
return [
|
|
{'key': 'printedparts_label_prefix', 'value': '', 'valuetype': 'string',
|
|
'category': 'printedparts', 'public': True,
|
|
'description': 'Leading text on the physical labels, shown at the kiosk'},
|
|
]
|
|
```
|
|
|
|
The framework seeds declared keys at install, at enable, and on every
|
|
`flask plugin upgrade-all`, so a key added in a later plugin version reaches a
|
|
site that installed an earlier one. Existing values are never overwritten.
|
|
|
|
Declaring a key is also what tells the settings API which category and type to
|
|
use when an admin's save creates the row for the first time. Do not seed
|
|
settings by hand in `on_install` / `on_enable`: those hooks fire only on a state
|
|
transition, so a hand-seeded key added later never reaches an existing site, and
|
|
the row the first save creates lands in the placeholder `plugin` category where
|
|
the plugin's own settings page (which filters by category) cannot see it.
|
|
|
|
`public: True` puts the key on the unauthenticated read allowlist of
|
|
`GET /api/settings/<key>` and `GET /api/settings`. Use it only for cosmetic
|
|
values that a page rendering before login needs (a kiosk, a print page). Never
|
|
mark a credential, a hostname, or an integration URL public.
|
|
|
|
### `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`, `AssetRelationship`,
|
|
`RelationshipType`
|
|
- Responses: `success_response`, `error_response`, `paginated_response`,
|
|
`ErrorCodes`
|
|
- Pagination: `get_pagination_params`, `paginate_query`
|
|
- Authorization: `require_permission`, `require_role`,
|
|
`service_token_authorized`
|
|
(`service_token_authorized(scope)` returns True when the request carries a
|
|
managed service token scoped for `scope` whose owner holds that permission -
|
|
for unattended plugin endpoints like the GE-Enforce fetch API)
|
|
- `authorized_service_token(scope)` (0.15.0) - same check as
|
|
`service_token_authorized` but returns the `ApiToken` itself (or None), so a
|
|
plugin can honor the token's optional resource binding
|
|
(`token.resourcescopelist`: an allowlist of resource names the token may
|
|
reach, NULL = unrestricted). GE-Enforce uses it to pin a display's fetch
|
|
token to its own manifest scope + that scope's blobs.
|
|
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
|
|
`dualpath_single_machine_enabled`
|
|
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
|
`parse_import_datetime`
|
|
- Legacy employee directory: `employee_connection`
|
|
- CMMC USB check-in/out DB (read-write, used by the usb plugin):
|
|
`cmmc_usb_connection`
|
|
- `User` / `Role` (0.13.0) - the account and role models, e.g. resolving
|
|
alert recipients' emails from selected user ids or role membership
|
|
- `SupportTeam` (0.15.0) - the support-team model (carries a `webhookurl`), so
|
|
an alerting plugin can route a notification to a chosen team's Teams webhook
|
|
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
|
|
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
|
|
email is unconfigured; send_alert targets the site's alert_recipients
|
|
- `send_webhook(title, text)` (0.14.0) - POST an alert to the configured
|
|
`alert_webhook_url` (Teams Incoming Webhook / Workflow, or generic JSON via
|
|
the `alert_webhook_format` setting); best-effort, no-op when unset.
|
|
`send_alert` fans out to this automatically alongside email.
|
|
|
|
```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.
|
|
|
|
### Dualpath single-machine collapse
|
|
|
|
A Dualpath relationship pair is one physical dual-bay machine recorded as two
|
|
asset rows. When the site setting `dualpath_single_machine` is on (default), the
|
|
machines list, dashboard/report counts, and the floor map show the pair as a
|
|
single machine (the SECONDARY bay is hidden); the data model always keeps both
|
|
rows and detail pages stay per-bay.
|
|
|
|
```python
|
|
from shopdb.api import resolve_dualpath_pairs, dualpath_single_machine_enabled
|
|
|
|
collapse = resolve_dualpath_pairs()
|
|
# collapse.secondaryassetids: set of the non-primary bay asset ids to hide
|
|
# collapse.partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for every
|
|
# pair member (primary and secondary), for banners
|
|
|
|
if dualpath_single_machine_enabled():
|
|
# exclude the hidden bays and annotate the visible (primary) bay
|
|
...
|
|
```
|
|
|
|
PRIMARY is the pair member with the lower natural-sort assetnumber.
|
|
`resolve_dualpath_pairs` ignores the toggle (so a detail-page sibling banner can
|
|
show always); gate the collapse itself on `dualpath_single_machine_enabled()`.
|
|
|
|
### 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.
|
|
|
|
## Reversible bulk-import journal
|
|
|
|
Contract version 0.30.0.
|
|
|
|
A plugin that drives a bulk import - a site asset adoption, an integration sync -
|
|
records the primitive changes it makes so the whole batch can be reversed later.
|
|
The journal is import-agnostic: it stores primitive mutations (set a cell, insert
|
|
a row) tied to a batch id, and a generic undo replays them in reverse. Nothing in
|
|
core learns what the import means.
|
|
|
|
Exposed on `shopdb.api`:
|
|
|
|
- `ImportBatch` - one row per applied import. `batchid` is the identity that the
|
|
approval step, the write, and the undo all reference. `status` is `applied` or
|
|
`undone`.
|
|
- `ImportJournalEntry` - one reversible primitive mutation. `operation` is
|
|
`update` (a cell change, carrying `oldvalue`/`newvalue` so it can be put back)
|
|
or `insert` (a row this batch created, so undo can delete strictly what the
|
|
import created).
|
|
- `BATCH_APPLIED`, `BATCH_UNDONE` - the two `ImportBatch.status` values.
|
|
- `OP_UPDATE`, `OP_INSERT` - the two `ImportJournalEntry.operation` values.
|
|
|
|
What a plugin gets from this, and what it does NOT:
|
|
|
|
- A plugin's import UI reads `ImportBatch` to list applied batches (kind, row
|
|
count, who applied it, when) and offers an "Undo import" per batch.
|
|
- A plugin does NOT write the journal or run undo through these model imports.
|
|
The write/undo logic lives with the import writer (see
|
|
`scripts/site_imports/journal.py`), which records entries in the SAME
|
|
transaction as the mutation they describe and reverses them conservatively:
|
|
an `update` is put back only if the cell still holds the value the import
|
|
wrote (a value a human changed since is left as-is and reported), and an
|
|
`insert` is hard-deleted only when nothing outside the batch still references
|
|
the created row.
|
|
|
|
The tables are core-owned (migration `7d37_import_batches_and_journal`), so they
|
|
exist regardless of which plugins are installed.
|