# Plugin lab: build the printedparts plugin A hand-held, build-along tutorial: construct the 3D-printed-parts storefront + kiosk plugin specified in `docs/proposals/printedparts-plugin.md`, stage by stage, seeing each piece work before moving on. Written for someone building their first plugin. The finished implementation lives on the `feat/printedparts-plugin` branch with one commit per stage, tagged `lab-stage-01` .. `lab-stage-10` - when stuck, `git diff lab-stage-03 lab-stage-04` shows exactly what a stage changes. Know before you start - You are building a BUNDLED plugin inside this repo. Plugin frontend files live in core (`frontend/src/...`), and three core files get small edits: `frontend/src/api/index.js`, the sidebar icon map in `AppLayout.vue`, and `PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. Normal for all bundled plugins - external-plugin UI packaging does not exist yet. - Three deliberate divergences from the scaffold, each a teaching point: (1) NO AssetType - these are quantity consumables, not ADR-001 assets (stage 1); (2) the migration is a REAL baseline that creates tables, not a stamp-only anchor (stage 2); (3) the kiosk take endpoint is the product's first UNauthenticated write - read the decision record in the proposal before stage 7. - Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`); DB names lowercase concatenated (`quantityonhand`); run `bash scripts/check-naming-and-style.sh` + the tests at every stage; one git commit per stage. Prerequisites: working dev environment (README quick start), skim `PLUGIN-QUICKSTART.md`, `PLUGIN-GUIDE.md` (the measuringtools exemplar this lab imitates), `PLUGIN-HOOKS.md`, and `CONTRIBUTING.md` naming rules. --- ## Stage 0 - orientation (no code) Read the proposal. Tour the two reference plugins you will imitate: `plugins/usb/` (checkout ledger + badge contract) and `plugins/measuringtools/` (post-cutover migration baseline, hooks). See it work: run the app, log in. ## Stage 1 - scaffold, minus the AssetType ``` flask plugin new printedparts --description "3D-printed parts inventory + kiosk checkout" ``` Walk the generated tree. Then diverge: 1. In `plugins/printedparts/plugin.py`, DELETE `_ensure_asset_type` and its `on_install` call - a printed part is a kind-with-a-count, not an asset. Replace it with settings seeding (see the tagged commit): three Setting rows, category `printedparts` - `printedparts_code_prefix` (3DP), `printedparts_default_threshold` (5), `printedparts_unknown_badge` (deny). 2. `manifest.json`: `"dependencies": ["employees"]` (badge names), `"core_version": ">=0.11.0,<1.0.0"`, `"default_enabled": false`, `"display_name": "3D Printed Parts"`. See it work: `flask plugin list` shows printedparts [Available]. Commit: `printedparts stage 1: scaffold, no AssetType, manifest per spec` ## Stage 2 - models + real migration baseline + tables live 1. Replace the scaffold model with `models/printeditem.py`: `PrintedItem` (itemcode unique+indexed, itemname, itemdescription, imageurl, quantityonhand, lowstockthreshold, binlocation, printnotes) and `PrintedItemTransaction` (printeditemid FK CASCADE, transactiontype take/restock/adjust, SIGNED quantitychange, employeesso, employeename, reason, transactiondate) - both on `BaseModel`. The ledger is the source of truth; quantityonhand is a cache moved in the same commit. 2. Update `models/__init__.py` exports and `plugin.py` `get_models`. 3. Register in `PLUGIN_TABLE_OWNERS` (`shopdb/plugins/alembic_template.py`): `'printedparts': ('printeditems', 'printeditemtransactions'),` 4. `migrations/`: copy `script.py.mako` + the 3-line `env.py` from measuringtools (change PLUGIN_NAME), then hand-write `versions/0001_printedparts_baseline.py` with explicit `op.create_table` for both tables + the three transaction indexes. 5. The scaffold's `api/routes.py` still imports the model you deleted - make the blueprint import cleanly (a placeholder route is fine for now). See it work: ``` flask plugin install printedparts && flask plugin enable printedparts mysql> SHOW TABLES LIKE 'printed%'; -- both tables mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline flask plugin upgrade-all -- printedparts: ok (idempotent) ``` Common errors (both hit for real while building this): - An empty `Migration error:` on install. Root cause: anything that makes `plugins.printedparts.models` fail to import - the alembic env imports the models package, which pulls in plugin.py and routes.py. Here it was the scaffold routes importing the deleted model; the ImportError gets caught and retried down a subprocess path with no stderr. Fix the import, not the migration. - `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py`: add `EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'` - the guard makes every new plugin declare its expected head on purpose. Commit + tag `lab-stage-02`. ## Stage 3 - read API + list page (the first visible win) 1. Real `api/routes.py`: `GET /items` (jwt-optional; pagination via `get_pagination_params`/`paginate_query`, search across code/name/description/bin, `?lowstock=true` filter) and `GET /items/` returning the item + its 25 most recent transactions. 2. `get_navigation_items` on the plugin: `{'name': '3D Parts', 'icon': 'box', 'route': '/printedparts', 'position': 46}`. 3. Frontend: paste the `printedpartsApi` client into `frontend/src/api/index.js` (list/get for now, paths under `/printedparts/items`); rename the scaffold views to `PrintedItemsList/PrintedItemDetail/PrintedItemForm.vue` and repoint `router/routes/printedparts.js`; build the list page from `PrintersList.vue` (global styles, `useListQuery`, PaginationBar) with an image thumb column and a red/green quantity badge vs the threshold. 4. Seed two or three rows by hand (SQL or flask shell) purely to have something to look at. NOTE: hand-seeded stock has no ledger backing - the stage-9 reconcile report will flag exactly these rows, which is the check working. See it work: navigate to `/printedparts` - your parts in a table, low-stock row red-badged. Everything before this moment was invisible; from here on every stage shows on screen. Common error: nav icon missing. The sidebar maps icon NAMES to Lucide components in `AppLayout.vue` (`iconMap`); an unknown name renders nothing. Add `'box': Box` to the map (and the import) or reuse an existing name. Commit + tag `lab-stage-03`. ## Stage 4 - catalog mutations + item photos + detail/form pages 1. `POST /items` mints the itemcode AFTER `db.session.flush()` assigns the id: `-` with the prefix from Setting. `PUT /items/` updates catalog fields but REFUSES `quantityonhand` (ledger-managed). `DELETE` soft-retires. All `@jwt_required()` (permissions come in stage 6). 2. Image trio copied from `shopdb/core/api/models.py`: POST/DELETE `/items//image` + public `GET /image/`, storing `printeditem-.` in `instance/printedpartsimages/`, wiping prior extensions on replace, prefix-guarded delete. 3. `PrintedItemDetail.vue` on the unified detail skeleton (hero image, info list, transactions table); `PrintedItemForm.vue` create/edit + photo upload on edit; extend the api client. See it work: add a part with a photo in the UI; thumbnail on the list, hero on the detail; `PUT` with `quantityonhand` returns the ledger-managed error. Commit + tag `lab-stage-04`. ## Stage 5 - the ledger: restock/adjust with badge attribution 1. `services/badges.py` - COPY the USB badge contract (do not import `plugins.usb`; cross-plugin imports fail the contract test): `^0(\d+)BZ$` PayNo wrap, all-digits SSO, name lookup via the employees plugin `DirectoryEmployee` (lazy import, graceful fallback), and the `printedparts_unknown_badge` policy - deny raises a kiosk-displayable `BadgeError`, allow records the SSO with an empty name. 2. `_ledger_write(item, type, change, sso, name, reason)` - THE invariant: append the transaction row and move the cached quantity in ONE commit. Every write path goes through it. 3. `POST /items//restock` {quantity, badge} and `/adjust` {quantitychange, reason, badge}; adjust requires a reason and refuses to drive stock below zero. 4. Detail page: Restock/Adjust modals (shared `Modal.vue`). 5. Tests as you go: minting, cache==ledger after a restock, the PayNo badge shape, reason-required + below-zero guards, the policy toggle, 401 for anonymous. See `tests/test_plugins/test_printedparts_ledger.py`. See it work: restock from the detail page with your SSO - quantity moves AND a named transaction row appears. Common error: in tests, mutating rows through a nested `app.app_context()` does not reliably stick in the sqlite test env - stock the item through the real restock endpoint instead (also more honest). Commit + tag `lab-stage-05`. ## Stage 6 - RBAC 1. `get_permissions` on the plugin: view/create/edit/delete/restock, category `printedparts` (seeded automatically on install/enable and by `flask seed permissions`). 2. Add `@require_permission('printedparts.')` under `@jwt_required()` on every mutation: create/edit/delete/image = create/edit/delete; restock + adjust = restock. 3. Test with the `member_headers` fixture (authenticated, role-less): 403 where admin succeeds - authentication alone is not authorization. See it work: the permissions appear in the role grid (Settings > Roles), and the member test passes. Commit + tag `lab-stage-06`. ## Stage 7 - the kiosk (the deliberate open write) Read the decision record in the proposal first. The take endpoint must stay: decrement-only, badge-attributed server-side, bounded, physically rate-limited. Put the justification in the plugin README. 1. Backend, both UNdecorated: `GET /kiosk/item/` (summary for a scanned bin code) and `POST /kiosk/take` {itemcode, badge, quantity} - validate active item, 1 <= qty <= onhand, resolve the badge, then `_ledger_write(..., 'take', -quantity, ...)`. Error strings are shown verbatim on the kiosk - write them for a person standing at a screen. 2. `TouchKeypad.vue` - net-new, dumb 3x4 grid emitting digit/clear/backspace. 3. `PartsKiosk.vue` + a top-level `/parts-kiosk` route registered beside `/shopfloor` in `router/index.js` (NO requiresAuth, outside AppLayout, `meta.plugin` so a disabled plugin dead-ends). Three steps - scan item, scan badge, keypad quantity - driven by ONE hidden always-focused input that consumes keyboard-wedge scans (scanners type the code + Enter) for whichever step is active; manual type-in fallbacks for damaged labels. Success screen auto-resets after a few seconds. 4. Kiosk test: open access, over-take guard, unknown-badge 422, and cache==ledger afterward. See it work: full walkthrough in a browser - type a code, badge in, keypad 2, TAKE - stock drops with your name in the ledger. Common error (by design): the full suite fails with `test_authz.py::test_mutation_rejects_roleless_member[printedparts.kiosk_take]`. That sweep asserts EVERY mutating route rejects a role-less user - the framework's net against accidentally-open writes. Your kiosk take is open on purpose, so add `printedparts.kiosk_take` to EXEMPT_ENDPOINTS with a comment pointing at the decision record. The net stays; the exception is explicit and reviewable. Commit + tag `lab-stage-07`. ## Stage 8 - 1in x 0.5in bin labels 1. `frontend/src/views/print/PrintedPartsLabels.vue` + a public `/print/printedparts-labels` route beside `/print/usb-labels` (a plugin OWNS its label page - the USB precedent; parts are not in the asset-label TYPE_CONFIG because they are not assets). 2. The label: CODE128 of the itemcode via JsBarcode (`{format:'CODE128', displayValue:false, width:1.4, height:26, margin:0}`) + the code text at ~6.5pt. A QR at 0.4in is at the edge of scanner tolerance; CODE128 of `3DP-0042` is comfortable. 3. Roll stock = one label per page: a global (unscoped) print style with `@page { size: 1in 0.5in; margin: 0 }` and `page-break-after: always` on each `.bin-label`. Multi-select + per-item copies; `?item=` preselects (the Detail page's Bin Label button). See it work: print preview shows one 1x0.5 label per page; scan the printed barcode (or the on-screen one with a phone scanner app) into the kiosk - label -> scan -> badge -> take -> ledger is the demo moment. Commit + tag `lab-stage-08`. ## Stage 9 - reports + the reconcile check 1. Three jwt-optional endpoints in the plugin blueprint, each honoring `?format=csv` (local CSV helper - `generate_csv` is not on the contract surface): `/reports/stock`, `/reports/consumption?days=N`, `/reports/by-person?days=N`. 2. The stock report's `ledgerdelta` column = cached quantityonhand minus the ledger SUM per item. Always 0 for ledger-driven stock; nonzero flags a write path that bypassed `_ledger_write` - your stage-3 hand-seeded rows show up here, proving the check works. 3. `get_reports` on the plugin (endpoint-style entries, categories inventory/usage) - they merge into `GET /api/reports` and the /reports hub while the plugin is enabled. Common error: MySQL `SUM()` returns Decimal; `int()` it or the JSON carries strings. Deferred by decision: `get_dashboard_widgets` (predates the ADR-010 data-only renderers; needs a core component) and a Settings card (needs a settings page to link). Reports are the monitoring surface. Commit + tag `lab-stage-09`. ## Stage 10 - closeout 1. Lifecycle: `flask plugin disable printedparts` - nav, reports, and grantable permissions disappear; API routes only disappear after a RESTART (blueprints register at startup - the guide's section 12 gotcha). Re-enable. 2. Fresh-database proof: scratch DATABASE_URL, `flask db upgrade` + `flask plugin install/enable printedparts` + `upgrade-all` - green with zero manual SQL. 3. Full suite: backend pytest, vitest, frontend build, naming hook. 4. Walk `PLUGIN-GUIDE.md` section 12's End checklist. Done means: a colleague can clone the repo, enable the plugin, print a bin label, and take a part at the kiosk with their badge - without asking you anything. ## Stage 11 (extension) - low-stock email alerts Per-item thresholds already exist; alerting on them is a worked example of a CONTRACT ADDITION, because the mailer was not on the plugin surface: 1. Export `send_email`/`send_alert` from `shopdb/api/__init__.py`, bump `__contract_version__` 0.11.0 -> 0.12.0, and update PLUGIN-HOOKS.md - the docs-drift guard test fails until the doc's version example matches. Manifest pins `core_version >=0.12.0` since the plugin now needs it. 2. Fire the alert inside `_ledger_write` when a DECREMENT crosses the threshold (before > threshold >= after). Crossing, not being-below, is the natural debounce: one alert per depletion, restocking above rearms. Best-effort try/except AFTER the commit - mail failure must never fail the take. 3. Recipients: Setting `printedparts_alert_email` (comma-separated), empty falls back to the site's alert_recipients via `send_alert`. Seed the new setting in on_enable too (idempotent) so already-installed sites get it. 4. Test with a monkeypatched sender: no alert above threshold, one on the crossing, no re-fire while below, rearm after restock (see `test_lowstock_alert_fires_on_crossing_only`). ## Stage 12 (extension) - the admin settings page A get_settings_cards card needs a PAGE to link, which is why stage 9 deferred it. The page is ordinary: 1. `frontend/src/views/settings/PrintedPartsSettings.vue` - load the four keys via `settingsApi.list({category: 'printedparts'})`, save each with `settingsApi.update(key, value)` (admin-gated server-side). 2. Route in the PLUGIN's router file with path `settings/printedparts` + `requiresAuth, requiresAdmin, plugin` meta - the router shell automatically nests any `settings/...` path under the two-pane settings rail. 3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` - the card appears in the rail's catalog while the plugin is enabled. ## Stage 13 (extension) - alert recipients picked from shopdb users Free-text emails rot; user accounts do not. Another contract addition: `User` joins the surface (0.13.0 - export, PLUGIN-HOOKS, version bump, the docs-drift guard again). 1. Setting `printedparts_alert_userids` (comma-separated user ids), seeded beside the others. 2. `_alert_recipients()`: resolve each selected id to an ACTIVE user's account email, merge with the free-text list, dedupe order-preserving; empty result still falls back to the site alert_recipients. 3. Settings page: checkbox picker over `usersApi.list()` (the page is admin-only, matching the endpoint), saving joined ids. 4. Test: active user's email + free-text merge deduped, inactive user skipped (`test_alert_recipients_merge_users_and_freetext`). ## Stage 14 (extension) - retire/restore in the UI, dashless codes Field feedback stage: the soft-delete endpoint existed with no button, and the site wanted `WJRP0042`, not `WJRP-0042`. 1. Detail gains Retire (confirm dialog; item leaves the storefront and the kiosk 404s its code, history and label intact) and Restore; the list gains an Include-retired toggle (`?active=false`) with a Retired badge. Restore is its own POST gated by printedparts.delete - PUT deliberately cannot flip isactive. 2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their codes - itemcode is an immutable label once printed on a bin. ## Stage 15 (extension) - print-file revisions + role-based alerts Two more field requests, and the plugin's FIRST incremental migration: 1. `printeditemfiles` (append-only revisions of the STL/3MF/gcode per item) arrives as `0002_printeditemfiles` on top of the 0001 baseline - the ADR-008 payoff: the plugin evolves its own schema, `flask plugin upgrade-all` applies it, the core chain never hears about it. Update PLUGIN_TABLE_OWNERS and the guard test's expected head. Gotchas hit live: (a) MySQL 5.6 dev box - a VARCHAR(255) UNIQUE on utf8mb4 dies with error 1071 because the per-plugin chain does not apply the core env's ROW_FORMAT=DYNAMIC hook; size unique columns to 191 or less (191*4 = 764 bytes fits the 767 prefix). (b) The dev container's innodb_large_prefix globals reset on restart (documented dev caveat). 2. Upload endpoint assigns revision = max+1, stores `printeditem--rev` in `instance/printedpartsfiles/` (extension allowlist, 100 MB cap), records uploader from the JWT. Download serves the ORIGINAL filename; delete (permission-gated) exists for wrong-file mistakes, otherwise history is append-only. Detail page gains the revision table with a "current" badge on the newest. 3. Role-based alert recipients: `Role` joins the 0.13.0 surface beside User; Setting `printedparts_alert_roleids`; `_alert_recipients` folds in every ACTIVE member of each selected role (role.users backref), deduped with the user picks and free-text; settings page gains a role picker. --- ## Where each pattern lives (cheat sheet) | Need | Copy from | |---|---| | Standalone (non-asset) plugin shape | `plugins/knowledgebase/` | | Checkout/ledger + badge contract | `plugins/usb/` (`api/routes.py` badge regex, `api/selfhosted.py` name resolve) | | Real-baseline plugin migration | `plugins/measuringtools/migrations/` | | Blueprint style, pagination, authz | `plugins/measuringtools/api/routes.py` | | Image upload/serve/delete | `shopdb/core/api/models.py` | | Open kiosk endpoints precedent | `plugins/employees/api/routes.py`, `plugins/notifications/api/routes.py` | | Plugin-owned label print view | `frontend/src/views/print/USBLabelBatch.vue` | | Barcode/QR rendering | JsBarcode usage in `AssetLabel.vue`, `qrLogo.js` | | Kiosk route posture | `/shopfloor` in `frontend/src/router/index.js` | | List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` | | Reports hook + CSV | `plugins/warranty/` + `shopdb/core/api/reports.py` | | Permissions declaration | `plugins/usb/plugin.py::get_permissions` | | The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..10` |