diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md new file mode 100644 index 0000000..d6f863f --- /dev/null +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -0,0 +1,209 @@ +# 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.sh` and the test suite at every + checkpoint. + +--- + +## Milestone 1 - skeleton, models, migration (backend exists) + +Build +1. `flask plugin new printedparts` - scaffolds `plugins/printedparts/`. +2. The scaffold assumes an Asset-extension plugin; ours is standalone. + In `plugin.py` strip the AssetType seeding from `on_install` (imitate + `plugins/knowledgebase/plugin.py` instead of the template). +3. Replace the scaffold model with the two spec tables: `PrintedItem`, + `PrintedItemTransaction` (`models/printeditem.py`). Use `BaseModel` + + `AuditMixin` from `shopdb.api`. Itemcode: leave generation to the API + layer (M2), column just `unique=True, index=True`. +4. Register both tables in `PLUGIN_TABLE_OWNERS` + (`shopdb/plugins/alembic_template.py`). +5. Create `plugins/printedparts/migrations/` with the 3-line `env.py` + + `script.py.mako` (copy from measuringtools) and a REAL baseline + `versions/0001_printedparts_baseline.py` - hand-written + `op.create_table(...)` for both tables (see + `plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py`). +6. 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 +1. `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 shared + `success_response`/`error_response` helpers from `shopdb.api`. +2. Itemcode on create: `-`; prefix from Setting + `printedparts_code_prefix` (read via `self.get_setting` or Setting model + through `shopdb.api`). Two-step: insert, flush to get the id, set code. +3. `get_permissions()` on the plugin class: view/create/edit/delete/restock + (tuples, category `printedparts` - copy shape from + `plugins/usb/plugin.py`). Gate mutations with `@jwt_required()` + + `@require_permission(...)`; reads are `@jwt_required(optional=True)`. +4. Restock + adjust endpoints: both write a LEDGER row and move + `quantityonhand` in the same commit. Adjust requires `reason`, rejects a + result below zero. Both record the operator: accept `badge` in the body + and resolve it (M4 extracts the resolver - for now digits=SSO is enough). +5. Seed the 3 settings in `on_install` (Setting.set pattern - see how other + plugins seed in `on_install`; category `printedparts`). + +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 +1. The scaffold already dropped `PrintedpartsList/Detail/Form.vue` starters + and a router file; rename/build them into `PrintedItemsList/Detail/Form` + per the spec. Master templates: `PrintersList.vue` (list), + `PrinterDetail.vue` (detail). Global CSS only; CSS variables for colors + (frontend/CLAUDE.md rules). +2. Register the API client in `frontend/src/api/index.js` (paste the + generated `frontend-api-snippet.js`, extend with restock/adjust/image + calls). +3. Low-stock highlighting on the list (`quantityonhand <= lowstockthreshold` + -> danger badge). Filters: search + low-stock-only checkbox. +4. Image upload: replicate the models-image trio - upload/serve/delete - + from `shopdb/core/api/models.py` INTO the plugin blueprint + (`instance/printedpartsimages/`, public GET serve, imageurl column, + prefix-guarded delete). Wire the Form upload + Detail hero image. +5. Nav: `get_navigation_items()` -> "3D Parts" (usb plugin shape). Router + meta: list/detail plugin-gated, new/edit `requiresAuth` (see + `frontend/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 +1. 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 way + `plugins/usb/api/selfhosted.py::_resolve_name` does (lazy import inside + the function, degrade gracefully when the plugin is absent). Policy + setting `printedparts_unknown_badge` (deny -> 422). +2. Kiosk endpoints (UNauthenticated - the notifications/employees open-read + precedent): `GET /kiosk/item/` and `POST /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. +3. Kiosk view `/parts-kiosk`: top-level route, NO requiresAuth, outside + AppLayout (register beside `/shopfloor` in `frontend/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. Build `TouchKeypad.vue` (3x4 grid of big buttons, emits digits/ + clear/backspace) - net-new, nothing to copy, keep it dumb. +4. 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 +1. Public print route `/print/printedparts-labels` + view (imitate + `USBLabelBatch.vue` - USB is the precedent for a plugin OWNING its label + page instead of joining TYPE_CONFIG). +2. 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. +3. Batch: multi-select items -> sequence of labels; plus a ULINE mini-grid + sheet fallback (mini72 pattern in `AssetLabelBatch.vue`). +4. 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 +1. `get_reports()` -> stock, consumption (date range), by-person; endpoints + in the plugin blueprint, `@jwt_required(optional=True)`, `?format=csv` via + the `generate_csv` helper pattern (`shopdb/core/api/reports.py` shows the + shape; a plugin report lives in the plugin and is merged into + `GET /api/reports` automatically when enabled). +2. 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. +3. `get_dashboard_widgets()` -> low-stock count. +4. 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 +1. Disable/enable cycle: `flask plugin disable printedparts` - nav entry, + routes, reports, and grantable permissions all disappear; enable restores. +2. 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. +3. Full suite: backend pytest, vitest, frontend build, naming hook. +4. End checklist from `PLUGIN-GUIDE.md` section 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` | diff --git a/docs/proposals/printedparts-plugin.md b/docs/proposals/printedparts-plugin.md new file mode 100644 index 0000000..267dbaf --- /dev/null +++ b/docs/proposals/printedparts-plugin.md @@ -0,0 +1,194 @@ +# Proposal: printedparts plugin (3D-printed parts storefront + kiosk) + +Status: PROPOSED (also serves as the reference design for the plugin-development +lab in `docs/PLUGIN-LAB-PRINTEDPARTS.md`) + +## 1. Problem + +The 3D-printer engineers stock bins of printed parts (fixtures, clips, covers, +spacers). Anyone on the floor can take parts, so stock silently runs out and +nobody knows who took what or how fast items burn down. They need: + +- a catalog ("storefront") of printable items: photo, description, quantity on + hand; +- a barcode label per item (1in x 0.5in) stuck on each bin; +- a touch-screen kiosk: scan the bin barcode, scan your badge, enter how many + you took, submit; +- restock and correction flows for the engineers; +- stock monitoring plus consumption metrics. + +## 2. Shape: standalone model plugin, NOT an asset type + +These are quantity-based consumables: one row represents a *kind* of part with +a count, not an individually tracked machine. ADR-001 assets are one-row-per- +physical-thing (a PC, a printer). So printedparts follows the +knowledgebase/usb shape - own tables, own blueprint, no AssetType row - and +does NOT join the asset-label TYPE_CONFIG; it ships its own print view the way +USB labels do. + +Item identity: `itemcode`, generated `3DP-` (prefix +configurable via setting `printedparts_code_prefix`). Short, CODE128-friendly, +human-readable. This is what the bin label encodes. + +## 3. Data model (2 tables, LOCKED naming, per-plugin Alembic) + +### printeditems +| column | type | notes | +|---|---|---| +| printeditemid | int PK autoincrement | | +| itemcode | varchar(20) unique, indexed | generated on create | +| itemname | varchar(120) NOT NULL | | +| itemdescription | varchar(500) | brief description | +| imageurl | varchar(255) | served upload, models.py pattern | +| quantityonhand | int NOT NULL default 0 | cached; ledger is truth | +| lowstockthreshold | int NOT NULL default 5 | per-item, seeds from setting | +| binlocation | varchar(100) | where the bin lives | +| printnotes | mediumtext | material, print time, slicer file path | +| isactive | tinyint(1) | soft retire | +| createddate / modifieddate | datetime | AuditMixin/BaseModel | + +### printeditemtransactions (the ledger - source of truth) +| column | type | notes | +|---|---|---| +| transactionid | int PK | | +| printeditemid | int FK -> printeditems CASCADE, indexed | | +| transactiontype | varchar(10) NOT NULL | take / restock / adjust | +| quantitychange | int NOT NULL | negative for take, signed for adjust | +| employeesso | varchar(20) NOT NULL, indexed | who (badge-resolved) | +| employeename | varchar(120) | resolved at write time (USB pattern) | +| reason | varchar(255) | required for adjust | +| transactiondate | datetime NOT NULL default naive-UTC, indexed | | + +Invariants: `quantityonhand` = sum of `quantitychange` (enforced by writing +both in one session/commit; an `adjust` can never drive it below 0 - reject). +Every write records WHO via badge scan; there is no anonymous mutation. + +Both tables registered in `PLUGIN_TABLE_OWNERS` +(`shopdb/plugins/alembic_template.py`); migration 0001 is a REAL baseline +(measuringtools pattern - hand-written `op.create_table`, no cross-schema FK +so `create_plugin_tables` would also work, but write the ops explicitly for +the exercise). + +## 4. Badge resolution (reuse the USB contract exactly) + +Same input shapes as `plugins/usb/api/routes.py`: +- all digits -> SSO; +- `0BZ` (case-insensitive) -> physical badge wrapping a PayNo; +- resolution to a display name via the employees plugin directory + (`DirectoryEmployee`, selfhosted mode) with graceful "" fallback. + +Extract-or-copy decision for the lab: copy the small `_PAYNO_BADGE` regex + +lookup into the plugin (contract-pure, no cross-plugin import of usb). +Resolution happens SERVER-side on the kiosk endpoint - the kiosk client never +supplies a name, only the raw badge string. + +Manifest `dependencies: ["employees"]` (name lookup). Badge that resolves to +no employee: configurable policy setting `printedparts_unknown_badge` +(`allow` = record SSO with empty name, `deny` = 422). Default deny. + +## 5. API surface (blueprint at /api/printedparts) + +Authenticated management (JWT + permission): +| route | method | permission | +|---|---|---| +| `/items` | GET list (search, paginate, lowstock filter) | open read (jwt optional) | +| `/items/` | GET detail + recent transactions | open read | +| `/items` | POST create (mints itemcode) | printedparts.create | +| `/items/` | PUT update | printedparts.edit | +| `/items/` | DELETE soft-retire | printedparts.delete | +| `/items//image` | POST/DELETE upload/remove | printedparts.edit | +| `/image/` | GET serve | public (models.py pattern) | +| `/items//restock` | POST {quantity, badge} | printedparts.restock | +| `/items//adjust` | POST {quantitychange, reason, badge} | printedparts.restock | +| `/items//transactions` | GET history, ?format=csv | open read | + +Kiosk (unauthenticated, notifications/employees open-endpoint precedent): +| route | method | body | +|---|---|---| +| `/kiosk/item/` | GET | item summary by scanned code | +| `/kiosk/take` | POST | {itemcode, badge, quantity} | + +`/kiosk/take` validation: item exists + active; quantity 1..quantityonhand +(clamp/reject configurable? no - reject with clear message, kiosk shows it); +badge resolves per policy. Writes ledger row (negative) + decrements cached +quantity in one commit. Rate of abuse is low (plant floor), but the endpoint +only ever DECREMENTS stock with a recorded badge - it cannot edit the catalog. + +Permissions declared via `get_permissions()`: printedparts.view/create/edit/ +delete/restock (category `printedparts`), seeded on install/enable. + +## 6. Frontend + +Management pages (scaffold output, standard layout, master templates +PrintersList/PrinterDetail): +- `PrintedItemsList.vue` - table: image thumb, code, name, qty (red badge when + <= threshold), bin; filters: search, low-stock-only; row click -> detail. +- `PrintedItemDetail.vue` - hero image + fields, transaction history table, + restock/adjust buttons (modal w/ quantity + badge + reason). +- `PrintedItemForm.vue` - create/edit incl. image upload, threshold, bin. +- Router file `router/routes/printedparts.js`, list/detail plugin-gated only, + new/edit + requiresAuth (ADR-009, usb.js precedent). +- Nav via `get_navigation_items()` -> "3D Parts". + +Kiosk (net-new, top-level route `/parts-kiosk`, NO requiresAuth, outside +AppLayout - shopfloor precedent): +- Full-screen, 3-step flow: (1) SCAN ITEM - a focused invisible input catches + the keyboard-wedge scan of the bin barcode, shows item card w/ photo + qty; + (2) SCAN BADGE - same wedge input pattern for the badge; (3) QUANTITY - big + touch keypad (0-9, clear, backspace - net-new component + `TouchKeypad.vue`) + TAKE button. Success screen w/ remaining count, auto + reset after a few seconds. All state client-side; one POST at the end. +- Scanner UX rule: keyboard-wedge scanners type the code + Enter. A hidden + always-focused input with @keydown.enter handles both scans; on-screen + prompt tells the user what to scan. Touch fallback: item search + manual + badge entry (small link, for damaged labels). + +Labels (own print view, USBLabelBatch precedent): +- `/print/printedparts-labels` public print route. +- NEW physical size: 1in x 0.5in stock -> `@page { size: 1in 0.5in; margin: 0 }` + one label per page (label printers feed roll stock; per-page = per-label). + Layout: CODE128 barcode (JsBarcode, ~0.9in x 0.28in, displayValue false) + + itemcode text under it (~7pt) + optional item name truncated. QR variant + offered but barcode is default at this size (a 0.4in QR is at the edge of + scanner tolerance; CODE128 of `3DP-0042` is comfortable). +- Batch mode: pick items -> one label per page sequence for roll printers; + also a ULINE mini-grid fallback for sheet printers (reuse mini72 pattern). + +## 7. Metrics / reports (get_reports hook) + +- `printedparts-stock` - current stock levels w/ threshold flags (CSV). +- `printedparts-consumption` - takes per item over a date range (CSV). +- `printedparts-by-person` - takes grouped by employee (CSV). +- Dashboard widget via `get_dashboard_widgets()`: low-stock item count. +- Nice-to-have later: burn-rate (avg takes/week per item + weeks-to-empty + projection) - plain SQL over the ledger, add once basics work. + +## 8. Settings (get_settings_cards, category printedparts) + +| key | default | purpose | +|---|---|---| +| printedparts_code_prefix | 3DP | itemcode prefix | +| printedparts_default_threshold | 5 | seed for new items | +| printedparts_unknown_badge | deny | kiosk policy for unresolvable badges | + +## 9. Manifest + +name printedparts, version 0.1.0, api_prefix /api/printedparts, +core_version ">=0.11.0,<1.0.0", dependencies ["employees"], +default_enabled false (site opts in - USB precedent). + +## 10. Explicitly out of scope (v1) + +- Reservations/approvals, per-item cost, print-queue integration, multi-bin + per item, email low-stock alerts (the reports + dashboard widget cover + monitoring; alerting can ride the existing report-email endpoint later). + +## 11. Risks / decisions taken + +- Cached quantity vs ledger drift: single-commit writes + a reconcile query in + the stock report (flags items where cache != ledger sum). +- Unauthenticated kiosk take: accepted (matches shopfloor kiosk posture); + it is decrement-only, fully attributed, and rate-limited by physics. +- 1x0.5in QR marginal: default to CODE128 barcode. +- Not an Asset: no floor-map plotting or warranty for items. If a site later + wants bins on the floor map, revisit via get_map_overlays (ADR-010).