printedparts stage 10: closeout - lab guide rewritten from the real build
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s

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.
This commit is contained in:
cproudlock
2026-07-17 08:11:36 -04:00
parent b68e927ef6
commit fc0d48a6a7
2 changed files with 259 additions and 184 deletions

View File

@@ -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 + A hand-held, build-along tutorial: construct the 3D-printed-parts storefront +
kiosk plugin specified in `docs/proposals/printedparts-plugin.md`. Each kiosk plugin specified in `docs/proposals/printedparts-plugin.md`, stage by
milestone lists what to build, which existing code to imitate, and a stage, seeing each piece work before moving on. Written for someone building
checkpoint that proves you are done. Read the spec first, keep it open. their first plugin. The finished implementation lives on the
`feat/printedparts-plugin` branch with one commit per stage, tagged
Prerequisites: a working dev environment (README quick start), the three `lab-stage-01` .. `lab-stage-10` - when stuck, `git diff lab-stage-03
plugin docs skimmed once - `PLUGIN-QUICKSTART.md` (mechanics), lab-stage-04` shows exactly what a stage changes.
`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.
Know before you start Know before you start
- You are building a BUNDLED plugin inside this repo. Plugin frontend files - You are building a BUNDLED plugin inside this repo. Plugin frontend files
live in core (`frontend/src/...`), and three core files get small edits: live in core (`frontend/src/...`), and three core files get small edits:
`frontend/src/api/index.js` (api client), the router, and `frontend/src/api/index.js`, the sidebar icon map in `AppLayout.vue`, and
`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. That is `PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. Normal for
normal for all 12 bundled plugins - external-plugin UI packaging does not all bundled plugins - external-plugin UI packaging does not exist yet.
exist yet - so do not be confused when a "plugin" touches core. - Three deliberate divergences from the scaffold, each a teaching point:
- This plugin deliberately DIVERGES from the scaffold in three places, each (1) NO AssetType - these are quantity consumables, not ADR-001 assets
a teaching point you will hit in order: (1) it is NOT an asset type, so the (stage 1); (2) the migration is a REAL baseline that creates tables, not a
scaffold's AssetType seeding gets deleted (M1); (2) its migration is a REAL stamp-only anchor (stage 2); (3) the kiosk take endpoint is the product's
baseline that creates tables, not a stamp-only anchor (M1); (3) its kiosk first UNauthenticated write - read the decision record in the proposal
take endpoint is the product's first UNauthenticated write - read the before stage 7.
decision record in the proposal before building it (M4). - Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`);
- Instructor option: keep a solution branch with one commit per milestone DB names lowercase concatenated (`quantityonhand`); run
(tag `lab-m1`..`lab-m7`); a stuck learner can `git diff lab-m3 lab-m4` to `bash scripts/check-naming-and-style.sh` + the tests at every stage; one
see exactly what a milestone changes. 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 Read the proposal. Tour the two reference plugins you will imitate:
1. `flask plugin new printedparts` - scaffolds `plugins/printedparts/`. `plugins/usb/` (checkout ledger + badge contract) and
2. The scaffold assumes an Asset-extension plugin; ours is standalone. `plugins/measuringtools/` (post-cutover migration baseline, hooks).
In `plugin.py` strip the AssetType seeding from `on_install` (imitate See it work: run the app, log in.
`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 ## 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 install printedparts && flask plugin enable printedparts
flask plugin upgrade-all # applies your 0001 mysql> SHOW TABLES LIKE 'printed%'; -- both tables
mysql> SHOW TABLES LIKE 'printed%'; -- both tables mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline
mysql> SELECT * FROM alembic_version_printedparts; -- your revision id flask plugin upgrade-all -- printedparts: ok (idempotent)
pytest tests/ -q # nothing broken, contract tests green
``` ```
## 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 Commit + tag `lab-stage-02`.
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>-<id zero-padded to 4>`; 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`).
Tip - earlier visible win: as soon as the GET list endpoint works, jump ahead ## Stage 3 - read API + list page (the first visible win)
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.
Checkpoint 1. Real `api/routes.py`: `GET /items` (jwt-optional; pagination via
``` `get_pagination_params`/`paginate_query`, search across
pytest plugins/printedparts/tests/ -q # write tests as you go: code/name/description/bin, `?lowstock=true` filter) and
# - create mints 3DP-0001 style codes `GET /items/<id>` returning the item + its 25 most recent transactions.
# - restock/adjust move both ledger and cache atomically 2. `get_navigation_items` on the plugin: `{'name': '3D Parts', 'icon': 'box',
# - adjust below zero -> 400/422 'route': '/printedparts', 'position': 46}`.
# - permission gates: anonymous create -> 401, wrong-perm user -> 403 3. Frontend: paste the `printedpartsApi` client into
curl -s localhost:5001/api/printedparts/items | jq # anonymous list OK `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 Common error: nav icon missing. The sidebar maps icon NAMES to Lucide
1. The scaffold already dropped `PrintedpartsList/Detail/Form.vue` starters components in `AppLayout.vue` (`iconMap`); an unknown name renders nothing.
and a router file; rename/build them into `PrintedItemsList/Detail/Form` Add `'box': Box` to the map (and the import) or reuse an existing name.
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 Commit + tag `lab-stage-03`.
detail; restock from detail updates qty + shows in history; frontend build +
`npx vitest run` green; naming hook green.
## Milestone 4 - badge resolution + kiosk ## Stage 4 - catalog mutations + item photos + detail/form pages
Build 1. `POST /items` mints the itemcode AFTER `db.session.flush()` assigns the
1. Badge resolver in the plugin (`services/badges.py`): copy the USB contract id: `<prefix>-<id:04d>` with the prefix from Setting. `PUT /items/<id>`
- all-digits -> SSO; `^0(\d+)BZ$` case-insensitive -> PayNo; resolve updates catalog fields but REFUSES `quantityonhand` (ledger-managed).
display name via the employees plugin directory the way `DELETE` soft-retires. All `@jwt_required()` (permissions come in
`plugins/usb/api/selfhosted.py::_resolve_name` does (lazy import inside stage 6).
the function, degrade gracefully when the plugin is absent). Policy 2. Image trio copied from `shopdb/core/api/models.py`: POST/DELETE
setting `printedparts_unknown_badge` (deny -> 422). `/items/<id>/image` + public `GET /image/<filename>`, storing
2. Kiosk endpoints (UNauthenticated - the notifications/employees open-read `printeditem-<id>.<ext>` in `instance/printedpartsimages/`, wiping prior
precedent): `GET /kiosk/item/<itemcode>` and `POST /kiosk/take` extensions on replace, prefix-guarded delete.
{itemcode, badge, quantity}. Take: validate active item, 1 <= qty <= 3. `PrintedItemDetail.vue` on the unified detail skeleton (hero image, info
onhand, resolve badge, single-commit ledger row + decrement. Clear error list, transactions table); `PrintedItemForm.vue` create/edit + photo
strings - the kiosk displays them verbatim. upload on edit; extend the api client.
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 See it work: add a part with a photo in the UI; thumbnail on the list, hero
an itemcode -> item card; badge `0123456BZ` and plain SSO both resolve; take 3 on the detail; `PUT` with `quantityonhand` returns the ledger-managed error.
-> 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) Commit + tag `lab-stage-04`.
Build ## Stage 5 - the ledger: restock/adjust with badge attribution
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 1. `services/badges.py` - COPY the USB badge contract (do not import
PDF-zoomed) barcode scans back into the kiosk and pulls up the right item. `plugins.usb`; cross-plugin imports fail the contract test):
That round trip - label printed from the catalog, scanned at the kiosk, `^0(\d+)BZ$` PayNo wrap, all-digits SSO, name lookup via the employees
stock decremented with your name on it - is the demo moment; make it work plugin `DirectoryEmployee` (lazy import, graceful fallback), and the
end to end before polishing. `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`.
## 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 Common error: in tests, mutating rows through a nested `app.app_context()`
1. `get_reports()` -> stock, consumption (date range), by-person; endpoints does not reliably stick in the sqlite test env - stock the item through the
in the plugin blueprint, `@jwt_required(optional=True)`, `?format=csv` via real restock endpoint instead (also more honest).
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.
Checkpoint: reports appear on /reports grouped under the plugin, CSV Commit + tag `lab-stage-05`.
downloads; widget renders on the dashboard; reconcile column all-clear after
a kiosk session.
## Milestone 7 - lifecycle + closeout ## Stage 6 - RBAC
Build/verify 1. `get_permissions` on the plugin: view/create/edit/delete/restock, category
1. Disable/enable cycle: `flask plugin disable printedparts` - nav entry, `printedparts` (seeded automatically on install/enable and by
routes, reports, and grantable permissions all disappear; enable restores. `flask seed permissions`).
2. Fresh-database proof: point DATABASE_URL at a scratch DB, `flask db 2. Add `@require_permission('printedparts.<x>')` under `@jwt_required()` on
upgrade` + `flask plugin install/enable/upgrade-all` - everything works every mutation: create/edit/delete/image = create/edit/delete; restock +
with zero manual SQL. 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. 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 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 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` | | Barcode/QR rendering | JsBarcode usage in `AssetLabel.vue`, `qrLogo.js` |
| Kiosk route posture | `/shopfloor` in `frontend/src/router/index.js` | | Kiosk route posture | `/shopfloor` in `frontend/src/router/index.js` |
| List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` | | 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` | | Permissions declaration | `plugins/usb/plugin.py::get_permissions` |
| The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..10` |

View File

@@ -45,7 +45,12 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
# shape as the exempt collector blueprint; the geenforce admin endpoints in # shape as the exempt collector blueprint; the geenforce admin endpoints in
# the same blueprint are JWT+permission gated and ARE swept. # the same blueprint are JWT+permission gated and ARE swept.
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', 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) @pytest.fixture(autouse=True)