# 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 ` flips a registry flag; until the service restarts, `/api/` 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 `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.