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.
195 lines
10 KiB
Markdown
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.
|