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

10 KiB

Building a ShopDB plugin: conventions and traps

This page is the judgement layer. 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 to get a plugin running today; 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.

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 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, 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.

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).

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. 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).
  • Install / enable / RESTART verified live, and a demo record renders on every page.