Files
shopdb-flask/docs/PLUGIN-GUIDE.md
cproudlock 534f179ee6 Make main the lean product a site receives
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.
2026-09-19 11:24:40 -04:00

195 lines
10 KiB
Markdown

<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: The conventions and traps a real plugin proves out, and the checklist to walk before calling one done.
loadwith: PLUGIN-HOOKS.md
-->
# Building a ShopDB plugin: conventions and traps
This page is the judgement layer. [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) says what
each hook TAKES AND RETURNS; this says what a plugin that works actually DOES
with them - the conventions every bundled plugin follows, the traps that have
cost someone an afternoon, and the checklist to walk before calling a plugin
done. It defines nothing: where it shows a shape, the contract page is right.
Elsewhere: [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) to get a plugin running
today; [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md) to ship one from its
own repo.
> **Windows:** use `venv\Scripts\python` and `$env:FLASK_APP="shopdb"` (not
> `export`). Onboarding: [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md).
**Read `plugins/warranty/` alongside this page.** It is the reference
implementation: own tables, own permissions, a derived status, an asset panel, a
dashboard widget and reports - most of the contract exercised correctly, and
small enough to read in one sitting. `plugins/network/` is the cleanest
asset-only plugin, `plugins/backups/` the one to copy for a revision ledger fed
by the collector, and `plugins/knowledgebase/` for a plugin that owns no assets
at all.
---
## Conventions the contract does not state
**Identity lives on the `Asset`, not in your table** (ADR-001). The asset
carries the number, the serial, the location and the status; your extension
table carries only what is specific to your domain, keyed one-to-one by
`assetid` with `unique=True` and `ondelete='CASCADE'`. A plugin that adds its
own "asset number" column has forked the platform contract.
**Derived state is computed at read time, never stored.** Calibration status,
warranty coverage and low-stock are all derived from a date or a count when
they are read. A stored status column goes stale the moment a due date passes
with no write, and then the list badge, the detail badge and the report
disagree. Deriving costs one thing, and it is worth knowing before you choose:
you cannot filter it in SQL. `warranty` applies its coverage filter to the built
rows instead of as a `WHERE`, which is fine for the number of warranties a site
holds and would need rethinking at the size of the PC estate.
**One payload, one shape.** Create and update take the asset core fields and
the extension fields in ONE body, and every response returns the asset's
`to_dict()` with the extension nested under the plugin's key, so GET, POST and
PUT are the same shape to the frontend. `BaseModel.to_dict` iso-formats
`datetime` but not plain `date`, so a model with `Date` columns formats them
itself (`YYYY-MM-DD`) rather than shipping whatever the driver returned.
**Reads are `jwt_required(optional=True)`; writes require a permission.**
Anonymous kiosks and signed-out staff read the catalog; any state change needs
a logged-in user holding the right permission, declared by your own
[`get_permissions`](PLUGIN-HOOKS.md#hook-get_permissions) so core never edits
its catalog to accommodate a plugin. The `admin` role bypasses every check, so
an admin can drive the plugin before anyone grants the granular permissions.
Every write also writes an `AuditLog` row.
**Use the framework response helpers** (`success_response`, `error_response`
with `ErrorCodes`, `paginated_response`, `get_pagination_params`,
`paginate_query`) so every plugin's API looks the same to the frontend.
**Guard a delete that something still references.** Deleting a subtype that
rows still point at is refused with a 409 and a count, not allowed to blank out
existing rows.
**Seed reference data in `on_install`, idempotently.** Each `_ensure_*` helper
checks for existence first, so a reinstall does not duplicate rows. Do NOT seed
`Setting` rows this way - declare them from
[`get_settings_defaults`](PLUGIN-HOOKS.md#hook-get_settings_defaults), which
also reaches sites that installed an earlier version.
**Names are lowercase and concatenated** - `nextcalibrationdate`, not
`next_calibration_date`. `scripts/check-naming-and-style.sh` fails the build
otherwise. The rules are in `CONTRIBUTING.md`.
**Skipping a hook is a decision worth recording.** `warranty` declares no
`get_config_schema` because warranties are entered by hand: no external system,
no endpoint, no API key. The collector hooks are skipped for the same reason. A
reviewer asks about both, so say why in the plugin class rather than leaving a
silent gap.
---
## Traps
**`create_plugin_tables` cannot render a foreign key to a core table.** It
builds a per-plugin `MetaData` filtered to the plugin's own tables, so an FK to
`assets` has nothing to resolve against and Alembic raises
`NoReferencedTableError` at compile time. If your tables reference core tables,
hand the DDL to `op.create_table` (the same shape autogenerate produces); the
helper is only for plugins whose tables stand alone. This is a candidate
framework fix - teaching `_get_plugin_metadata` to pull in FK-referenced core
tables - not a rule you should design around.
**Two registries must know about your plugin**, and the failures are far apart:
`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py` (missing entry =
`flask plugin upgrade-all` raises rather than guessing) and
`EXPECTED_HEAD_REVISION` in `tests/test_plugin_migrations.py` (missing entry =
a `KeyError` from the test suite). A plugin that later adds `0002` moves its
expected-head line to the new revision. Whether your `0001` creates tables or
is a stamp-only anchor is decided in [PLUGINS.md](PLUGINS.md#migrations).
**Charset comes from the connection.** A plain `CREATE TABLE` inherits the
database default, which on the documented utf8mb4 database is what you want and
matches how the core chain creates its tables. Check any unique column against
the InnoDB index prefix limit before you widen it (utf8mb4 `VARCHAR(255)` is
1020 bytes, over the 767-byte cap on old MySQL - see the setup-problems table
in [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md)).
**Enabling a plugin does not serve it - restart the API.** Blueprints are
registered at app startup and Flask refuses to register one after the app has
served a request. `flask plugin enable <name>` flips a registry flag; until the
service restarts, `/api/<name>` is still a 404.
**Never hardcode an AssetType id.** Seeded ids are not stable across sites.
Resolve the type at runtime from `GET /api/assets/types` by its `assettype`
key. The machines form hardcoded one and has since moved to the lookup.
**`default_enabled: false` means the shared test app never registers your
blueprint.** The plugin's test module builds its own app, registers the
blueprint, runs `on_install` seeding, and restores the process-wide
`plugin_manager` singleton afterwards - the same snapshot/restore pattern
`tests/test_plugin_migrations.py` uses. The parametrized contract and migration
tests still pick the plugin up from the filesystem on their own.
---
## The three core files a bundled plugin still edits
Everything the sidebar, the Reports hub, the settings rail, asset panels and
the map show is data-driven off hooks, so a plugin contributes UI without core
knowing it exists. Three edits remain, and a review expects exactly these:
1. `PLUGIN_TABLE_OWNERS` - the migration registry above.
2. `frontend/src/api/index.js` - ADD your `<name>Api` block, mirroring
`machinesApi`. Do not reorganize the file. (The scaffold generates the block
for you and ships the views with an inline client so they run before you
paste it.)
3. `frontend/src/views/AppLayout.vue` - one `iconMap` entry mapping the icon
string your hooks return to a Lucide component. ADR-009 wants this replaced
by a registration API eventually.
Your route module carries `meta.plugin` on every route so the router gates it
when the plugin is absent; the staging rules are in
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#frontend). Follow the app's auth pattern:
list and detail public, the form `requiresAuth`, the settings subtype page
`requiresAuth + requiresAdmin`.
Three core features compose onto your pages for free, and there is nothing to
wire: `CustomFieldsSection` / `CustomFieldsInputs` (call
`customFieldsRef.value.save(assetId)` after your own save), `WarrantyPanel`
(asset-general, works on any type), and the floor map, which draws any typed
asset that has a position (ADR-001, ADR-017).
---
## End checklist
Before you call a plugin done:
- [ ] `manifest.json`: name, version, `core_version` pinned to the lowest
contract you need and capped below the next major, `api_prefix`,
`default_enabled`.
- [ ] Models follow the naming convention; identity stays on `Asset`.
- [ ] Derived state (if any) is computed at read time, never stored.
- [ ] Tables in `PLUGIN_TABLE_OWNERS`; expected head in
`tests/test_plugin_migrations.py`.
- [ ] Migration chain correct for your side of the cutover; hand-written
`op.create_table` if you have FKs to core tables.
- [ ] Imports only from `shopdb.api` and `shopdb.plugins.base` (the contract
test scans for this).
- [ ] Blueprint: jwt-optional reads, permission-gated writes, framework
response and pagination helpers, audit logs on writes.
- [ ] Permissions declared from `get_permissions`; install/enable seeds them.
- [ ] `on_install` seeds the asset type and reference data, idempotently;
settings declared from `get_settings_defaults`.
- [ ] Hooks: navigation, reports and models implemented; config schema and
collector implemented or consciously skipped with a reason.
- [ ] Frontend: route module with `meta.plugin`, API client addition, views
mirroring the master templates, settings subtype page, one `iconMap`
entry.
- [ ] Custom fields and warranty panel composed onto the detail and form pages.
- [ ] Tests: unit + API, self-contained app if `default_enabled` is false; the
three gates green ([DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md)).
- [ ] Install / enable / RESTART verified live, and a demo record renders on
every page.