6 Commits

Author SHA1 Message Date
cproudlock
427eb0de8c printedparts stage 12: admin settings page + settings-rail card
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
PrintedPartsSettings edits the four plugin settings (code prefix,
default threshold, kiosk badge policy, alert recipients) through the
core settings API; the route rides the plugin's router file and the
settings shell nests it into the rail; get_settings_cards contributes
the catalog card while the plugin is enabled.
2026-07-17 08:25:33 -04:00
cproudlock
df918ed38f printedparts stage 11: low-stock email alerts on threshold crossing
Some checks failed
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Contract 0.12.0: send_email/send_alert join the plugin surface (the
mailer was core-only), PLUGIN-HOOKS and status docs updated, manifest
pins the new floor. The alert fires inside _ledger_write only when a
decrement CROSSES the item's threshold - one alert per depletion,
rearmed by restocking above - and is best-effort after the commit so
mail trouble can never fail a take. Recipients come from
printedparts_alert_email, falling back to the site alert_recipients.
on_enable re-seeds settings idempotently so existing installs pick up
new keys. Crossing/rearm semantics proven by test.
2026-07-17 08:15:50 -04:00
cproudlock
fc0d48a6a7 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.
2026-07-17 08:11:36 -04:00
cproudlock
b68e927ef6 printedparts stage 9: reports - stock w/ reconcile, consumption, by-person
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Three jwt-optional endpoints with ?format=csv, merged into the reports
hub via get_reports while the plugin is enabled. The stock report's
ledgerdelta column is the reconcile check: 0 for every item whose
stock moved through the ledger, nonzero for anything that bypassed it
(the hand-seeded dev rows demonstrate the catch). MySQL SUM returns
Decimal - cast to int or the delta serializes as a string.
2026-07-17 08:04:09 -04:00
cproudlock
6439d1ccd9 printedparts stage 8: 1x0.5in bin labels
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
New public print view at /print/printedparts-labels following the
plugin-owned USB label precedent: multi-select with per-item copies,
CODE128 of the item code via JsBarcode (a QR at this size is at the
edge of scanner tolerance), one label per page on 1in x 0.5in roll
stock via a new @page size. The Detail page's Bin Label button
preselects its item through ?item=<id>; the list header gains a batch
Print Labels button.
2026-07-17 08:00:35 -04:00
cproudlock
6ed3da1b64 printedparts stage 7: the kiosk - scan bin, scan badge, keypad, take
Some checks failed
CI / backend (push) Failing after 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Two open endpoints: an item lookup by scanned code and the take POST -
the product's first unauthenticated write, held to the decision
record's bar (decrement-only, badge-attributed server-side, bounded,
physically rate-limited; justification in the plugin README). The
/parts-kiosk route is a full-screen no-auth view beside /shopfloor: a
hidden always-focused input consumes keyboard-wedge scans for
whichever step is active, TouchKeypad (net-new 3x4 grid) takes the
quantity, and a success screen resets after a few seconds. Manual
type-in fallbacks cover damaged labels. Kiosk test proves open access,
the over-take guard, the badge policy, and cache==ledger afterward.
2026-07-17 07:49:13 -04:00
21 changed files with 1268 additions and 191 deletions

View File

@@ -43,7 +43,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
### Active state
- 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
- `__contract_version__` at 0.11.0 (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- `__contract_version__` at 0.12.0 (0.12.0 adds the mailer to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
- Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 15 stages, validated end-to-end including on a Windows + MySQL 8 VM).

View File

@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`:
```python
__contract_version__ = '0.11.0'
__contract_version__ = '0.12.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -479,6 +479,9 @@ What `shopdb.api` exposes:
- Import mode: `apply_import_timestamps`, `import_mode_active`,
`parse_import_datetime`
- Legacy employee directory: `employee_connection`
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
email is unconfigured; send_alert targets the site's alert_recipients
```python
from shopdb.api import db, Asset, AssetType, success_response, paginate_query

View File

@@ -1,222 +1,325 @@
# 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`).
## Stage 12 (extension) - the admin settings page
A get_settings_cards card needs a PAGE to link, which is why stage 9 deferred
it. The page is ordinary:
1. `frontend/src/views/settings/PrintedPartsSettings.vue` - load the four
keys via `settingsApi.list({category: 'printedparts'})`, save each with
`settingsApi.update(key, value)` (admin-gated server-side).
2. Route in the PLUGIN's router file with path `settings/printedparts` +
`requiresAuth, requiresAdmin, plugin` meta - the router shell
automatically nests any `settings/...` path under the two-pane settings
rail.
3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` -
the card appears in the rail's catalog while the plugin is enabled.
---
## Where each pattern lives (cheat sheet)
@@ -233,5 +336,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` |

View File

@@ -1,6 +1,6 @@
# Roadmap
shopdb-flask is at `__contract_version__ = '0.11.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
shopdb-flask is at `__contract_version__ = '0.12.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
## Phase status

View File

@@ -1159,5 +1159,11 @@ export const printedpartsApi = {
},
adjust(printeditemid, data) {
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
},
kioskItem(itemcode) {
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
},
kioskTake(data) {
return api.post('/printedparts/kiosk/take', data)
}
}

View File

@@ -0,0 +1,38 @@
<template>
<div class="touch-keypad">
<button v-for="digit in digits" :key="digit" type="button"
class="keypad-button" @click="$emit('digit', digit)">
{{ digit }}
</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('clear')">C</button>
<button type="button" class="keypad-button" @click="$emit('digit', '0')">0</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('backspace')">&lt;</button>
</div>
</template>
<script setup>
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
defineEmits(['digit', 'clear', 'backspace'])
</script>
<style scoped>
.touch-keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
max-width: 20rem;
}
.keypad-button {
font-size: 1.8rem;
padding: 1rem 0;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
cursor: pointer;
}
.keypad-button:active { background: var(--primary); color: #fff; }
.keypad-muted { color: var(--text-light); }
</style>

View File

@@ -65,6 +65,14 @@ const routes = [
name: 'shopfloor',
component: () => import('../views/ShopfloorDashboard.vue')
},
{
// Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad.
// Open on purpose - see the decision record in the printedparts proposal.
path: '/parts-kiosk',
name: 'parts-kiosk',
component: () => import('../views/printedparts/PartsKiosk.vue'),
meta: { plugin: 'printedparts' }
},
{
path: '/tv',
name: 'tv',
@@ -109,6 +117,12 @@ const routes = [
component: () => import('../views/print/USBLabelBatch.vue'),
meta: { plugin: 'usb' }
},
{
path: '/print/printedparts-labels',
name: 'print-printedparts-labels',
component: () => import('../views/print/PrintedPartsLabels.vue'),
meta: { plugin: 'printedparts' }
},
{
path: '/',
component: AppLayout,

View File

@@ -31,5 +31,11 @@ export default [
name: 'printedparts-edit',
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'settings/printedparts',
name: 'settings-printedparts',
component: () => import('../../views/settings/PrintedPartsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' }
}
]

View File

@@ -0,0 +1,207 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Print 3D Parts Bin Labels (1in x 0.5in)</h3>
<p>
Each label is one page on 1in x 0.5in roll stock: CODE128 barcode of
the item code, scannable at the parts kiosk.
</p>
<div v-if="loading" class="loading-msg">Loading parts...</div>
<div v-else-if="items.length === 0" class="loading-msg">No parts found</div>
<div v-else class="parts-grid">
<div
v-for="item in items"
:key="item.printeditemid"
class="part-item"
:class="{ selected: isSelected(item) }"
@click="toggleItem(item)"
>
<input type="checkbox" :checked="isSelected(item)" @click.stop />
<label>
<strong><code>{{ item.itemcode }}</code></strong>
<div class="alias">{{ item.itemname }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedItems.length }}</span> labels
<label class="copies-label">Copies each:
<input v-model.number="copies" type="number" min="1" max="10" />
</label>
</div>
<button class="print-btn" :disabled="selectedItems.length === 0"
@click="print">Print Labels</button>
<button class="clear-btn" @click="selectedItems = []">Clear All</button>
<button class="select-all-btn" @click="selectedItems = [...items]">Select All</button>
</div>
</div>
<div class="labels-container">
<div v-for="(label, index) in printLabels" :key="index" class="bin-label">
<svg :ref="element => setBarcodeElement(element, index)" class="bin-barcode"></svg>
<div class="bin-code">{{ label.itemcode }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import JsBarcode from 'jsbarcode'
import { printedpartsApi } from '../../api'
const items = ref([])
const selectedItems = ref([])
const copies = ref(1)
const loading = ref(true)
const barcodeElements = ref({})
onMounted(async () => {
try {
const response = await printedpartsApi.list({ perpage: 500 })
items.value = response.data.data || []
// ?item=<id> preselects one part (the Detail-page print button)
const preselect = new URLSearchParams(window.location.search).get('item')
if (preselect) {
const match = items.value.find(
candidate => String(candidate.printeditemid) === preselect)
if (match) selectedItems.value = [match]
}
} catch (error) {
console.error('Error loading parts:', error)
} finally {
loading.value = false
}
})
const printLabels = computed(() => {
const labels = []
for (const item of selectedItems.value) {
for (let copy = 0; copy < Math.max(1, copies.value); copy++) {
labels.push(item)
}
}
return labels
})
function isSelected(item) {
return selectedItems.value.some(
candidate => candidate.printeditemid === item.printeditemid)
}
function toggleItem(item) {
if (isSelected(item)) {
selectedItems.value = selectedItems.value.filter(
candidate => candidate.printeditemid !== item.printeditemid)
} else {
selectedItems.value = [...selectedItems.value, item]
}
}
function setBarcodeElement(element, index) {
if (element) barcodeElements.value[index] = element
}
watch(printLabels, async labels => {
await nextTick()
labels.forEach((label, index) => {
const element = barcodeElements.value[index]
if (element) {
// CODE128 of the short item code fits 1x0.5in with comfortable
// scanner tolerance; a QR at this size would be marginal.
JsBarcode(element, label.itemcode, {
format: 'CODE128',
displayValue: false,
width: 1.4,
height: 26,
margin: 0
})
}
})
}, { deep: true })
function print() {
window.print()
}
</script>
<style scoped>
.controls {
max-width: 46rem;
margin: 1rem auto;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.parts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.5rem;
max-height: 20rem;
overflow-y: auto;
margin: 1rem 0;
}
.part-item {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.35rem;
cursor: pointer;
}
.part-item.selected { border-color: var(--primary); }
.alias { color: var(--text-light); font-size: 0.85rem; }
.selected-count { margin: 0.75rem 0; }
.copies-label { margin-left: 1.25rem; }
.copies-label input { width: 4rem; padding: 0.25rem; }
.print-btn, .clear-btn, .select-all-btn {
margin-right: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
}
.loading-msg { color: var(--text-light); padding: 1rem; }
/* screen preview of the labels */
.labels-container { display: flex; flex-wrap: wrap; gap: 0.4rem; padding: 1rem; }
.bin-label {
width: 1in;
height: 0.5in;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
background: #fff;
outline: 1px dashed #bbb;
}
.bin-barcode { width: 0.92in; height: 0.3in; }
.bin-code {
font-size: 6.5pt;
font-family: monospace;
color: #000;
line-height: 1;
}
/* 1in x 0.5in roll stock: one label per page */
@media print {
.no-print { display: none; }
.labels-container { display: block; padding: 0; gap: 0; }
.bin-label {
outline: none;
page-break-after: always;
break-after: page;
}
}
</style>
<style>
@media print {
@page { size: 1in 0.5in; margin: 0; }
body { margin: 0; }
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<div class="parts-kiosk" @click="focusWedge">
<!-- keyboard-wedge scanners type the code + Enter into this hidden,
always-focused input; whichever step is active consumes the scan -->
<input ref="wedgeInput" v-model="wedgeBuffer" class="wedge-input"
autocomplete="off" @keydown.enter.prevent="onWedgeEnter" />
<header class="kiosk-header">
<h1>3D Printed Parts</h1>
<button v-if="step !== 'item'" class="btn btn-secondary" @click="reset">
Start over
</button>
</header>
<div v-if="error" class="kiosk-error">{{ error }}</div>
<!-- step 1: scan the bin -->
<section v-if="step === 'item'" class="kiosk-step">
<p class="kiosk-prompt">Scan the barcode on the bin</p>
<p class="kiosk-hint">
No scanner?
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the code</a>
</p>
<div v-if="manualEntry" class="manual-row">
<input v-model="manualCode" class="form-control" placeholder="3DP-0001"
@keydown.enter="lookupItem(manualCode)" />
<button class="btn btn-primary" @click="lookupItem(manualCode)">Go</button>
</div>
</section>
<!-- step 2: badge -->
<section v-else-if="step === 'badge'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<p class="kiosk-hint">{{ item.itemcode }} - {{ item.quantityonhand }} on hand</p>
</div>
</div>
<p class="kiosk-prompt">Scan your badge</p>
<div class="manual-row">
<input v-model="manualBadge" class="form-control" placeholder="or type your SSO"
@keydown.enter="acceptBadge(manualBadge)" />
<button class="btn btn-primary" @click="acceptBadge(manualBadge)">Next</button>
</div>
</section>
<!-- step 3: quantity -->
<section v-else-if="step === 'quantity'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<p class="kiosk-hint">{{ item.quantityonhand }} on hand</p>
</div>
</div>
<p class="kiosk-prompt">How many are you taking?</p>
<div class="quantity-display">{{ quantity || '0' }}</div>
<TouchKeypad @digit="quantity += $event"
@clear="quantity = ''"
@backspace="quantity = quantity.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
@click="submitTake">
{{ submitting ? 'Working...' : 'TAKE' }}
</button>
</section>
<!-- done -->
<section v-else-if="step === 'done'" class="kiosk-step">
<p class="kiosk-success">Done - {{ doneMessage }}</p>
<p class="kiosk-hint">Starting over in a few seconds...</p>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
import TouchKeypad from '../../components/TouchKeypad.vue'
const step = ref('item')
const item = ref(null)
const badge = ref('')
const quantity = ref('')
const error = ref('')
const doneMessage = ref('')
const submitting = ref(false)
const manualEntry = ref(false)
const manualCode = ref('')
const manualBadge = ref('')
const wedgeInput = ref(null)
const wedgeBuffer = ref('')
let resetTimer = null
onMounted(focusWedge)
onBeforeUnmount(() => clearTimeout(resetTimer))
function focusWedge() {
wedgeInput.value?.focus()
}
function onWedgeEnter() {
const scanned = wedgeBuffer.value.trim()
wedgeBuffer.value = ''
if (!scanned) return
if (step.value === 'item') lookupItem(scanned)
else if (step.value === 'badge') acceptBadge(scanned)
}
async function lookupItem(itemcode) {
error.value = ''
if (!itemcode) return
try {
const response = await printedpartsApi.kioskItem(itemcode.trim())
item.value = response.data.data
step.value = 'badge'
manualEntry.value = false
manualCode.value = ''
} catch (lookupError) {
error.value = lookupError.response?.data?.data?.error?.message ||
'No part matches that barcode'
}
focusWedge()
}
function acceptBadge(value) {
error.value = ''
const scanned = (value || '').trim()
if (!scanned) return
badge.value = scanned
manualBadge.value = ''
step.value = 'quantity'
focusWedge()
}
async function submitTake() {
submitting.value = true
error.value = ''
try {
const response = await printedpartsApi.kioskTake({
itemcode: item.value.itemcode,
badge: badge.value,
quantity: parseInt(quantity.value, 10)
})
doneMessage.value = response.data.message
step.value = 'done'
resetTimer = setTimeout(reset, 4000)
} catch (takeError) {
error.value = takeError.response?.data?.data?.error?.message ||
'Could not complete - see the parts team'
if (takeError.response?.status === 422) {
// badge problem: go back a step so the next scan retries cleanly
step.value = 'badge'
}
} finally {
submitting.value = false
}
}
function reset() {
clearTimeout(resetTimer)
step.value = 'item'
item.value = null
badge.value = ''
quantity.value = ''
error.value = ''
doneMessage.value = ''
focusWedge()
}
</script>
<style scoped>
.parts-kiosk {
min-height: 100vh;
background: var(--bg);
color: var(--text);
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.kiosk-header {
width: 100%;
max-width: 40rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.wedge-input {
position: absolute;
opacity: 0;
height: 1px;
width: 1px;
}
.kiosk-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
max-width: 40rem;
width: 100%;
}
.kiosk-prompt { font-size: 1.6rem; font-weight: 600; }
.kiosk-hint { color: var(--text-light); }
.kiosk-error {
background: var(--danger);
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
}
.kiosk-success { font-size: 1.6rem; color: var(--success); font-weight: 600; }
.item-card {
display: flex;
align-items: center;
gap: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 1rem 1.5rem;
width: 100%;
}
.item-photo {
width: 5rem;
height: 5rem;
object-fit: cover;
border-radius: 0.4rem;
}
.quantity-display {
font-size: 3rem;
font-weight: 700;
min-width: 8rem;
text-align: center;
border-bottom: 3px solid var(--primary);
}
.take-button {
font-size: 1.5rem;
padding: 0.9rem 3.5rem;
}
.manual-row { display: flex; gap: 0.6rem; }
</style>

View File

@@ -27,6 +27,8 @@
</button>
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
class="btn btn-secondary btn-sm">Edit</router-link>
<router-link :to="`/print/printedparts-labels?item=${item.printeditemid}`"
class="btn btn-secondary btn-sm">Bin Label</router-link>
</div>
</div>
</div>

View File

@@ -2,7 +2,12 @@
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
<div class="header-actions">
<router-link to="/print/printedparts-labels" class="btn btn-secondary">
Print Labels
</router-link>
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
</div>
</div>
<div class="filters">
@@ -131,6 +136,7 @@ function debouncedSearch() {
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions { display: flex; gap: 0.5rem; }
.lowstock-filter {
display: inline-flex;
align-items: center;

View File

@@ -0,0 +1,109 @@
<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group">
<label>Item code prefix</label>
<input v-model="values.printedparts_code_prefix" type="text"
class="form-control" maxlength="8" />
<p class="field-hint">
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}-0042.
Changing it does not rename existing items.
</p>
</div>
<div class="form-group">
<label>Default low-stock threshold</label>
<input v-model.number="values.printedparts_default_threshold"
type="number" min="0" class="form-control" />
<p class="field-hint">Seed value for new items; each item can override.</p>
</div>
<div class="form-group">
<label>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control">
<option value="deny">Deny - refuse badges with no directory match</option>
<option value="allow">Allow - record the SSO with no name</option>
</select>
</div>
<div class="form-group">
<label>Low-stock alert recipients</label>
<input v-model="values.printedparts_alert_email" type="text"
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
<p class="field-hint">
Comma-separated. Empty uses the site-wide alert recipients
(Settings &gt; System &gt; Email). Alerts fire once when an item
crosses its threshold; restocking above re-arms.
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
const KEYS = [
'printedparts_code_prefix',
'printedparts_default_threshold',
'printedparts_unknown_badge',
'printedparts_alert_email'
]
const values = ref({
printedparts_code_prefix: '3DP',
printedparts_default_threshold: 5,
printedparts_unknown_badge: 'deny',
printedparts_alert_email: ''
})
const saving = ref(false)
const message = ref('')
const error = ref('')
onMounted(async () => {
try {
const response = await settingsApi.list({ category: 'printedparts' })
const rows = response.data.data || []
for (const row of rows) {
if (KEYS.includes(row.key)) values.value[row.key] = row.value
}
values.value.printedparts_default_threshold =
parseInt(values.value.printedparts_default_threshold, 10) || 0
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
for (const key of KEYS) {
await settingsApi.update(key, String(values.value[key] ?? ''))
}
message.value = 'Settings saved'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
</style>

View File

@@ -41,3 +41,19 @@ pytest plugins/printedparts/tests/
- `docs/PLUGIN-QUICKSTART.md` - 30-minute walkthrough
- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract
- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules
## Why the kiosk take endpoint is unauthenticated
`POST /api/printedparts/kiosk/take` is the product's first open WRITE (every
other kiosk endpoint is a read). Accepted deliberately, against the criteria
in docs/proposals/printedparts-plugin.md:
1. Decrement-only: it can reduce stock of an active item, nothing else.
2. Fully attributed: it refuses to act without a badge that resolves under
the site policy; every action lands in the ledger with SSO + name + time.
3. Bounded blast radius: worst case is stock counts driven low - visible in
the ledger and reversible with an adjust.
4. Physically rate-limited: it serves a touch screen on the shop floor;
nothing enumerable, nothing worth scraping.
Any future open-write endpoint must clear the same bar.

View File

@@ -228,8 +228,12 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None)
"""Append a ledger row and move the cached quantity in ONE commit.
The single-commit invariant is what keeps quantityonhand equal to the
ledger sum; every write path must go through here.
ledger sum; every write path must go through here. Fires the low-stock
alert when this write CROSSES the item's threshold downward - crossing
(not being below) is the natural debounce: one alert per depletion, and
restocking above the threshold rearms it.
"""
quantitybefore = item.quantityonhand
item.quantityonhand += quantitychange
db.session.add(PrintedItemTransaction(
printeditemid=item.printeditemid,
@@ -240,6 +244,37 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None)
reason=reason,
))
db.session.commit()
if (quantitychange < 0
and quantitybefore > item.lowstockthreshold
and item.quantityonhand <= item.lowstockthreshold):
_send_lowstock_alert(item)
def _send_lowstock_alert(item):
"""Best-effort email when an item crosses its low-stock threshold.
Recipients: Setting printedparts_alert_email (comma-separated), falling
back to the site's alert_recipients. Never fails the transaction - the
ledger write already committed."""
from shopdb.api import send_email, send_alert
subject = (f'Low stock: {item.itemname} ({item.itemcode}) - '
f'{item.quantityonhand} left')
html = (f'<p><strong>{item.itemname}</strong> ({item.itemcode}) is down '
f'to <strong>{item.quantityonhand}</strong> '
f'(threshold {item.lowstockthreshold}).</p>'
f'<p>Bin: {item.binlocation or "-"}</p>'
f'<p>Time to print more.</p>')
try:
recipients = (Setting.get('printedparts_alert_email') or '').strip()
if recipients:
send_email([address.strip() for address in recipients.split(',')
if address.strip()], subject, html)
else:
send_alert(subject, html)
except Exception:
import logging
logging.getLogger(__name__).exception(
'Low-stock alert failed for %s', item.itemcode)
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
@@ -295,3 +330,164 @@ def adjust_item(item_id: int):
http_code=422)
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
return success_response(item.to_dict(), message='Stock adjusted')
# --- kiosk: UNauthenticated by decision record --------------------------------
# The take endpoint is the product's first open WRITE. The proposal's decision
# record sets the bar it must meet: decrement-only, badge-attributed, bounded,
# physically rate-limited. It can reduce stock of an active item and nothing
# else; identity comes from the badge resolved server-side, never the client.
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
def kiosk_item(itemcode):
"""Item summary for a scanned bin barcode (open read for the kiosk)."""
item = PrintedItem.query.filter(
PrintedItem.itemcode == itemcode.strip(),
PrintedItem.isactive == True).first()
if not item:
return error_response(ErrorCodes.NOT_FOUND,
'No part matches that barcode', http_code=404)
return success_response(item.to_dict())
@printedparts_bp.route('/kiosk/take', methods=['POST'])
def kiosk_take():
"""Take parts from a bin. Body: {itemcode, badge, quantity}."""
data = request.get_json() or {}
item = PrintedItem.query.filter(
PrintedItem.itemcode == (data.get('itemcode') or '').strip(),
PrintedItem.isactive == True).first()
if not item:
return error_response(ErrorCodes.NOT_FOUND,
'No part matches that barcode', http_code=404)
quantity = data.get('quantity')
if not isinstance(quantity, int) or quantity < 1:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Enter how many you are taking')
if quantity > item.quantityonhand:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Only {item.quantityonhand} on hand - take fewer or see the '
f'parts team')
try:
sso, name = resolve_badge(data.get('badge'))
except BadgeError as badge_error:
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
http_code=422)
_ledger_write(item, 'take', -quantity, sso, name)
return success_response(item.to_dict(),
message=f'Took {quantity}, {item.quantityonhand} left')
# --- reports (merged into GET /api/reports while the plugin is enabled) ------
import csv
import io
from flask import Response
from sqlalchemy import func
def _csv_response(rows, columns, filename):
"""CSV download; local helper because generate_csv is not on the
contract surface (shopdb.api)."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(columns)
for row in rows:
writer.writerow([row.get(column, '') for column in columns])
return Response(
output.getvalue(), mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename={filename}'})
@printedparts_bp.route('/reports/stock', methods=['GET'])
@jwt_required(optional=True)
def report_stock():
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
ledgerdelta should always be 0; anything else means a write path
bypassed the single-commit rule and needs finding.
"""
# int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a
# string and breaks the delta arithmetic's type.
ledger = {itemid: int(total) for itemid, total in
db.session.query(
PrintedItemTransaction.printeditemid,
func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0))
.group_by(PrintedItemTransaction.printeditemid).all()}
rows = []
for item in PrintedItem.query.filter_by(isactive=True).order_by(
PrintedItem.itemname).all():
rows.append({
'itemcode': item.itemcode,
'itemname': item.itemname,
'binlocation': item.binlocation or '',
'quantityonhand': item.quantityonhand,
'lowstockthreshold': item.lowstockthreshold,
'islowstock': item.islowstock,
'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0),
})
columns = ['itemcode', 'itemname', 'binlocation', 'quantityonhand',
'lowstockthreshold', 'islowstock', 'ledgerdelta']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-stock.csv')
return success_response({'columns': columns, 'rows': rows})
@printedparts_bp.route('/reports/consumption', methods=['GET'])
@jwt_required(optional=True)
def report_consumption():
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItem.itemcode,
PrintedItem.itemname,
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.join(PrintedItemTransaction,
PrintedItemTransaction.printeditemid == PrintedItem.printeditemid)
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname)
rows = [{'itemcode': code, 'itemname': name, 'takes': takes,
'quantitytaken': int(taken)}
for code, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['itemcode', 'itemname', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-consumption.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})
@printedparts_bp.route('/reports/by-person', methods=['GET'])
@jwt_required(optional=True)
def report_by_person():
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItemTransaction.employeesso,
func.max(PrintedItemTransaction.employeename),
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItemTransaction.employeesso)
rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes,
'quantitytaken': int(taken)}
for sso, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['employeesso', 'employeename', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-by-person.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})

View File

@@ -5,7 +5,7 @@
"display_name": "3D Printed Parts",
"author": "",
"dependencies": ["employees"],
"core_version": ">=0.11.0,<1.0.0",
"core_version": ">=0.12.0,<1.0.0",
"api_prefix": "/api/printedparts",
"default_enabled": false
}

View File

@@ -62,6 +62,45 @@ class PrintedpartsPlugin(BasePlugin):
'printedparts'),
]
def get_settings_cards(self) -> List[dict]:
return [
{
'group': '3D Printed Parts',
'to': '/settings/printedparts',
'icon': 'box',
'title': '3D Parts Settings',
'description': 'Item code prefix, default threshold, kiosk '
'badge policy, low-stock alert recipients',
'position': 47,
},
]
def get_reports(self) -> List[dict]:
return [
{
'id': 'printedparts-stock',
'name': '3D Parts Stock',
'description': 'Stock levels with low-stock flags and the '
'cache-vs-ledger reconcile check',
'category': 'inventory',
'endpoint': '/api/printedparts/reports/stock',
},
{
'id': 'printedparts-consumption',
'name': '3D Parts Consumption',
'description': 'Takes per item over a date range',
'category': 'usage',
'endpoint': '/api/printedparts/reports/consumption',
},
{
'id': 'printedparts-by-person',
'name': '3D Parts by Person',
'description': 'Takes grouped by employee',
'category': 'usage',
'endpoint': '/api/printedparts/reports/by-person',
},
]
def get_navigation_items(self) -> List[dict]:
return [
{
@@ -77,6 +116,12 @@ class PrintedpartsPlugin(BasePlugin):
self._seed_settings()
logger.info('Printedparts plugin installed')
def on_enable(self, app: Flask) -> None:
# Idempotent re-seed so settings added in later versions reach sites
# that installed earlier (enable runs on every upgrade cycle).
with app.app_context():
self._seed_settings()
def _seed_settings(self) -> None:
defaults = [
('printedparts_code_prefix', '3DP', 'string',
@@ -85,6 +130,9 @@ class PrintedpartsPlugin(BasePlugin):
'Default low-stock threshold for new items'),
('printedparts_unknown_badge', 'deny', 'string',
'Kiosk policy when a badge resolves to no employee: allow or deny'),
('printedparts_alert_email', '', 'string',
'Comma-separated low-stock alert recipients; empty uses the '
'site alert_recipients'),
]
for key, value, valuetype, description in defaults:
if Setting.get(key) is None:

View File

@@ -36,7 +36,7 @@ from .plugins import plugin_manager
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name
# on the import surface, minor bump.
__contract_version__ = '0.11.0'
__contract_version__ = '0.12.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -78,6 +78,7 @@ from shopdb.core.services.dualpath import (
# Legacy employee directory lookup (read-only) used by notifications
from shopdb.utils.employee_db import employee_connection
from shopdb.utils.mailer import send_email, send_alert
# CMMC USB check-in/out database (read-write) used by the usb plugin
from shopdb.utils.cmmc_usb_db import cmmc_usb_connection
@@ -266,6 +267,8 @@ __all__ = [
'parse_import_datetime',
# Legacy employee directory
'employee_connection',
'send_email',
'send_alert',
# CMMC USB check-in/out database
'cmmc_usb_connection',
]

View File

@@ -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)

View File

@@ -128,3 +128,75 @@ def test_member_without_permission_gets_403(client, member_headers, item):
assert client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '1'},
headers=member_headers).status_code == 403
def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item,
directory_employee):
"""The kiosk endpoint needs no auth but only ever decrements stock."""
itemcode = '3DP-9001'
stocked = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 5, 'badge': directory_employee},
headers=auth_headers)
assert stocked.status_code == 200
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}')
assert lookup.status_code == 200
take = client.post('/api/printedparts/kiosk/take', json={
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2})
assert take.status_code == 200, take.get_json()
assert take.get_json()['data']['quantityonhand'] == 3
too_many = client.post('/api/printedparts/kiosk/take', json={
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99})
assert too_many.status_code == 400
unknown = client.post('/api/printedparts/kiosk/take', json={
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1})
assert unknown.status_code == 422
with app.app_context():
rows = PrintedItemTransaction.query.filter_by(
printeditemid=item, transactiontype='take').all()
assert len(rows) == 1
assert rows[0].quantitychange == -2
assert rows[0].employeename == 'Pat Printer'
cached = db.session.get(PrintedItem, item).quantityonhand
ledgersum = sum(r.quantitychange for r in
PrintedItemTransaction.query.filter_by(
printeditemid=item).all())
assert cached == ledgersum
def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
directory_employee, monkeypatch):
"""One alert when stock CROSSES the threshold downward; restocking above
rearms it; staying below does not re-fire."""
sent = []
import plugins.printedparts.api.routes as printedparts_routes
monkeypatch.setattr(
printedparts_routes, '_send_lowstock_alert',
lambda alerted_item: sent.append(alerted_item.itemcode))
def restock(quantity):
return client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': quantity,
'badge': directory_employee},
headers=auth_headers)
def take(quantity):
return client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001',
'badge': directory_employee,
'quantity': quantity})
restock(10) # 10 on hand, threshold 5
assert take(3).status_code == 200 # 7: above threshold, no alert
assert sent == []
assert take(3).status_code == 200 # 4: CROSSES 5 -> one alert
assert sent == ['3DP-9001']
assert take(2).status_code == 200 # 2: still below, no re-fire
assert sent == ['3DP-9001']
restock(20) # 22: rearmed
assert take(18).status_code == 200 # 4: crosses again -> second alert
assert sent == ['3DP-9001', '3DP-9001']