main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
867 lines
38 KiB
Markdown
867 lines
38 KiB
Markdown
<!-- docmeta
|
|
purpose: reference
|
|
audience: developer, integrator
|
|
status: current
|
|
summary: The plugin contract itself: every BasePlugin hook, the manifest fields, and the shopdb.api import surface.
|
|
loadwith: none
|
|
-->
|
|
|
|
# Plugin Hooks Reference
|
|
|
|
**This page is the contract, and it is the only page that defines it.** Every
|
|
hook a plugin may implement, every manifest field, and every name a plugin may
|
|
import is specified here. PLUGIN-QUICKSTART and PLUGIN-GUIDE TEACH - they show
|
|
one plugin making one set of choices, and they link back here for the shape of
|
|
anything they use. If one of them disagrees with
|
|
this page, this page is right and the other page is a defect: fix it here and
|
|
delete the copy there.
|
|
|
|
Plugin authors implement `BasePlugin` and override the hooks they care about.
|
|
Required hooks must be implemented; optional hooks have defaults and can be
|
|
left alone. Every section carries a stable anchor (`#hook-get_reports` and so
|
|
on) so another page links to exactly the hook it means.
|
|
|
|
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).
|
|
|
|
## Hook index
|
|
|
|
Jump straight to the one you need; you should never have to read this page end
|
|
to end.
|
|
|
|
| Hook | | Contributes |
|
|
|---|---|---|
|
|
| [`meta`](#hook-meta) | required | The plugin's identity, built from `manifest.json` |
|
|
| [`get_blueprint`](#hook-get_blueprint) | required | The Flask blueprint mounted at `api_prefix` |
|
|
| [`get_models`](#hook-get_models) | required | The SQLAlchemy models the plugin owns |
|
|
| [`init_app`](#hook-init_app) | optional | Anything else at startup: schemas, a second blueprint |
|
|
| [`get_cli_commands`](#hook-get_cli_commands) | optional | Click commands on the Flask CLI |
|
|
| [`get_services`](#hook-get_services) | optional | Named services another plugin can obtain |
|
|
| [`get_dashboard_widgets`](#hook-get_dashboard_widgets) | optional | Cards on the dashboard queue |
|
|
| [`get_navigation_items`](#hook-get_navigation_items) | optional | The sidebar entry |
|
|
| [`get_reports`](#hook-get_reports) | optional | Cards on the Reports hub |
|
|
| [`get_permissions`](#hook-get_permissions) | optional | The RBAC permissions the plugin's routes enforce |
|
|
| [`get_guest_grantable_permissions`](#hook-get_guest_grantable_permissions) | optional | Which of its WRITES a site may grant to `guest` |
|
|
| [`get_settings_cards`](#hook-get_settings_cards) | optional | Cards in the settings rail |
|
|
| [`get_asset_panels`](#hook-get_asset_panels) | optional | Panels on asset detail pages |
|
|
| [`get_map_overlays`](#hook-get_map_overlays) | optional | Badges/rings and a legend entry on the floor map |
|
|
| [`get_asset_presentation`](#hook-get_asset_presentation) | optional | Icon and detail route for an asset type it owns |
|
|
| [`get_provisioning_note`](#hook-get_provisioning_note) | optional | What the setup wizard warns a site it will create |
|
|
| [`get_config_schema`](#hook-get_config_schema) | optional | Config fields the setup wizard prompts for |
|
|
| [`get_settings_defaults`](#hook-get_settings_defaults) | optional | The `Setting` rows the plugin owns |
|
|
| [`get_collector_schema`](#hook-get_collector_schema) | optional | JSON Schema for an external collector push |
|
|
| [`apply_collector_payload`](#hook-apply_collector_payload) | conditional | The upsert behind that collector |
|
|
| [`on_install` / `on_uninstall` / `on_enable` / `on_disable`](#lifecycle-hooks) | optional | State-transition work |
|
|
| [`get_setting` / `set_setting`](#helpers) | provided | Plugin-scoped settings (you call these, not override) |
|
|
|
|
The rest of the contract: [manifest fields](#manifest) -
|
|
[frontend contribution](#frontend) - [the `shopdb.api` import
|
|
surface](#import-surface) - [helpers](#helpers) - [removed
|
|
hooks](#removed-hooks) - [changing the contract](#versioning) - [the bulk-import
|
|
journal](#import-journal).
|
|
|
|
**One rule holds for every list-returning hook below.** The endpoint that
|
|
consumes it skips disabled plugins, and isolates a plugin that raises: logged
|
|
and dropped in production, re-raised in dev and test so you see it.
|
|
|
|
<a id="contract-version"></a>
|
|
<a id="manifest"></a>
|
|
## Plugin metadata
|
|
|
|
Each plugin ships a `manifest.json`, the single source of truth for its
|
|
identity; 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>` |
|
|
| `display_name` | No | Human label for UIs; defaults to the title-cased `name` |
|
|
| `default_enabled` | No | `false` means installing does not turn the plugin on |
|
|
|
|
`core_version` is checked at load time and a plugin outside the range is
|
|
refused. The framework declares its own 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. How
|
|
tight to pin, and what happens on a mismatch, are in
|
|
[PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md).
|
|
|
|
<a id="required-hooks"></a>
|
|
## Required hooks
|
|
|
|
<a id="hook-meta"></a>
|
|
### `meta` -> `PluginMeta`
|
|
|
|
Returns the plugin's metadata. Read it from `manifest.json` rather than typing
|
|
the values twice - the scaffold (`flask plugin new`) writes this for you:
|
|
|
|
```python
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
|
|
class ComputersPlugin(BasePlugin):
|
|
@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'),
|
|
)
|
|
```
|
|
|
|
<a id="hook-get_blueprint"></a>
|
|
### `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
|
|
```
|
|
|
|
<a id="hook-get_models"></a>
|
|
### `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]
|
|
```
|
|
|
|
<a id="optional-hooks"></a>
|
|
## Optional hooks
|
|
|
|
<a id="hook-init_app"></a>
|
|
### `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')
|
|
```
|
|
|
|
<a id="hook-get_cli_commands"></a>
|
|
### `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]
|
|
```
|
|
|
|
<a id="hook-get_services"></a>
|
|
### `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}
|
|
```
|
|
|
|
<a id="hook-get_dashboard_widgets"></a>
|
|
### `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.
|
|
|
|
**A card using the pre-0.19.0 shape (`'component': 'SomeWidget.vue'`) is
|
|
ignored and renders nothing** - if a card of yours is silently absent, that is
|
|
why. The reasoning is in the 0.19.0 row of
|
|
[CONTRACT-STABILITY.md](CONTRACT-STABILITY.md).
|
|
|
|
```python
|
|
class WarrantyPlugin(BasePlugin):
|
|
def get_dashboard_widgets(self):
|
|
return [{
|
|
'id': 'warranty-expiring', # stable, unique across plugins
|
|
'title': 'Warranties expiring',
|
|
'endpoint': '/api/warranty/dashboard/expiring',
|
|
'render': 'exceptions', # a core renderer, not a component
|
|
'severity': 'info', # orders cards on the page
|
|
'permission': 'warranty.view', # hidden without it
|
|
'empty': 'hide', # say nothing when there is nothing
|
|
'position': 50,
|
|
'viewall': '/reports/warranty', # optional link behind the heading
|
|
'map': {'title': 'hostname', 'detail': 'state'},
|
|
}]
|
|
```
|
|
|
|
`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 every enabled plugin's
|
|
widgets sorted by `position`.
|
|
|
|
<a id="hook-get_navigation_items"></a>
|
|
### `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.
|
|
|
|
<a id="hook-get_reports"></a>
|
|
### `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; the frontend groups them by category.
|
|
|
|
<a id="hook-get_permissions"></a>
|
|
### `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.
|
|
|
|
<a id="hook-get_guest_grantable_permissions"></a>
|
|
### `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.
|
|
|
|
<a id="hook-get_settings_cards"></a>
|
|
### `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 PrintersPlugin(BasePlugin):
|
|
def get_settings_cards(self):
|
|
return [{
|
|
'group': 'Printers', # rail group title (created if new)
|
|
'to': '/settings/printer-alerts',
|
|
'icon': 'bell', # string key, mapped core-side
|
|
'title': 'Low-Toner Alerts',
|
|
'description': 'Who gets warning and critical toner emails',
|
|
'position': 48, # order within the group
|
|
}]
|
|
```
|
|
|
|
Consumed by `GET /api/pluginui/settings-cards`, which merges enabled plugins'
|
|
cards into the core catalog.
|
|
|
|
<a id="hook-get_asset_panels"></a>
|
|
### `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. A panel that needs bespoke UI
|
|
(a chart) is out of scope for this data-only hook.
|
|
|
|
<a id="hook-get_map_overlays"></a>
|
|
### `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.
|
|
|
|
**No bundled plugin implements this hook on this line,** so the example below is
|
|
written out rather than pointed at. `GET /api/pluginui/map-overlays` returns an
|
|
empty list until a plugin declares one, which is the correct behaviour and not a
|
|
fault. Take the shape from here and from
|
|
[ADR-010](adr/ADR-010-frontend-plugin-hooks.md).
|
|
|
|
```python
|
|
class CamerasPlugin(BasePlugin):
|
|
def get_map_overlays(self):
|
|
return [{
|
|
'id': 'camera-offline',
|
|
'label': 'Camera offline', # legend label
|
|
'endpoint': '/api/cameras/map-overlay', # -> [{assetid, color, label}]
|
|
'style': 'badge', # 'badge' | 'ring'
|
|
'legend': True,
|
|
}]
|
|
```
|
|
|
|
Consumed by `GET /api/pluginui/map-overlays`.
|
|
|
|
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.
|
|
|
|
<a id="hook-get_asset_presentation"></a>
|
|
### `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.
|
|
|
|
**No bundled plugin implements this hook on this line either,** so this example
|
|
is also written out. `GET /api/pluginui/asset-presentation` returns an empty
|
|
list until a plugin declares one; core then falls back to its own icon and
|
|
route for the asset type.
|
|
|
|
```python
|
|
class CamerasPlugin(BasePlugin):
|
|
def get_asset_presentation(self):
|
|
return [{
|
|
'assettype': 'camera', # AssetType.assettype key the plugin owns
|
|
'icon': 'video',
|
|
'label': 'Camera',
|
|
'route': '/cameras/{assetid}',
|
|
}]
|
|
```
|
|
|
|
Consumed by `GET /api/pluginui/asset-presentation`.
|
|
|
|
<a id="hook-get_provisioning_note"></a>
|
|
### `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
|
|
return:
|
|
|
|
No bundled plugin declares one on this line; the shape is:
|
|
|
|
```python
|
|
class CamerasPlugin(BasePlugin):
|
|
def get_provisioning_note(self):
|
|
return {
|
|
'tables': ['camerastreams'],
|
|
'note': 'Creates a stream-credential table in the shopdb database.',
|
|
'docs': 'plugins/cameras/README.md',
|
|
}
|
|
```
|
|
|
|
<a id="hook-get_config_schema"></a>
|
|
### `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'},
|
|
]
|
|
```
|
|
|
|
<a id="hook-get_settings_defaults"></a>
|
|
### `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 BackupsPlugin(BasePlugin):
|
|
def get_settings_defaults(self):
|
|
return [
|
|
{'key': 'backups_intervalhours', 'value': '24', 'valuetype': 'integer',
|
|
'category': 'backups', 'public': True,
|
|
'description': 'Minimum hours between collection attempts on a PC'},
|
|
]
|
|
```
|
|
|
|
`public: True` there is load-bearing, not tidiness: the collecting script reads
|
|
that value from `/api/settings/public` BEFORE it has any credential. Left
|
|
private, the setting looked configurable and was not.
|
|
|
|
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.
|
|
|
|
<a id="hook-get_collector_schema"></a>
|
|
### `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.
|
|
|
|
<a id="hook-apply_collector_payload"></a>
|
|
### `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': []}
|
|
```
|
|
|
|
<a id="frontend"></a>
|
|
## Frontend contribution
|
|
|
|
A plugin's UI is part of the plugin, not part of core. This section is the
|
|
contract for it; ADR-013 has the reasoning.
|
|
|
|
**Where the files live.** `plugins/<name>/frontend/`, holding `routes.js` and
|
|
`views/`. Nothing is written into `frontend/src/`. `scripts/stage-frontend.mjs`
|
|
(run by npm predev/prebuild) copies each chosen plugin's `frontend/` into
|
|
`frontend/src/.plugins-staged/<name>/` and codegens
|
|
`frontend/src/router/routes.gen.js`, which the router imports. Both outputs are
|
|
generated and gitignored.
|
|
|
|
**What a lean build stages.** `SITE_PLUGINS=machines,printers npm run build`
|
|
stages only those plugins; unset, it stages every plugin that has a
|
|
`frontend/routes.js`. The exception, and the rest of the lean-build model, is
|
|
in [PLUGINS.md](PLUGINS.md#lean-builds).
|
|
|
|
**The route module.** `routes.js` default-exports an array of routes and may
|
|
also export `toplevel` for full-screen routes that render outside `AppLayout`
|
|
(a kiosk, a print page). `toplevel` is optional: the codegen imports each
|
|
staged module as a namespace, so a module without it is not a build error.
|
|
Every route carries `meta: { plugin: '<name>' }`; the router guard redirects to
|
|
the dashboard when the backend plugin is disabled, so a route never loads a
|
|
shell over a plugin that is not there.
|
|
|
|
**One import rule, and staging is the reason.** Anything from the CORE tree
|
|
uses the `@` alias (`@/api`, `@/components/PaginationBar.vue`, `@/stores/auth`);
|
|
anything inside your own plugin is relative (`./views/...`). A relative path
|
|
into the core tree such as `../../api` is correct where the file sits in your
|
|
plugin and wrong once staged into `.plugins-staged/<name>/`. The failure is a
|
|
build error, not anything subtle.
|
|
|
|
**Menus follow the build, not a flag.** The sidebar nav, the settings rail and
|
|
the Displays links gate on whether your route was actually staged, so an
|
|
omitted plugin's nav item, settings cards and kiosk links vanish together -
|
|
your hooks cannot leave a menu entry pointing at a page that is not there.
|
|
|
|
<a id="lifecycle-hooks"></a>
|
|
## 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 |
|
|
|
|
<a id="import-surface"></a>
|
|
## 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. The machine-readable truth is `__all__` in
|
|
`shopdb/api/__init__.py`; this list groups the same names by what they are for.
|
|
|
|
**Infrastructure and model bases** - `db`, `cache`, `BaseModel`, `AuditMixin`.
|
|
|
|
**Core models (the asset contract, ADR-001)** - `Asset`, `AssetType`,
|
|
`AssetStatus`, `AssetRelationship`, `RelationshipType`, `Vendor`, `Model`,
|
|
`Location`, `Communication`, `CommunicationType`, `Setting`, `AuditLog`,
|
|
`Application`, `AppVersion`, `OperatingSystem`, `Script` (the hosted script
|
|
library), `User`, `Role`, `SupportTeam` (carries a `webhookurl`, so an alerting
|
|
plugin can route to a team's webhook).
|
|
|
|
**Displays** - `DashboardDefault`, `DISPLAY_ROLES`, `DISPLAY_ROLE_PATHS`,
|
|
`normalize_display_role`. The role vocabulary is core's, so a plugin that
|
|
reports on displays reads it instead of keeping a copy that drifts.
|
|
|
|
**Responses and pagination** - `success_response`, `error_response`,
|
|
`paginated_response`, `ErrorCodes`, `get_pagination_params`, `paginate_query`.
|
|
|
|
**Authorization** - `require_permission`, `require_any_permission` (a read two
|
|
different rights should reach), `require_role`, and `caller_holds(permission)`,
|
|
which ANSWERS whether the caller holds a permission without refusing the
|
|
request - for a route that serves everyone but should only offer what the
|
|
caller can act on.
|
|
|
|
**Service and collector tokens** - `service_token_authorized(scope)` returns
|
|
True when the request carries a managed service token scoped for `scope` whose
|
|
owner holds that permission; `authorized_service_token(scope)` returns the
|
|
`ApiToken` itself so a plugin can honor the token's optional resource binding
|
|
(`token.resourcescopelist`, NULL = unrestricted); `mint_service_token`,
|
|
`list_service_tokens` and `revoke_service_tokens` provision, list and revoke a
|
|
single-scope token from a plugin's own UI; `collector_authorized(pluginname)`
|
|
gates an agent-facing route that lives outside the generic collector blueprint
|
|
on the same per-plugin key and kill switch the ingest route uses.
|
|
|
|
**Serving uploads** - `send_upload`, `harden_upload_response`. Use them for any
|
|
user-supplied bytes: they set the headers that keep an SVG or an HTML file from
|
|
running as script in this origin.
|
|
|
|
**Caller IP** - `client_ip`, `strip_port`. Do not hand-roll this. The obvious
|
|
version reads the first `X-Forwarded-For` hop, which the caller supplies, so a
|
|
rate limit keyed on it is defeated by rotating the header.
|
|
|
|
**Request-argument and value parsing** - `intarg`, `intargs`, `QueryArgError`;
|
|
`parse_date`, `parse_date_fields`, `DateValueError`; `badge_kind`,
|
|
`normalise_badge`, `BadgeShapeError` (one badge vocabulary for the site, since
|
|
both kiosks read the same physical badges).
|
|
|
|
**Helpers** - `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
|
|
`dualpath_single_machine_enabled`.
|
|
|
|
**Mail and webhooks** - `send_email(to, subject, html, text=None)`,
|
|
`send_alert(subject, html, text=None)` (settings-first, no-op safe when email
|
|
is unconfigured; targets the site's alert_recipients) and
|
|
`send_webhook(title, text)` (Teams Incoming Webhook / Workflow, or generic JSON
|
|
via the `alert_webhook_format` setting; best-effort, no-op when unset).
|
|
`send_alert` fans out to the webhook automatically alongside email.
|
|
|
|
**Bulk import** - `apply_import_timestamps`, `import_mode_active`,
|
|
`parse_import_datetime`; `register_import_hook` and `RowWarning` (a plugin owns
|
|
a VIRTUAL COLUMN on an importable CSV table plus a post-write row hook; core
|
|
never learns what the column means); and the journal models under
|
|
[Reversible bulk-import journal](#import-journal).
|
|
|
|
**External directories** - `employee_connection`, a read-only handle on a
|
|
site's existing HR database. Core global search and audit-log name resolution
|
|
use it; a plugin may too. It returns nothing when `EMPLOYEE_DB_*` is unset, so
|
|
guard on that rather than assuming a site has one.
|
|
|
|
```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.
|
|
|
|
<a id="helpers"></a>
|
|
## Helpers exposed to plugins
|
|
|
|
Everything importable is listed above; this section is only the semantics you
|
|
cannot guess from the name.
|
|
|
|
**`audit_log(action, entitytype, entityid=None, entityname=None, changes=None,
|
|
details=None)`** - write an audit row on every mutation. `changes` takes
|
|
`{'before': ..., 'after': ...}`. The current user, IP and timestamp are
|
|
captured for you.
|
|
|
|
**`get_setting(key, default=None)` / `set_setting(key, value, valuetype='string',
|
|
description=None)`** - methods ON your plugin class, reading and writing the
|
|
core `Setting` store under the namespaced key `plugin.<pluginname>.<key>`, so
|
|
two plugins cannot collide. Use them to READ; declare the rows themselves from
|
|
[`get_settings_defaults`](#hook-get_settings_defaults) rather than writing them
|
|
in `on_install`.
|
|
|
|
**`resolve_asset_position(asset)`** - returns
|
|
`{'mapx', 'mapy', 'levelid', 'positionsource'}`, where `positionsource` is
|
|
`'self' | 'related' | 'location' | None`. The resolution order is in
|
|
[ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md); the `levelid`
|
|
belongs to whichever source supplied the coordinates, and a plugin that writes
|
|
a position MUST write its level with it (ADR-017) - a marker drawn on the wrong
|
|
floor plan looks correct.
|
|
|
|
**`resolve_dualpath_pairs()` / `dualpath_single_machine_enabled()`** - a
|
|
Dualpath pair is one physical dual-bay machine recorded as two asset rows. When
|
|
the site setting `dualpath_single_machine` is on (the default), lists,
|
|
dashboard/report counts and the map show the pair as one machine by hiding the
|
|
SECONDARY bay; both rows always exist and detail pages stay per-bay. PRIMARY is
|
|
the pair member with the lower natural-sort assetnumber.
|
|
`resolve_dualpath_pairs()` returns `secondaryassetids` (the ids to hide) and
|
|
`partnerbyasset` (`assetid -> partner`, for both members, for banners) and
|
|
IGNORES the toggle, so a detail-page sibling banner can always show; gate the
|
|
collapse itself on `dualpath_single_machine_enabled()`.
|
|
|
|
**`apply_import_timestamps(row, data)` / `import_mode_active()` /
|
|
`parse_import_datetime(value)`** - bulk imports from the classic ASP shopdb
|
|
preserve each row's original `createddate` / `modifieddate` instead of stamping
|
|
"now". `apply_import_timestamps` is a no-op unless the caller is an admin AND
|
|
sent `X-Import-Mode: true`, so it never affects normal traffic; call it after
|
|
`db.session.add(row)` and before the commit. `import_mode_active()` is the same
|
|
predicate, for guarding other backdated behaviour (accepting a historical
|
|
`checkouttime`, say). `parse_import_datetime` reads both ISO
|
|
`2020-01-05T12:00:00` and legacy `YYYY-MM-DD HH:MM:SS` into naive UTC. The
|
|
operator manual is [IMPORT-API.md](IMPORT-API.md).
|
|
|
|
<a id="removed-hooks"></a>
|
|
## 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. |
|
|
| `get_searchable_fields` | Removed in 0.4.0. Global search (`/api/search`) is a core concern over the asset model, already covering every bundled asset type, and honours runtime enable/disable. No plugin ever implemented it. |
|
|
|
|
<a id="versioning"></a>
|
|
## 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.
|
|
4. Update this page in the same change. `tests/test_docs_contract.py` fails the
|
|
build when a public `BasePlugin` hook is not mentioned here.
|
|
|
|
<a id="import-journal"></a>
|
|
## 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 the approval step, the write and the undo all reference; `status`
|
|
is `BATCH_APPLIED` or `BATCH_UNDONE`) and `ImportJournalEntry` (one reversible
|
|
mutation; `operation` is `OP_UPDATE`, carrying `oldvalue`/`newvalue`, or
|
|
`OP_INSERT`, a row this batch created).
|
|
|
|
**A plugin READS these and does not write them.** Its import UI lists applied
|
|
batches and offers "Undo import" per batch. The write and undo logic lives with
|
|
the import writer (`scripts/site_imports/journal.py`), which journals in the
|
|
SAME transaction as the mutation it describes and reverses conservatively: an
|
|
update is put back only if the cell still holds the value the import wrote (a
|
|
value a human has since changed is left alone and reported), and an insert is
|
|
hard-deleted only when nothing outside the batch references the created row.
|
|
|
|
The tables are core-owned (migration `7d37_import_batches_and_journal`), so they
|
|
exist regardless of which plugins are installed.
|