Browsing the catalog (item list, detail, file listings) now requires authentication plus the view permission, and the /printedparts pages and the label print page require login. Still deliberately open: the kiosk endpoints per the decision record, the image serve and file download (img tags and anchor downloads cannot carry a JWT), and the reports (product-wide jwt-optional convention). Grant printedparts.view to the roles that should see the catalog.
22 KiB
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 inAppLayout.vue, andPLUGIN_TABLE_OWNERSinshopdb/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); runbash 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:
- In
plugins/printedparts/plugin.py, DELETE_ensure_asset_typeand itson_installcall - a printed part is a kind-with-a-count, not an asset. Replace it with settings seeding (see the tagged commit): three Setting rows, categoryprintedparts-printedparts_code_prefix(3DP),printedparts_default_threshold(5),printedparts_unknown_badge(deny). 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
- Replace the scaffold model with
models/printeditem.py:PrintedItem(itemcode unique+indexed, itemname, itemdescription, imageurl, quantityonhand, lowstockthreshold, binlocation, printnotes) andPrintedItemTransaction(printeditemid FK CASCADE, transactiontype take/restock/adjust, SIGNED quantitychange, employeesso, employeename, reason, transactiondate) - both onBaseModel. The ledger is the source of truth; quantityonhand is a cache moved in the same commit. - Update
models/__init__.pyexports andplugin.pyget_models. - Register in
PLUGIN_TABLE_OWNERS(shopdb/plugins/alembic_template.py):'printedparts': ('printeditems', 'printeditemtransactions'), migrations/: copyscript.py.mako+ the 3-lineenv.pyfrom measuringtools (change PLUGIN_NAME), then hand-writeversions/0001_printedparts_baseline.pywith explicitop.create_tablefor both tables + the three transaction indexes.- The scaffold's
api/routes.pystill 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 makesplugins.printedparts.modelsfail 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'fromtests/test_plugin_migrations.py: addEXPECTED_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)
- Real
api/routes.py:GET /items(jwt-optional; pagination viaget_pagination_params/paginate_query, search across code/name/description/bin,?lowstock=truefilter) andGET /items/<id>returning the item + its 25 most recent transactions. get_navigation_itemson the plugin:{'name': '3D Parts', 'icon': 'box', 'route': '/printedparts', 'position': 46}.- Frontend: paste the
printedpartsApiclient intofrontend/src/api/index.js(list/get for now, paths under/printedparts/items); rename the scaffold views toPrintedItemsList/PrintedItemDetail/PrintedItemForm.vueand repointrouter/routes/printedparts.js; build the list page fromPrintersList.vue(global styles,useListQuery, PaginationBar) with an image thumb column and a red/green quantity badge vs the threshold. - 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
POST /itemsmints the itemcode AFTERdb.session.flush()assigns the id:<prefix>-<id:04d>with the prefix from Setting.PUT /items/<id>updates catalog fields but REFUSESquantityonhand(ledger-managed).DELETEsoft-retires. All@jwt_required()(permissions come in stage 6).- Image trio copied from
shopdb/core/api/models.py: POST/DELETE/items/<id>/image+ publicGET /image/<filename>, storingprinteditem-<id>.<ext>ininstance/printedpartsimages/, wiping prior extensions on replace, prefix-guarded delete. PrintedItemDetail.vueon the unified detail skeleton (hero image, info list, transactions table);PrintedItemForm.vuecreate/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
services/badges.py- COPY the USB badge contract (do not importplugins.usb; cross-plugin imports fail the contract test):^0(\d+)BZ$PayNo wrap, all-digits SSO, name lookup via the employees pluginDirectoryEmployee(lazy import, graceful fallback), and theprintedparts_unknown_badgepolicy - deny raises a kiosk-displayableBadgeError, allow records the SSO with an empty name._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.POST /items/<id>/restock{quantity, badge} and/adjust{quantitychange, reason, badge}; adjust requires a reason and refuses to drive stock below zero.- Detail page: Restock/Adjust modals (shared
Modal.vue). - 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
get_permissionson the plugin: view/create/edit/delete/restock, categoryprintedparts(seeded automatically on install/enable and byflask seed permissions).- Add
@require_permission('printedparts.<x>')under@jwt_required()on every mutation: create/edit/delete/image = create/edit/delete; restock + adjust = restock. - Test with the
member_headersfixture (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.
- Backend, both UNdecorated:
GET /kiosk/item/<itemcode>(summary for a scanned bin code) andPOST /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. TouchKeypad.vue- net-new, dumb 3x4 grid emitting digit/clear/backspace.PartsKiosk.vue+ a top-level/parts-kioskroute registered beside/shopfloorinrouter/index.js(NO requiresAuth, outside AppLayout,meta.pluginso 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.- 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
frontend/src/views/print/PrintedPartsLabels.vue+ a public/print/printedparts-labelsroute 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).- 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-0042is comfortable.
- the code text at ~6.5pt. A QR at 0.4in is at the edge of scanner
tolerance; CODE128 of
- Roll stock = one label per page: a global (unscoped) print style with
@page { size: 1in 0.5in; margin: 0 }andpage-break-after: alwayson 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
- Three jwt-optional endpoints in the plugin blueprint, each honoring
?format=csv(local CSV helper -generate_csvis not on the contract surface):/reports/stock,/reports/consumption?days=N,/reports/by-person?days=N. - The stock report's
ledgerdeltacolumn = 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. get_reportson the plugin (endpoint-style entries, categories inventory/usage) - they merge intoGET /api/reportsand 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
- 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. - Fresh-database proof: scratch DATABASE_URL,
flask db upgrade+flask plugin install/enable printedparts+upgrade-all- green with zero manual SQL. - Full suite: backend pytest, vitest, frontend build, naming hook.
- Walk
PLUGIN-GUIDE.mdsection 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:
- Export
send_email/send_alertfromshopdb/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 pinscore_version >=0.12.0since the plugin now needs it. - Fire the alert inside
_ledger_writewhen 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. - Recipients: Setting
printedparts_alert_email(comma-separated), empty falls back to the site's alert_recipients viasend_alert. Seed the new setting in on_enable too (idempotent) so already-installed sites get it. - 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:
frontend/src/views/settings/PrintedPartsSettings.vue- load the four keys viasettingsApi.list({category: 'printedparts'}), save each withsettingsApi.update(key, value)(admin-gated server-side).- Route in the PLUGIN's router file with path
settings/printedparts+requiresAuth, requiresAdmin, pluginmeta - the router shell automatically nests anysettings/...path under the two-pane settings rail. get_settings_cardson 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).
- Setting
printedparts_alert_userids(comma-separated user ids), seeded beside the others. _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.- Settings page: checkbox picker over
usersApi.list()(the page is admin-only, matching the endpoint), saving joined ids. - 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.
- 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. - 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:
printeditemfiles(append-only revisions of the STL/3MF/gcode per item) arrives as0002_printeditemfileson top of the 0001 baseline - the ADR-008 payoff: the plugin evolves its own schema,flask plugin upgrade-allapplies 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).- Upload endpoint assigns revision = max+1, stores
printeditem-<id>-rev<n><ext>ininstance/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. - Role-based alert recipients:
Rolejoins the 0.13.0 surface beside User; Settingprintedparts_alert_roleids;_alert_recipientsfolds 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.
Stage 16a (extension) - view permission on the catalog
The catalog started with open reads (the product's jwt-optional list
convention). Field decision: browsing and managing the parts catalog is
staff-only, so the reads (list, detail, file listings) move behind
@jwt_required() + require_permission('printedparts.view'), the
/printedparts routes and the label print page gain requiresAuth, and
the view permission becomes meaningful in the role grid.
Deliberately still open: the kiosk endpoints (decision record), the image
serve and file download (fetched by <img> tags and anchor clicks, which
cannot carry a JWT header), and the reports (product-wide jwt-optional
convention). Grant printedparts.view to the roles that should see the
catalog - admins bypass as always.
Stage 16 (extension) - kiosk touch fixes from first hands-on use
First real touchscreen session found two problems worth their own stage:
- Focus steal: the page's tap-anywhere handler refocused the hidden wedge input, yanking focus out of the manual-entry field the moment it was tapped. Guard the handler - never reclaim focus from INPUT/SELECT/ TEXTAREA/BUTTON/A targets, only from dead space.
- No physical keyboard on a touchscreen: manual fallbacks now use the
TouchKeypad. Badge entry is digits (an SSO) so the keypad covers it;
item codes are letters+digits, solved server-side instead of building an
alphanumeric keyboard - the digits in a minted code ARE the row id, so
/kiosk/item/<digits>resolves bare digits by id. Bonus: labels printed under an older code prefix keep working after the prefix changes.
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 |