From fc0d48a6a73b036d6b4532b7d4ef91a4956f920c Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 08:11:36 -0400 Subject: [PATCH] printedparts stage 10: closeout - lab guide rewritten from the real build The lab is now a build-along mirroring what actually happened: ten stages, each with the goal, the divergences, a see-it-work check, and the errors genuinely hit while building (empty Migration error from a broken model import, the migration-guard KeyError, the missing Lucide icon, nested-app-context test writes, Decimal sums, and the authz sweep catching the deliberately open kiosk take). That last one gets its explicit EXEMPT_ENDPOINTS entry with a pointer to the decision record - the net stays, the exception is reviewable. Full suite: 993 backend tests, 49 vitest, frontend build, naming hook, all green. --- docs/PLUGIN-LAB-PRINTEDPARTS.md | 436 ++++++++++++++++++-------------- tests/test_core/test_authz.py | 7 +- 2 files changed, 259 insertions(+), 184 deletions(-) diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index 00e6589..8a0ac37 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -1,217 +1,286 @@ -# Plugin lab: build the printedparts plugin yourself +# Plugin lab: build the printedparts plugin -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. -- Commit once per milestone. Working on a branch is fine; so is a fork. +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` (api client), the router, and - `PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. That is - normal for all 12 bundled plugins - external-plugin UI packaging does not - exist yet - so do not be confused when a "plugin" touches core. -- This plugin deliberately DIVERGES from the scaffold in three places, each - a teaching point you will hit in order: (1) it is NOT an asset type, so the - scaffold's AssetType seeding gets deleted (M1); (2) its migration is a REAL - baseline that creates tables, not a stamp-only anchor (M1); (3) its kiosk - take endpoint is the product's first UNauthenticated write - read the - decision record in the proposal before building it (M4). -- Instructor option: keep a solution branch with one commit per milestone - (tag `lab-m1`..`lab-m7`); a stuck learner can `git diff lab-m3 lab-m4` to - see exactly what a milestone changes. + `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. --- -## Milestone 1 - skeleton, models, migration (backend exists) +## Stage 0 - orientation (no code) -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`. +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. -Checkpoint +## 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 -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 +mysql> SHOW TABLES LIKE 'printed%'; -- both tables +mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline +flask plugin upgrade-all -- printedparts: ok (idempotent) ``` -## Milestone 2 - CRUD API + permissions + itemcode +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. -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`). +Commit + tag `lab-stage-02`. -Tip - earlier visible win: as soon as the GET list endpoint works, jump ahead -and wire just the api client + router entry + a bare `PrintedItemsList.vue` -(first two steps of M3), seed two rows by hand, and look at your parts in the -browser. Everything before that moment is invisible; seeing the table makes -the rest of the lab concrete. Then come back and finish the mutations here. +## Stage 3 - read API + list page (the first visible win) -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 -``` +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/` 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. -## Milestone 3 - management frontend + images +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. -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`). +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. -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. +Commit + tag `lab-stage-03`. -## Milestone 4 - badge resolution + kiosk +## Stage 4 - catalog mutations + item photos + detail/form pages -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. +1. `POST /items` mints the itemcode AFTER `db.session.flush()` assigns the + id: `-` with the prefix from Setting. `PUT /items/` + 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//image` + public `GET /image/`, storing + `printeditem-.` 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. -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. +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. -## Milestone 5 - labels (1in x 0.5in) +Commit + tag `lab-stage-04`. -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). +## Stage 5 - the ledger: restock/adjust with badge attribution -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. +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//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`. -## Milestone 6 - metrics, reports, widget +See it work: restock from the detail page with your SSO - quantity moves AND +a named transaction row appears. -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. 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. -4. Nice-to-have if time: burn rate (avg weekly takes over trailing 4 weeks + - weeks-to-empty). Plain SQL over the ledger. +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). -Checkpoint: reports appear on /reports grouped under the plugin, CSV -downloads; widget renders on the dashboard; reconcile column all-clear after -a kiosk session. +Commit + tag `lab-stage-05`. -## Milestone 7 - lifecycle + closeout +## Stage 6 - RBAC -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. +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.')` 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/` (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=` + 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. End checklist from `PLUGIN-GUIDE.md` section 12. +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 @@ -233,5 +302,6 @@ anything. | 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` | +| 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` | diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py index 75d4cd8..04a8fee 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -45,7 +45,12 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'} # shape as the exempt collector blueprint; the geenforce admin endpoints in # the same blueprint are JWT+permission gated and ARE swept. EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', - 'geenforce.post_report'} + 'geenforce.post_report', + # Deliberately open kiosk write: decrement-only, + # badge-attributed server-side. Decision record in + # docs/proposals/printedparts-plugin.md; justification in + # the plugin README. + 'printedparts.kiosk_take'} @pytest.fixture(autouse=True)