The kiosk take endpoint is the product's first unauthenticated mutation; spell out the acceptance criteria (decrement-only, badge attributed, bounded, physically rate-limited) so future open-write endpoints meet the same bar. The dashboard-widget milestone is marked optional: get_dashboard_widgets predates the ADR-010 data-only renderers and needs a core component to render.
11 KiB
Plugin lab: build the printedparts plugin yourself
A guided, milestone-based exercise: build the 3D-printed-parts storefront +
kiosk plugin specified in docs/proposals/printedparts-plugin.md. Each
milestone lists what to build, which existing code to imitate, and a
checkpoint that proves you are done. Read the spec first, keep it open.
Prerequisites: a working dev environment (README quick start), the three
plugin docs skimmed once - PLUGIN-QUICKSTART.md (mechanics),
PLUGIN-GUIDE.md (the measuringtools walkthrough - your narrative reference),
PLUGIN-HOOKS.md (hook reference). Naming rules: CONTRIBUTING.md - the
pre-commit hook enforces them, read it before naming anything.
Ground rules
- Import core ONLY via
shopdb.api(+shopdb.plugins.base). The contract test fails your build otherwise. - DB columns: lowercase concatenated (
quantityonhand, not quantity_on_hand). - Run
bash scripts/check-naming-and-style.shand the test suite at every checkpoint.
Milestone 1 - skeleton, models, migration (backend exists)
Build
flask plugin new printedparts- scaffoldsplugins/printedparts/.- The scaffold assumes an Asset-extension plugin; ours is standalone.
In
plugin.pystrip the AssetType seeding fromon_install(imitateplugins/knowledgebase/plugin.pyinstead of the template). - Replace the scaffold model with the two spec tables:
PrintedItem,PrintedItemTransaction(models/printeditem.py). UseBaseModel+AuditMixinfromshopdb.api. Itemcode: leave generation to the API layer (M2), column justunique=True, index=True. - Register both tables in
PLUGIN_TABLE_OWNERS(shopdb/plugins/alembic_template.py). - Create
plugins/printedparts/migrations/with the 3-lineenv.py+script.py.mako(copy from measuringtools) and a REAL baselineversions/0001_printedparts_baseline.py- hand-writtenop.create_table(...)for both tables (seeplugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py). - Manifest: api_prefix
/api/printedparts,dependencies: ["employees"],default_enabled: false.
Checkpoint
flask plugin install printedparts && flask plugin enable printedparts
flask plugin upgrade-all # applies your 0001
mysql> SHOW TABLES LIKE 'printed%'; -- both tables
mysql> SELECT * FROM alembic_version_printedparts; -- your revision id
pytest tests/ -q # nothing broken, contract tests green
Milestone 2 - CRUD API + permissions + itemcode
Build
api/routes.py: list/detail/create/update/soft-delete per the spec table. Imitate a clean plugin blueprint (plugins/measuringtools/api/routes.py) for pagination (perpage,dir), search, and the sharedsuccess_response/error_responsehelpers fromshopdb.api.- Itemcode on create:
<prefix>-<id zero-padded to 4>; prefix from Settingprintedparts_code_prefix(read viaself.get_settingor Setting model throughshopdb.api). Two-step: insert, flush to get the id, set code. get_permissions()on the plugin class: view/create/edit/delete/restock (tuples, categoryprintedparts- copy shape fromplugins/usb/plugin.py). Gate mutations with@jwt_required()+@require_permission(...); reads are@jwt_required(optional=True).- Restock + adjust endpoints: both write a LEDGER row and move
quantityonhandin the same commit. Adjust requiresreason, rejects a result below zero. Both record the operator: acceptbadgein the body and resolve it (M4 extracts the resolver - for now digits=SSO is enough). - Seed the 3 settings in
on_install(Setting.set pattern - see how other plugins seed inon_install; categoryprintedparts).
Checkpoint
pytest plugins/printedparts/tests/ -q # write tests as you go:
# - create mints 3DP-0001 style codes
# - restock/adjust move both ledger and cache atomically
# - adjust below zero -> 400/422
# - permission gates: anonymous create -> 401, wrong-perm user -> 403
curl -s localhost:5001/api/printedparts/items | jq # anonymous list OK
Milestone 3 - management frontend + images
Build
- The scaffold already dropped
PrintedpartsList/Detail/Form.vuestarters and a router file; rename/build them intoPrintedItemsList/Detail/Formper the spec. Master templates:PrintersList.vue(list),PrinterDetail.vue(detail). Global CSS only; CSS variables for colors (frontend/CLAUDE.md rules). - Register the API client in
frontend/src/api/index.js(paste the generatedfrontend-api-snippet.js, extend with restock/adjust/image calls). - Low-stock highlighting on the list (
quantityonhand <= lowstockthreshold-> danger badge). Filters: search + low-stock-only checkbox. - Image upload: replicate the models-image trio - upload/serve/delete -
from
shopdb/core/api/models.pyINTO the plugin blueprint (instance/printedpartsimages/, public GET serve, imageurl column, prefix-guarded delete). Wire the Form upload + Detail hero image. - Nav:
get_navigation_items()-> "3D Parts" (usb plugin shape). Router meta: list/detail plugin-gated, new/editrequiresAuth(seefrontend/src/router/routes/usb.js).
Checkpoint: create an item with a photo in the UI; thumbnail on list, hero on
detail; restock from detail updates qty + shows in history; frontend build +
npx vitest run green; naming hook green.
Milestone 4 - badge resolution + kiosk
Build
- Badge resolver in the plugin (
services/badges.py): copy the USB contract- all-digits -> SSO;
^0(\d+)BZ$case-insensitive -> PayNo; resolve display name via the employees plugin directory the wayplugins/usb/api/selfhosted.py::_resolve_namedoes (lazy import inside the function, degrade gracefully when the plugin is absent). Policy settingprintedparts_unknown_badge(deny -> 422).
- all-digits -> SSO;
- Kiosk endpoints (UNauthenticated - the notifications/employees open-read
precedent):
GET /kiosk/item/<itemcode>andPOST /kiosk/take{itemcode, badge, quantity}. Take: validate active item, 1 <= qty <= onhand, resolve badge, single-commit ledger row + decrement. Clear error strings - the kiosk displays them verbatim. - Kiosk view
/parts-kiosk: top-level route, NO requiresAuth, outside AppLayout (register beside/shopfloorinfrontend/src/router/index.js). Three-step flow per the spec. The scanner is a keyboard wedge: hidden always-focused input, submit on Enter, route the scan to whichever step is active. BuildTouchKeypad.vue(3x4 grid of big buttons, emits digits/ clear/backspace) - net-new, nothing to copy, keep it dumb. - Manual fallback path (typed item search + badge entry) behind a small "no scanner?" link.
Checkpoint: full kiosk walkthrough on a touchscreen (or browser): scan/type
an itemcode -> item card; badge 0123456BZ and plain SSO both resolve; take 3
-> success screen, qty down 3, ledger row has your name; taking more than
onhand -> friendly error; unknown badge -> denied message. Backend tests for
the resolver shapes + take validation.
Milestone 5 - labels (1in x 0.5in)
Build
- Public print route
/print/printedparts-labels+ view (imitateUSBLabelBatch.vue- USB is the precedent for a plugin OWNING its label page instead of joining TYPE_CONFIG). - New stock size:
@page { size: 1in 0.5in; margin: 0 }, one label per page (roll-fed label printers treat each page as one label). Layout: CODE128 via JsBarcode (~0.9in wide, displayValue false), itemcode text ~7pt under it, optional truncated name. Offer QR as a variant but default barcode. - Batch: multi-select items -> sequence of labels; plus a ULINE mini-grid
sheet fallback (mini72 pattern in
AssetLabelBatch.vue). - Print buttons on Detail (single) and List (batch selected).
Checkpoint: print preview shows one 1x0.5 label per page; a printed (or PDF-zoomed) barcode scans back into the kiosk and pulls up the right item. That round trip - label printed from the catalog, scanned at the kiosk, stock decremented with your name on it - is the demo moment; make it work end to end before polishing.
Milestone 6 - metrics, reports, widget
Build
get_reports()-> stock, consumption (date range), by-person; endpoints in the plugin blueprint,@jwt_required(optional=True),?format=csvvia thegenerate_csvhelper pattern (shopdb/core/api/reports.pyshows the shape; a plugin report lives in the plugin and is merged intoGET /api/reportsautomatically when enabled).- Stock report includes the reconcile check: flag rows where cached
quantityonhand!= SUM(ledger). Should always be empty; if not, you have a non-atomic write path - find it. - OPTIONAL/deferred:
get_dashboard_widgets()-> low-stock count. Caveat: this hook predates the ADR-010 data-only renderers - the widget names a frontend component that must already exist in core, so a plugin widget only renders if you also add that component. Reports are the primary monitoring surface; skip the widget unless you want the extra credit. - Nice-to-have if time: burn rate (avg weekly takes over trailing 4 weeks + weeks-to-empty). Plain SQL over the ledger.
Checkpoint: reports appear on /reports grouped under the plugin, CSV downloads; widget renders on the dashboard; reconcile column all-clear after a kiosk session.
Milestone 7 - lifecycle + closeout
Build/verify
- Disable/enable cycle:
flask plugin disable printedparts- nav entry, routes, reports, and grantable permissions all disappear; enable restores. - Fresh-database proof: point DATABASE_URL at a scratch DB,
flask db upgrade+flask plugin install/enable/upgrade-all- everything works with zero manual SQL. - Full suite: backend pytest, vitest, frontend build, naming hook.
- End checklist from
PLUGIN-GUIDE.mdsection 12.
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.
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/ report + shopdb/core/api/reports.py |
| Permissions declaration | plugins/usb/plugin.py::get_permissions |