printeditemfiles lands as the plugin's first incremental migration (0002 on the plugin chain - the ADR-008 payoff). Revisions are append-only per item: upload assigns the next number, records the uploader from the JWT, enforces an extension allowlist and a 100 MB cap; download serves the original filename; a permission-gated delete covers wrong-file mistakes. The detail page gains the revision table with a current badge. Unique storedfilename is sized 191 so the index fits MySQL's 767-byte prefix - the per-plugin chain does not apply the core env's ROW_FORMAT hook. Alert recipients gain roles: Role joins the 0.13.0 surface, a role picker on the settings page, and every active member of the selected roles is folded into the deduped recipient list.
393 lines
20 KiB
Markdown
393 lines
20 KiB
Markdown
# 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/<id>` 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: `<prefix>-<id:04d>` with the prefix from Setting. `PUT /items/<id>`
|
|
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/<id>/image` + public `GET /image/<filename>`, storing
|
|
`printeditem-<id>.<ext>` 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/<id>/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.<x>')` 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/<itemcode>` (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=<id>`
|
|
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-<id>-rev<n><ext>` 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` |
|