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.
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\pythonand$env:FLASK_APP="shopdb"(notexport). 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:
PLUGIN_TABLE_OWNERS- the migration registry above.frontend/src/api/index.js- ADD your<name>Apiblock, mirroringmachinesApi. 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.)frontend/src/views/AppLayout.vue- oneiconMapentry 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_versionpinned 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 intests/test_plugin_migrations.py. - Migration chain correct for your side of the cutover; hand-written
op.create_tableif you have FKs to core tables. - Imports only from
shopdb.apiandshopdb.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_installseeds the asset type and reference data, idempotently; settings declared fromget_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, oneiconMapentry. - Custom fields and warranty panel composed onto the detail and form pages.
- Tests: unit + API, self-contained app if
default_enabledis false; the three gates green (DEVELOPMENT-SETUP). - Install / enable / RESTART verified live, and a demo record renders on every page.