Files
shopdb-flask/docs/PLUGIN-LAB-PRINTEDPARTS.md
cproudlock bc9159742c
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
printedparts lab: post-stage polish addendum and closing lesson
2026-07-17 13:24:11 -04:00

23 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 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.

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 16b (extension) - badge resolution honors the directory mode

Prod runs the employee directory in EXTERNAL mode (live HR database), where the self-hosted directoryemployees table is empty - so the original resolver's every lookup missed and the deny policy blocked the kiosk. The resolver now branches on the same employee_directory_mode setting the usb/employees plugins use: selfhosted reads DirectoryEmployee by SSO; external queries the HR directory via employee_connection() - and resolves PayNo badges by their actual PayNo column, recovering the real SSO, which the self-hosted table cannot do. Dual-backend lesson in miniature: a plugin that resolves people must honor the site's directory mode.

Stage 16 (extension) - kiosk touch fixes from first hands-on use

First real touchscreen session found two problems worth their own stage:

  1. 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.
  2. 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.

Post-stage polish (untagged commits on the branch)

Small refinements that did not warrant stages but complete the picture:

  • The kiosk launches from the sidebar's hardcoded "Displays" section (beside Shopfloor Dashboard / TV Slideshow), plugin-gated and opening a new tab - kiosk-style pages belong there, not in the plugin's Information nav.
  • The touch keypad was restyled into a terminal-style card panel (boxed entry display, fixed 3-column grid, press feedback) after the first hands-on review called the initial version ugly. Looks are requirements on a kiosk.

The overall arc is the lab's closing lesson: the spec carried the build to stage 10; every stage after came from deployment and real users. Plugins are finished by the floor, not by the spec.

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