|
|
|
|
@@ -1,222 +1,311 @@
|
|
|
|
|
# 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>-<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`).
|
|
|
|
|
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/<id>` 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/<itemcode>` 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: `<prefix>-<id:04d>` with the prefix from Setting. `PUT /items/<id>`
|
|
|
|
|
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/<id>/image` + public `GET /image/<filename>`, storing
|
|
|
|
|
`printeditem-<id>.<ext>` 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/<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
|
|
|
|
|
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.<x>')` 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/<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.
|
|
|
|
|
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
|
|
|
|
|
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:
|
|
|
|
|
1. Export `send_email`/`send_alert` from `shopdb/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 pins `core_version >=0.12.0` since the plugin now needs it.
|
|
|
|
|
2. Fire the alert inside `_ledger_write` when 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.
|
|
|
|
|
3. Recipients: Setting `printedparts_alert_email` (comma-separated), empty
|
|
|
|
|
falls back to the site's alert_recipients via `send_alert`. Seed the new
|
|
|
|
|
setting in on_enable too (idempotent) so already-installed sites get it.
|
|
|
|
|
4. 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)
|
|
|
|
|
@@ -233,5 +322,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` |
|
|
|
|
|
|