Contract 0.12.0: send_email/send_alert join the plugin surface (the mailer was core-only), PLUGIN-HOOKS and status docs updated, manifest pins the new floor. The alert fires inside _ledger_write only when a decrement CROSSES the item's threshold - one alert per depletion, rearmed by restocking above - and is best-effort after the commit so mail trouble can never fail a take. Recipients come from printedparts_alert_email, falling back to the site alert_recipients. on_enable re-seeds settings idempotently so existing installs pick up new keys. Crossing/rearm semantics proven by test.
16 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).
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 |