diff --git a/CLAUDE.md b/CLAUDE.md index d9374f1..3516a50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.13.0 (0.12.0 added the mailer, 0.13.0 the User model, 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). diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index bed411e..ad3dc13 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -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.13.0' ``` Each plugin's `manifest.json` declares the range of contract versions it supports: @@ -479,6 +479,11 @@ What `shopdb.api` exposes: - Import mode: `apply_import_timestamps`, `import_mode_active`, `parse_import_datetime` - Legacy employee directory: `employee_connection` +- `User` / `Role` (0.13.0) - the account and role models, e.g. resolving + alert recipients' emails from selected user ids or role membership +- 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 diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index 00e6589..2922d6b 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -1,222 +1,404 @@ -# Plugin lab: build the printedparts plugin yourself +# Plugin lab: build the printedparts plugin -A guided, milestone-based exercise: build the 3D-printed-parts storefront + -kiosk plugin specified in `docs/proposals/printedparts-plugin.md`. Each -milestone lists what to build, which existing code to imitate, and a -checkpoint that proves you are done. Read the spec first, keep it open. - -Prerequisites: a working dev environment (README quick start), the three -plugin docs skimmed once - `PLUGIN-QUICKSTART.md` (mechanics), -`PLUGIN-GUIDE.md` (the measuringtools walkthrough - your narrative reference), -`PLUGIN-HOOKS.md` (hook reference). Naming rules: `CONTRIBUTING.md` - the -pre-commit hook enforces them, read it before naming anything. - -Ground rules -- Import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`). The contract - test fails your build otherwise. -- DB columns: lowercase concatenated (`quantityonhand`, not quantity_on_hand). -- Run `bash scripts/check-naming-and-style.sh` and the test suite at every - checkpoint. -- Commit once per milestone. Working on a branch is fine; so is a fork. +A hand-held, build-along tutorial: construct the 3D-printed-parts storefront + +kiosk plugin specified in `docs/proposals/printedparts-plugin.md`, stage by +stage, seeing each piece work before moving on. Written for someone building +their first plugin. The finished implementation lives on the +`feat/printedparts-plugin` branch with one commit per stage, tagged +`lab-stage-01` .. `lab-stage-10` - when stuck, `git diff lab-stage-03 +lab-stage-04` shows exactly what a stage changes. Know before you start - You are building a BUNDLED plugin inside this repo. Plugin frontend files live in core (`frontend/src/...`), and three core files get small edits: - `frontend/src/api/index.js` (api client), the router, and - `PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. That is - normal for all 12 bundled plugins - external-plugin UI packaging does not - exist yet - so do not be confused when a "plugin" touches core. -- This plugin deliberately DIVERGES from the scaffold in three places, each - a teaching point you will hit in order: (1) it is NOT an asset type, so the - scaffold's AssetType seeding gets deleted (M1); (2) its migration is a REAL - baseline that creates tables, not a stamp-only anchor (M1); (3) its kiosk - take endpoint is the product's first UNauthenticated write - read the - decision record in the proposal before building it (M4). -- Instructor option: keep a solution branch with one commit per milestone - (tag `lab-m1`..`lab-m7`); a stuck learner can `git diff lab-m3 lab-m4` to - see exactly what a milestone changes. + `frontend/src/api/index.js`, the sidebar icon map in `AppLayout.vue`, and + `PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. Normal for + all bundled plugins - external-plugin UI packaging does not exist yet. +- Three deliberate divergences from the scaffold, each a teaching point: + (1) NO AssetType - these are quantity consumables, not ADR-001 assets + (stage 1); (2) the migration is a REAL baseline that creates tables, not a + stamp-only anchor (stage 2); (3) the kiosk take endpoint is the product's + first UNauthenticated write - read the decision record in the proposal + before stage 7. +- Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`); + DB names lowercase concatenated (`quantityonhand`); run + `bash scripts/check-naming-and-style.sh` + the tests at every stage; one + git commit per stage. + +Prerequisites: working dev environment (README quick start), skim +`PLUGIN-QUICKSTART.md`, `PLUGIN-GUIDE.md` (the measuringtools exemplar this +lab imitates), `PLUGIN-HOOKS.md`, and `CONTRIBUTING.md` naming rules. --- -## Milestone 1 - skeleton, models, migration (backend exists) +## Stage 0 - orientation (no code) -Build -1. `flask plugin new printedparts` - scaffolds `plugins/printedparts/`. -2. The scaffold assumes an Asset-extension plugin; ours is standalone. - In `plugin.py` strip the AssetType seeding from `on_install` (imitate - `plugins/knowledgebase/plugin.py` instead of the template). -3. Replace the scaffold model with the two spec tables: `PrintedItem`, - `PrintedItemTransaction` (`models/printeditem.py`). Use `BaseModel` + - `AuditMixin` from `shopdb.api`. Itemcode: leave generation to the API - layer (M2), column just `unique=True, index=True`. -4. Register both tables in `PLUGIN_TABLE_OWNERS` - (`shopdb/plugins/alembic_template.py`). -5. Create `plugins/printedparts/migrations/` with the 3-line `env.py` + - `script.py.mako` (copy from measuringtools) and a REAL baseline - `versions/0001_printedparts_baseline.py` - hand-written - `op.create_table(...)` for both tables (see - `plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py`). -6. Manifest: api_prefix `/api/printedparts`, `dependencies: ["employees"]`, - `default_enabled: false`. +Read the proposal. Tour the two reference plugins you will imitate: +`plugins/usb/` (checkout ledger + badge contract) and +`plugins/measuringtools/` (post-cutover migration baseline, hooks). +See it work: run the app, log in. -Checkpoint +## Stage 1 - scaffold, minus the AssetType + +``` +flask plugin new printedparts --description "3D-printed parts inventory + kiosk checkout" +``` + +Walk the generated tree. Then diverge: +1. In `plugins/printedparts/plugin.py`, DELETE `_ensure_asset_type` and its + `on_install` call - a printed part is a kind-with-a-count, not an asset. + Replace it with settings seeding (see the tagged commit): three Setting + rows, category `printedparts` - `printedparts_code_prefix` (3DP), + `printedparts_default_threshold` (5), `printedparts_unknown_badge` (deny). +2. `manifest.json`: `"dependencies": ["employees"]` (badge names), + `"core_version": ">=0.11.0,<1.0.0"`, `"default_enabled": false`, + `"display_name": "3D Printed Parts"`. + +See it work: `flask plugin list` shows printedparts [Available]. +Commit: `printedparts stage 1: scaffold, no AssetType, manifest per spec` + +## Stage 2 - models + real migration baseline + tables live + +1. Replace the scaffold model with `models/printeditem.py`: `PrintedItem` + (itemcode unique+indexed, itemname, itemdescription, imageurl, + quantityonhand, lowstockthreshold, binlocation, printnotes) and + `PrintedItemTransaction` (printeditemid FK CASCADE, transactiontype + take/restock/adjust, SIGNED quantitychange, employeesso, employeename, + reason, transactiondate) - both on `BaseModel`. The ledger is the source + of truth; quantityonhand is a cache moved in the same commit. +2. Update `models/__init__.py` exports and `plugin.py` `get_models`. +3. Register in `PLUGIN_TABLE_OWNERS` (`shopdb/plugins/alembic_template.py`): + `'printedparts': ('printeditems', 'printeditemtransactions'),` +4. `migrations/`: copy `script.py.mako` + the 3-line `env.py` from + measuringtools (change PLUGIN_NAME), then hand-write + `versions/0001_printedparts_baseline.py` with explicit `op.create_table` + for both tables + the three transaction indexes. +5. The scaffold's `api/routes.py` still imports the model you deleted - make + the blueprint import cleanly (a placeholder route is fine for now). + +See it work: ``` flask plugin install printedparts && flask plugin enable printedparts -flask plugin upgrade-all # applies your 0001 -mysql> SHOW TABLES LIKE 'printed%'; -- both tables -mysql> SELECT * FROM alembic_version_printedparts; -- your revision id -pytest tests/ -q # nothing broken, contract tests green +mysql> SHOW TABLES LIKE 'printed%'; -- both tables +mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline +flask plugin upgrade-all -- printedparts: ok (idempotent) ``` -## Milestone 2 - CRUD API + permissions + itemcode +Common errors (both hit for real while building this): +- An empty `Migration error:` on install. Root cause: anything that makes + `plugins.printedparts.models` fail to import - the alembic env imports the + models package, which pulls in plugin.py and routes.py. Here it was the + scaffold routes importing the deleted model; the ImportError gets caught + and retried down a subprocess path with no stderr. Fix the import, not the + migration. +- `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py`: add + `EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'` - + the guard makes every new plugin declare its expected head on purpose. -Build -1. `api/routes.py`: list/detail/create/update/soft-delete per the spec table. - Imitate a clean plugin blueprint (`plugins/measuringtools/api/routes.py`) - for pagination (`perpage`, `dir`), search, and the shared - `success_response`/`error_response` helpers from `shopdb.api`. -2. Itemcode on create: `-`; prefix from Setting - `printedparts_code_prefix` (read via `self.get_setting` or Setting model - through `shopdb.api`). Two-step: insert, flush to get the id, set code. -3. `get_permissions()` on the plugin class: view/create/edit/delete/restock - (tuples, category `printedparts` - copy shape from - `plugins/usb/plugin.py`). Gate mutations with `@jwt_required()` + - `@require_permission(...)`; reads are `@jwt_required(optional=True)`. -4. Restock + adjust endpoints: both write a LEDGER row and move - `quantityonhand` in the same commit. Adjust requires `reason`, rejects a - result below zero. Both record the operator: accept `badge` in the body - and resolve it (M4 extracts the resolver - for now digits=SSO is enough). -5. Seed the 3 settings in `on_install` (Setting.set pattern - see how other - plugins seed in `on_install`; category `printedparts`). +Commit + tag `lab-stage-02`. -Tip - earlier visible win: as soon as the GET list endpoint works, jump ahead -and wire just the api client + router entry + a bare `PrintedItemsList.vue` -(first two steps of M3), seed two rows by hand, and look at your parts in the -browser. Everything before that moment is invisible; seeing the table makes -the rest of the lab concrete. Then come back and finish the mutations here. +## Stage 3 - read API + list page (the first visible win) -Checkpoint -``` -pytest plugins/printedparts/tests/ -q # write tests as you go: -# - create mints 3DP-0001 style codes -# - restock/adjust move both ledger and cache atomically -# - adjust below zero -> 400/422 -# - permission gates: anonymous create -> 401, wrong-perm user -> 403 -curl -s localhost:5001/api/printedparts/items | jq # anonymous list OK -``` +1. Real `api/routes.py`: `GET /items` (jwt-optional; pagination via + `get_pagination_params`/`paginate_query`, search across + code/name/description/bin, `?lowstock=true` filter) and + `GET /items/` returning the item + its 25 most recent transactions. +2. `get_navigation_items` on the plugin: `{'name': '3D Parts', 'icon': 'box', + 'route': '/printedparts', 'position': 46}`. +3. Frontend: paste the `printedpartsApi` client into + `frontend/src/api/index.js` (list/get for now, paths under + `/printedparts/items`); rename the scaffold views to + `PrintedItemsList/PrintedItemDetail/PrintedItemForm.vue` and repoint + `router/routes/printedparts.js`; build the list page from + `PrintersList.vue` (global styles, `useListQuery`, PaginationBar) with an + image thumb column and a red/green quantity badge vs the threshold. +4. Seed two or three rows by hand (SQL or flask shell) purely to have + something to look at. NOTE: hand-seeded stock has no ledger backing - the + stage-9 reconcile report will flag exactly these rows, which is the check + working. -## Milestone 3 - management frontend + images +See it work: navigate to `/printedparts` - your parts in a table, low-stock +row red-badged. Everything before this moment was invisible; from here on +every stage shows on screen. -Build -1. The scaffold already dropped `PrintedpartsList/Detail/Form.vue` starters - and a router file; rename/build them into `PrintedItemsList/Detail/Form` - per the spec. Master templates: `PrintersList.vue` (list), - `PrinterDetail.vue` (detail). Global CSS only; CSS variables for colors - (frontend/CLAUDE.md rules). -2. Register the API client in `frontend/src/api/index.js` (paste the - generated `frontend-api-snippet.js`, extend with restock/adjust/image - calls). -3. Low-stock highlighting on the list (`quantityonhand <= lowstockthreshold` - -> danger badge). Filters: search + low-stock-only checkbox. -4. Image upload: replicate the models-image trio - upload/serve/delete - - from `shopdb/core/api/models.py` INTO the plugin blueprint - (`instance/printedpartsimages/`, public GET serve, imageurl column, - prefix-guarded delete). Wire the Form upload + Detail hero image. -5. Nav: `get_navigation_items()` -> "3D Parts" (usb plugin shape). Router - meta: list/detail plugin-gated, new/edit `requiresAuth` (see - `frontend/src/router/routes/usb.js`). +Common error: nav icon missing. The sidebar maps icon NAMES to Lucide +components in `AppLayout.vue` (`iconMap`); an unknown name renders nothing. +Add `'box': Box` to the map (and the import) or reuse an existing name. -Checkpoint: create an item with a photo in the UI; thumbnail on list, hero on -detail; restock from detail updates qty + shows in history; frontend build + -`npx vitest run` green; naming hook green. +Commit + tag `lab-stage-03`. -## Milestone 4 - badge resolution + kiosk +## Stage 4 - catalog mutations + item photos + detail/form pages -Build -1. Badge resolver in the plugin (`services/badges.py`): copy the USB contract - - all-digits -> SSO; `^0(\d+)BZ$` case-insensitive -> PayNo; resolve - display name via the employees plugin directory the way - `plugins/usb/api/selfhosted.py::_resolve_name` does (lazy import inside - the function, degrade gracefully when the plugin is absent). Policy - setting `printedparts_unknown_badge` (deny -> 422). -2. Kiosk endpoints (UNauthenticated - the notifications/employees open-read - precedent): `GET /kiosk/item/` and `POST /kiosk/take` - {itemcode, badge, quantity}. Take: validate active item, 1 <= qty <= - onhand, resolve badge, single-commit ledger row + decrement. Clear error - strings - the kiosk displays them verbatim. -3. Kiosk view `/parts-kiosk`: top-level route, NO requiresAuth, outside - AppLayout (register beside `/shopfloor` in `frontend/src/router/index.js`). - Three-step flow per the spec. The scanner is a keyboard wedge: hidden - always-focused input, submit on Enter, route the scan to whichever step is - active. Build `TouchKeypad.vue` (3x4 grid of big buttons, emits digits/ - clear/backspace) - net-new, nothing to copy, keep it dumb. -4. Manual fallback path (typed item search + badge entry) behind a small - "no scanner?" link. +1. `POST /items` mints the itemcode AFTER `db.session.flush()` assigns the + id: `-` with the prefix from Setting. `PUT /items/` + updates catalog fields but REFUSES `quantityonhand` (ledger-managed). + `DELETE` soft-retires. All `@jwt_required()` (permissions come in + stage 6). +2. Image trio copied from `shopdb/core/api/models.py`: POST/DELETE + `/items//image` + public `GET /image/`, storing + `printeditem-.` in `instance/printedpartsimages/`, wiping prior + extensions on replace, prefix-guarded delete. +3. `PrintedItemDetail.vue` on the unified detail skeleton (hero image, info + list, transactions table); `PrintedItemForm.vue` create/edit + photo + upload on edit; extend the api client. -Checkpoint: full kiosk walkthrough on a touchscreen (or browser): scan/type -an itemcode -> item card; badge `0123456BZ` and plain SSO both resolve; take 3 --> success screen, qty down 3, ledger row has your name; taking more than -onhand -> friendly error; unknown badge -> denied message. Backend tests for -the resolver shapes + take validation. +See it work: add a part with a photo in the UI; thumbnail on the list, hero +on the detail; `PUT` with `quantityonhand` returns the ledger-managed error. -## Milestone 5 - labels (1in x 0.5in) +Commit + tag `lab-stage-04`. -Build -1. Public print route `/print/printedparts-labels` + view (imitate - `USBLabelBatch.vue` - USB is the precedent for a plugin OWNING its label - page instead of joining TYPE_CONFIG). -2. New stock size: `@page { size: 1in 0.5in; margin: 0 }`, one label per page - (roll-fed label printers treat each page as one label). Layout: CODE128 - via JsBarcode (~0.9in wide, displayValue false), itemcode text ~7pt under - it, optional truncated name. Offer QR as a variant but default barcode. -3. Batch: multi-select items -> sequence of labels; plus a ULINE mini-grid - sheet fallback (mini72 pattern in `AssetLabelBatch.vue`). -4. Print buttons on Detail (single) and List (batch selected). +## Stage 5 - the ledger: restock/adjust with badge attribution -Checkpoint: print preview shows one 1x0.5 label per page; a printed (or -PDF-zoomed) barcode scans back into the kiosk and pulls up the right item. -That round trip - label printed from the catalog, scanned at the kiosk, -stock decremented with your name on it - is the demo moment; make it work -end to end before polishing. +1. `services/badges.py` - COPY the USB badge contract (do not import + `plugins.usb`; cross-plugin imports fail the contract test): + `^0(\d+)BZ$` PayNo wrap, all-digits SSO, name lookup via the employees + plugin `DirectoryEmployee` (lazy import, graceful fallback), and the + `printedparts_unknown_badge` policy - deny raises a kiosk-displayable + `BadgeError`, allow records the SSO with an empty name. +2. `_ledger_write(item, type, change, sso, name, reason)` - THE invariant: + append the transaction row and move the cached quantity in ONE commit. + Every write path goes through it. +3. `POST /items//restock` {quantity, badge} and `/adjust` + {quantitychange, reason, badge}; adjust requires a reason and refuses to + drive stock below zero. +4. Detail page: Restock/Adjust modals (shared `Modal.vue`). +5. Tests as you go: minting, cache==ledger after a restock, the PayNo badge + shape, reason-required + below-zero guards, the policy toggle, 401 for + anonymous. See `tests/test_plugins/test_printedparts_ledger.py`. -## Milestone 6 - metrics, reports, widget +See it work: restock from the detail page with your SSO - quantity moves AND +a named transaction row appears. -Build -1. `get_reports()` -> stock, consumption (date range), by-person; endpoints - in the plugin blueprint, `@jwt_required(optional=True)`, `?format=csv` via - the `generate_csv` helper pattern (`shopdb/core/api/reports.py` shows the - shape; a plugin report lives in the plugin and is merged into - `GET /api/reports` automatically when enabled). -2. Stock report includes the reconcile check: flag rows where cached - `quantityonhand` != SUM(ledger). Should always be empty; if not, you have - a non-atomic write path - find it. -3. OPTIONAL/deferred: `get_dashboard_widgets()` -> low-stock count. Caveat: - this hook predates the ADR-010 data-only renderers - the widget names a - frontend component that must already exist in core, so a plugin widget - only renders if you also add that component. Reports are the primary - monitoring surface; skip the widget unless you want the extra credit. -4. Nice-to-have if time: burn rate (avg weekly takes over trailing 4 weeks + - weeks-to-empty). Plain SQL over the ledger. +Common error: in tests, mutating rows through a nested `app.app_context()` +does not reliably stick in the sqlite test env - stock the item through the +real restock endpoint instead (also more honest). -Checkpoint: reports appear on /reports grouped under the plugin, CSV -downloads; widget renders on the dashboard; reconcile column all-clear after -a kiosk session. +Commit + tag `lab-stage-05`. -## Milestone 7 - lifecycle + closeout +## Stage 6 - RBAC -Build/verify -1. Disable/enable cycle: `flask plugin disable printedparts` - nav entry, - routes, reports, and grantable permissions all disappear; enable restores. -2. Fresh-database proof: point DATABASE_URL at a scratch DB, `flask db - upgrade` + `flask plugin install/enable/upgrade-all` - everything works - with zero manual SQL. +1. `get_permissions` on the plugin: view/create/edit/delete/restock, category + `printedparts` (seeded automatically on install/enable and by + `flask seed permissions`). +2. Add `@require_permission('printedparts.')` under `@jwt_required()` on + every mutation: create/edit/delete/image = create/edit/delete; restock + + adjust = restock. +3. Test with the `member_headers` fixture (authenticated, role-less): 403 + where admin succeeds - authentication alone is not authorization. + +See it work: the permissions appear in the role grid (Settings > Roles), and +the member test passes. + +Commit + tag `lab-stage-06`. + +## Stage 7 - the kiosk (the deliberate open write) + +Read the decision record in the proposal first. The take endpoint must stay: +decrement-only, badge-attributed server-side, bounded, physically +rate-limited. Put the justification in the plugin README. + +1. Backend, both UNdecorated: `GET /kiosk/item/` (summary for a + scanned bin code) and `POST /kiosk/take` {itemcode, badge, quantity} - + validate active item, 1 <= qty <= onhand, resolve the badge, then + `_ledger_write(..., 'take', -quantity, ...)`. Error strings are shown + verbatim on the kiosk - write them for a person standing at a screen. +2. `TouchKeypad.vue` - net-new, dumb 3x4 grid emitting digit/clear/backspace. +3. `PartsKiosk.vue` + a top-level `/parts-kiosk` route registered beside + `/shopfloor` in `router/index.js` (NO requiresAuth, outside AppLayout, + `meta.plugin` so a disabled plugin dead-ends). Three steps - scan item, + scan badge, keypad quantity - driven by ONE hidden always-focused input + that consumes keyboard-wedge scans (scanners type the code + Enter) for + whichever step is active; manual type-in fallbacks for damaged labels. + Success screen auto-resets after a few seconds. +4. Kiosk test: open access, over-take guard, unknown-badge 422, and + cache==ledger afterward. + +See it work: full walkthrough in a browser - type a code, badge in, keypad 2, +TAKE - stock drops with your name in the ledger. + +Common error (by design): the full suite fails with +`test_authz.py::test_mutation_rejects_roleless_member[printedparts.kiosk_take]`. +That sweep asserts EVERY mutating route rejects a role-less user - the +framework's net against accidentally-open writes. Your kiosk take is open on +purpose, so add `printedparts.kiosk_take` to EXEMPT_ENDPOINTS with a comment +pointing at the decision record. The net stays; the exception is explicit +and reviewable. + +Commit + tag `lab-stage-07`. + +## Stage 8 - 1in x 0.5in bin labels + +1. `frontend/src/views/print/PrintedPartsLabels.vue` + a public + `/print/printedparts-labels` route beside `/print/usb-labels` (a plugin + OWNS its label page - the USB precedent; parts are not in the asset-label + TYPE_CONFIG because they are not assets). +2. The label: CODE128 of the itemcode via JsBarcode + (`{format:'CODE128', displayValue:false, width:1.4, height:26, margin:0}`) + + the code text at ~6.5pt. A QR at 0.4in is at the edge of scanner + tolerance; CODE128 of `3DP-0042` is comfortable. +3. Roll stock = one label per page: a global (unscoped) print style with + `@page { size: 1in 0.5in; margin: 0 }` and `page-break-after: always` on + each `.bin-label`. Multi-select + per-item copies; `?item=` + preselects (the Detail page's Bin Label button). + +See it work: print preview shows one 1x0.5 label per page; scan the printed +barcode (or the on-screen one with a phone scanner app) into the kiosk - +label -> scan -> badge -> take -> ledger is the demo moment. + +Commit + tag `lab-stage-08`. + +## Stage 9 - reports + the reconcile check + +1. Three jwt-optional endpoints in the plugin blueprint, each honoring + `?format=csv` (local CSV helper - `generate_csv` is not on the contract + surface): `/reports/stock`, `/reports/consumption?days=N`, + `/reports/by-person?days=N`. +2. The stock report's `ledgerdelta` column = cached quantityonhand minus the + ledger SUM per item. Always 0 for ledger-driven stock; nonzero flags a + write path that bypassed `_ledger_write` - your stage-3 hand-seeded rows + show up here, proving the check works. +3. `get_reports` on the plugin (endpoint-style entries, categories + inventory/usage) - they merge into `GET /api/reports` and the /reports hub + while the plugin is enabled. + +Common error: MySQL `SUM()` returns Decimal; `int()` it or the JSON carries +strings. + +Deferred by decision: `get_dashboard_widgets` (predates the ADR-010 data-only +renderers; needs a core component) and a Settings card (needs a settings page +to link). Reports are the monitoring surface. + +Commit + tag `lab-stage-09`. + +## Stage 10 - closeout + +1. Lifecycle: `flask plugin disable printedparts` - nav, reports, and + grantable permissions disappear; API routes only disappear after a + RESTART (blueprints register at startup - the guide's section 12 gotcha). + Re-enable. +2. Fresh-database proof: scratch DATABASE_URL, `flask db upgrade` + + `flask plugin install/enable printedparts` + `upgrade-all` - green with + zero manual SQL. 3. Full suite: backend pytest, vitest, frontend build, naming hook. -4. End checklist from `PLUGIN-GUIDE.md` section 12. +4. Walk `PLUGIN-GUIDE.md` section 12's End checklist. Done means: a colleague can clone the repo, enable the plugin, print a bin label, and take a part at the kiosk with their badge - without asking you 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. + +## Stage 13 (extension) - alert recipients picked from shopdb users + +Free-text emails rot; user accounts do not. Another contract addition: +`User` joins the surface (0.13.0 - export, PLUGIN-HOOKS, version bump, the +docs-drift guard again). +1. Setting `printedparts_alert_userids` (comma-separated user ids), seeded + beside the others. +2. `_alert_recipients()`: resolve each selected id to an ACTIVE user's + account email, merge with the free-text list, dedupe order-preserving; + empty result still falls back to the site alert_recipients. +3. Settings page: checkbox picker over `usersApi.list()` (the page is + admin-only, matching the endpoint), saving joined ids. +4. Test: active user's email + free-text merge deduped, inactive user + skipped (`test_alert_recipients_merge_users_and_freetext`). + +## Stage 14 (extension) - retire/restore in the UI, dashless codes + +Field feedback stage: the soft-delete endpoint existed with no button, and +the site wanted `WJRP0042`, not `WJRP-0042`. +1. Detail gains Retire (confirm dialog; item leaves the storefront and the + kiosk 404s its code, history and label intact) and Restore; the list + gains an Include-retired toggle (`?active=false`) with a Retired badge. + Restore is its own POST gated by printedparts.delete - PUT deliberately + cannot flip isactive. +2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their + codes - itemcode is an immutable label once printed on a bin. + +## Stage 15 (extension) - print-file revisions + role-based alerts + +Two more field requests, and the plugin's FIRST incremental migration: +1. `printeditemfiles` (append-only revisions of the STL/3MF/gcode per item) + arrives as `0002_printeditemfiles` on top of the 0001 baseline - the + ADR-008 payoff: the plugin evolves its own schema, `flask plugin + upgrade-all` applies it, the core chain never hears about it. Update + PLUGIN_TABLE_OWNERS and the guard test's expected head. + Gotchas hit live: (a) MySQL 5.6 dev box - a VARCHAR(255) UNIQUE on + utf8mb4 dies with error 1071 because the per-plugin chain does not apply + the core env's ROW_FORMAT=DYNAMIC hook; size unique columns to 191 or + less (191*4 = 764 bytes fits the 767 prefix). (b) The dev container's + innodb_large_prefix globals reset on restart (documented dev caveat). +2. Upload endpoint assigns revision = max+1, stores + `printeditem--rev` in `instance/printedpartsfiles/` + (extension allowlist, 100 MB cap), records uploader from the JWT. + Download serves the ORIGINAL filename; delete (permission-gated) exists + for wrong-file mistakes, otherwise history is append-only. Detail page + gains the revision table with a "current" badge on the newest. +3. Role-based alert recipients: `Role` joins the 0.13.0 surface beside User; + Setting `printedparts_alert_roleids`; `_alert_recipients` folds in every + ACTIVE member of each selected role (role.users backref), deduped with + the user picks and free-text; settings page gains a role picker. + +## Stage 16a (extension) - view permission on the catalog + +The catalog started with open reads (the product's jwt-optional list +convention). Field decision: browsing and managing the parts catalog is +staff-only, so the reads (list, detail, file listings) move behind +`@jwt_required()` + `require_permission('printedparts.view')`, the +`/printedparts` routes and the label print page gain `requiresAuth`, and +the view permission becomes meaningful in the role grid. +Deliberately still open: the kiosk endpoints (decision record), the image +serve and file download (fetched by `` tags and anchor clicks, which +cannot carry a JWT header), and the reports (product-wide jwt-optional +convention). Grant `printedparts.view` to the roles that should see the +catalog - admins bypass as always. + +## Stage 16 (extension) - kiosk touch fixes from first hands-on use + +First real touchscreen session found two problems worth their own stage: +1. Focus steal: the page's tap-anywhere handler refocused the hidden wedge + input, yanking focus out of the manual-entry field the moment it was + tapped. Guard the handler - never reclaim focus from INPUT/SELECT/ + TEXTAREA/BUTTON/A targets, only from dead space. +2. No physical keyboard on a touchscreen: manual fallbacks now use the + TouchKeypad. Badge entry is digits (an SSO) so the keypad covers it; + item codes are letters+digits, solved server-side instead of building an + alphanumeric keyboard - the digits in a minted code ARE the row id, so + `/kiosk/item/` resolves bare digits by id. Bonus: labels printed + under an older code prefix keep working after the prefix changes. + --- ## Where each pattern lives (cheat sheet) @@ -233,5 +415,6 @@ anything. | Barcode/QR rendering | JsBarcode usage in `AssetLabel.vue`, `qrLogo.js` | | Kiosk route posture | `/shopfloor` in `frontend/src/router/index.js` | | List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` | -| Reports hook + CSV | `plugins/warranty/` report + `shopdb/core/api/reports.py` | +| Reports hook + CSV | `plugins/warranty/` + `shopdb/core/api/reports.py` | | Permissions declaration | `plugins/usb/plugin.py::get_permissions` | +| The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..10` | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5227ead..6e83d00 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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.13.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 diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index ec19d77..ac1fcee 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1126,3 +1126,61 @@ export const measuringtoolsApi = { } } } + +// 3D printed parts (printedparts plugin) +export const printedpartsApi = { + list(params = {}) { + return api.get('/printedparts/items', { params }) + }, + get(printeditemid) { + return api.get(`/printedparts/items/${printeditemid}`) + }, + create(data) { + return api.post('/printedparts/items', data) + }, + update(printeditemid, data) { + return api.put(`/printedparts/items/${printeditemid}`, data) + }, + remove(printeditemid) { + return api.delete(`/printedparts/items/${printeditemid}`) + }, + restore(printeditemid) { + return api.post(`/printedparts/items/${printeditemid}/restore`) + }, + uploadImage(printeditemid, file) { + const formData = new FormData() + formData.append('file', file) + return api.post(`/printedparts/items/${printeditemid}/image`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + deleteImage(printeditemid) { + return api.delete(`/printedparts/items/${printeditemid}/image`) + }, + restock(printeditemid, data) { + return api.post(`/printedparts/items/${printeditemid}/restock`, data) + }, + 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) + }, + listFiles(printeditemid) { + return api.get(`/printedparts/items/${printeditemid}/files`) + }, + uploadFile(printeditemid, file, note) { + const formData = new FormData() + formData.append('file', file) + if (note) formData.append('note', note) + return api.post(`/printedparts/items/${printeditemid}/files`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + removeFile(fileid) { + return api.delete(`/printedparts/files/${fileid}`) + } +} diff --git a/frontend/src/components/TouchKeypad.vue b/frontend/src/components/TouchKeypad.vue new file mode 100644 index 0000000..6baa0b8 --- /dev/null +++ b/frontend/src/components/TouchKeypad.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ade1a1e..2405699 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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,14 @@ const routes = [ component: () => import('../views/print/USBLabelBatch.vue'), meta: { plugin: 'usb' } }, + { + // Unlike the other print pages this one requires login: it lists the + // whole catalog, which is printedparts.view-gated at the API. + path: '/print/printedparts-labels', + name: 'print-printedparts-labels', + component: () => import('../views/print/PrintedPartsLabels.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, { path: '/', component: AppLayout, diff --git a/frontend/src/router/routes/printedparts.js b/frontend/src/router/routes/printedparts.js new file mode 100644 index 0000000..8b19b25 --- /dev/null +++ b/frontend/src/router/routes/printedparts.js @@ -0,0 +1,41 @@ +/** + * Printedparts plugin routes. + * + * Auto-discovered by the router via import.meta.glob, so no registration + * edit is needed. Every route carries meta.plugin 'printedparts' so the ADR-009 + * guard redirects to the dashboard when the printedparts backend plugin is + * disabled. Form routes add requiresAuth so anonymous users cannot reach + * create or edit. + */ +export default [ + { + path: 'printedparts', + name: 'printedparts', + component: () => import('../../views/printedparts/PrintedItemsList.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, + { + path: 'printedparts/new', + name: 'printedparts-new', + component: () => import('../../views/printedparts/PrintedItemForm.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, + { + path: 'printedparts/:id', + name: 'printedparts-detail', + component: () => import('../../views/printedparts/PrintedItemDetail.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, + { + path: 'printedparts/:id/edit', + 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' } + } +] diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 44e61ee..530584b 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -27,6 +27,8 @@ Shopfloor Dashboard TV Slideshow + Parts Kiosk Settings @@ -101,13 +103,14 @@ import ToastHost from '../components/ToastHost.vue' import { Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor, Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler, - KeyRound, LogOut + Box, KeyRound, LogOut } from 'lucide-vue-next' import { useAuthStore } from '../stores/auth' import { currentTheme, toggleTheme } from '../stores/theme' import { dashboardApi, notificationsApi } from '../api' import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings' import { withBase } from '../utils/basePath' +import { isPluginEnabled } from '../composables/enabledPlugins' const router = useRouter() const route = useRoute() @@ -147,6 +150,7 @@ const iconMap = { 'image': Image, 'shield': ShieldCheck, 'ruler': Ruler, + 'box': Box, } // Default navigation (used as fallback if API fails) diff --git a/frontend/src/views/print/PrintedPartsLabels.vue b/frontend/src/views/print/PrintedPartsLabels.vue new file mode 100644 index 0000000..c2dd1c6 --- /dev/null +++ b/frontend/src/views/print/PrintedPartsLabels.vue @@ -0,0 +1,207 @@ + + + + + + + diff --git a/frontend/src/views/printedparts/PartsKiosk.vue b/frontend/src/views/printedparts/PartsKiosk.vue new file mode 100644 index 0000000..543d547 --- /dev/null +++ b/frontend/src/views/printedparts/PartsKiosk.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/frontend/src/views/printedparts/PrintedItemDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue new file mode 100644 index 0000000..335895b --- /dev/null +++ b/frontend/src/views/printedparts/PrintedItemDetail.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/frontend/src/views/printedparts/PrintedItemForm.vue b/frontend/src/views/printedparts/PrintedItemForm.vue new file mode 100644 index 0000000..10058d0 --- /dev/null +++ b/frontend/src/views/printedparts/PrintedItemForm.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/views/printedparts/PrintedItemsList.vue b/frontend/src/views/printedparts/PrintedItemsList.vue new file mode 100644 index 0000000..09a0c46 --- /dev/null +++ b/frontend/src/views/printedparts/PrintedItemsList.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/views/settings/PrintedPartsSettings.vue b/frontend/src/views/settings/PrintedPartsSettings.vue new file mode 100644 index 0000000..fd2469b --- /dev/null +++ b/frontend/src/views/settings/PrintedPartsSettings.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/plugins/printedparts/README.md b/plugins/printedparts/README.md new file mode 100644 index 0000000..9cec4a1 --- /dev/null +++ b/plugins/printedparts/README.md @@ -0,0 +1,59 @@ +# Printedparts plugin + +3D-printed parts inventory + kiosk checkout + +This plugin was generated by `flask plugin new printedparts`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain. + +## What's here + +- `plugin.py` - the `PrintedpartsPlugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data. +- `models/printedparts.py` - example Asset extension table. Replace `examplefield` with your domain fields. +- `api/routes.py` - example list and detail endpoints. Add CRUD as needed. +- `schemas/__init__.py` - marshmallow schema stub for request/response validation. +- `tests/test_plugin.py` - smoke tests asserting contract compliance. +- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad. + +## Common edits + +| You want to... | Do this | +|---|---| +| Add a hook (search, navigation, dashboard widget) | Override the method in `PrintedpartsPlugin`. See `docs/PLUGIN-HOOKS.md`. | +| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. | +| Add another model | Create `models/.py`, export it in `models/__init__.py`, return it in `get_models()`. | +| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. | + +## Frontend + +Vue components for this plugin live under `frontend/src/views/printedparts/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. + +## Install and run + +```bash +flask plugin install printedparts +flask db migrate -m "Add printedparts plugin tables" +flask db upgrade +pytest plugins/printedparts/tests/ +``` + +## References + +- `docs/PLUGIN-HOOKS.md` - canonical hook reference +- `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. diff --git a/plugins/printedparts/__init__.py b/plugins/printedparts/__init__.py new file mode 100644 index 0000000..90bc9c3 --- /dev/null +++ b/plugins/printedparts/__init__.py @@ -0,0 +1,5 @@ +"""Printedparts plugin package.""" + +from .plugin import PrintedpartsPlugin + +__all__ = ['PrintedpartsPlugin'] diff --git a/plugins/printedparts/api/__init__.py b/plugins/printedparts/api/__init__.py new file mode 100644 index 0000000..2043056 --- /dev/null +++ b/plugins/printedparts/api/__init__.py @@ -0,0 +1,5 @@ +"""Printedparts plugin API package.""" + +from .routes import printedparts_bp + +__all__ = ['printedparts_bp'] diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py new file mode 100644 index 0000000..18c3072 --- /dev/null +++ b/plugins/printedparts/api/routes.py @@ -0,0 +1,679 @@ +"""Printedparts plugin API routes. + +Access model: browsing the catalog (items, detail, file listings) requires +the printedparts.view permission; every mutation carries its own permission. +Deliberately open: the kiosk endpoints (decision record in the proposal), +the image serve and file download (fetched by tags and anchor +downloads, which cannot carry a JWT header), and the reports (jwt-optional +like every other report in the product). +""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required +from sqlalchemy import or_ + +from shopdb.api import ( + db, + success_response, + error_response, + paginated_response, + ErrorCodes, + get_pagination_params, + paginate_query, + require_permission, +) + +from ..models import PrintedItem + +printedparts_bp = Blueprint('printedparts', __name__) + + +@printedparts_bp.route('/items', methods=['GET']) +@jwt_required() +@require_permission('printedparts.view') +def list_items(): + """List printed items, paginated; search + low-stock filter.""" + page, per_page = get_pagination_params(request) + query = PrintedItem.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(PrintedItem.isactive == True) + if search := request.args.get('search'): + like = f'%{search}%' + query = query.filter(or_( + PrintedItem.itemcode.ilike(like), + PrintedItem.itemname.ilike(like), + PrintedItem.itemdescription.ilike(like), + PrintedItem.binlocation.ilike(like), + )) + if request.args.get('lowstock', '').lower() == 'true': + query = query.filter( + PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold) + query = query.order_by(PrintedItem.itemname) + items, total = paginate_query(query, page, per_page) + return paginated_response( + [item.to_dict() for item in items], page, per_page, total) + + +@printedparts_bp.route('/items/', methods=['GET']) +@jwt_required() +@require_permission('printedparts.view') +def get_item(item_id: int): + """Get one printed item with its recent transactions.""" + item = db.session.get(PrintedItem, item_id) + if not item: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', + http_code=404) + data = item.to_dict() + recent = (item.transactions + .order_by(db.desc('transactiondate')) + .limit(25).all()) + data['recenttransactions'] = [t.to_dict() for t in recent] + return success_response(data) + + +# --- catalog mutations (stage 6 adds permission gates on top of jwt) -------- + +import glob +import os + +from flask import current_app +from werkzeug.utils import secure_filename + +from shopdb.api import Setting + +IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'} +IMAGE_URL_PREFIX = '/api/printedparts/image/' + +EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold', + 'binlocation', 'printnotes') + + +def _imagedir(): + return os.path.join(current_app.instance_path, 'printedpartsimages') + + +def _mint_itemcode(item): + """Set itemcode from the configured prefix + the flushed row id.""" + prefix = Setting.get('printedparts_code_prefix') or '3DP' + item.itemcode = f'{prefix}{item.printeditemid:04d}' + + +@printedparts_bp.route('/items', methods=['POST']) +@jwt_required() +@require_permission('printedparts.create') +def create_item(): + """Create a printed item; the itemcode is minted from the row id.""" + data = request.get_json() or {} + itemname = (data.get('itemname') or '').strip() + if not itemname: + return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required') + + threshold = data.get('lowstockthreshold') + if threshold is None: + threshold = int(Setting.get('printedparts_default_threshold') or 5) + + item = PrintedItem( + itemname=itemname, + itemdescription=data.get('itemdescription'), + lowstockthreshold=threshold, + binlocation=data.get('binlocation'), + printnotes=data.get('printnotes'), + quantityonhand=0, + ) + db.session.add(item) + db.session.flush() # assigns printeditemid + _mint_itemcode(item) + db.session.commit() + return success_response(item.to_dict(), message='Printed item created', + http_code=201) + + +@printedparts_bp.route('/items/', methods=['PUT']) +@jwt_required() +@require_permission('printedparts.edit') +def update_item(item_id: int): + """Update catalog fields. Quantity moves ONLY through the ledger.""" + item = db.session.get(PrintedItem, item_id) + if not item: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + data = request.get_json() or {} + if 'quantityonhand' in data: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'quantityonhand is ledger-managed; use restock or adjust') + for field in EDITABLE_FIELDS: + if field in data: + setattr(item, field, data[field]) + db.session.commit() + return success_response(item.to_dict(), message='Printed item updated') + + +@printedparts_bp.route('/items/', methods=['DELETE']) +@jwt_required() +@require_permission('printedparts.delete') +def delete_item(item_id: int): + """Soft-retire an item; its ledger history stays.""" + item = db.session.get(PrintedItem, item_id) + if not item: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + item.isactive = False + db.session.commit() + return success_response(message='Printed item retired') + + +@printedparts_bp.route('/items//restore', methods=['POST']) +@jwt_required() +@require_permission('printedparts.delete') +def restore_item(item_id: int): + """Bring a retired item back; code, photo, and history are intact.""" + item = db.session.get(PrintedItem, item_id) + if not item: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + item.isactive = True + db.session.commit() + return success_response(item.to_dict(), message='Printed item restored') + + +# --- item image: the models.py upload/serve/delete trio --------------------- + +@printedparts_bp.route('/items//image', methods=['POST']) +@jwt_required() +@require_permission('printedparts.edit') +def upload_item_image(item_id: int): + """Upload (or replace) the photo for an item (multipart file=).""" + item = db.session.get(PrintedItem, item_id) + if not item: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in IMAGE_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + imagedir = _imagedir() + os.makedirs(imagedir, exist_ok=True) + for old in glob.glob(os.path.join( + imagedir, secure_filename(f'printeditem-{item_id}') + '.*')): + os.remove(old) + + filename = secure_filename(f'printeditem-{item_id}{ext}') + upload.save(os.path.join(imagedir, filename)) + item.imageurl = f'{IMAGE_URL_PREFIX}{filename}' + db.session.commit() + return success_response(item.to_dict(), message='Item image uploaded') + + +@printedparts_bp.route('/image/', methods=['GET']) +def serve_item_image(filename): + """Serve an uploaded item image (public - kiosk and list read it).""" + from flask import send_from_directory + return send_from_directory(_imagedir(), filename) + + +@printedparts_bp.route('/items//image', methods=['DELETE']) +@jwt_required() +@require_permission('printedparts.delete') +def delete_item_image(item_id: int): + """Clear an item image; delete the file only if this plugin owns it.""" + item = db.session.get(PrintedItem, item_id) + if not item: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + url = item.imageurl or '' + if url.startswith(IMAGE_URL_PREFIX): + filename = secure_filename(url[len(IMAGE_URL_PREFIX):]) + path = os.path.join(_imagedir(), filename) + if os.path.exists(path): + os.remove(path) + item.imageurl = None + db.session.commit() + return success_response(item.to_dict(), message='Item image removed') + + +# --- the ledger: restock and adjust (stage 6 gates with printedparts.restock) + +from ..models import PrintedItemTransaction +from ..services.badges import BadgeError, resolve_badge + + +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. 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, + transactiontype=transactiontype, + quantitychange=quantitychange, + employeesso=sso, + employeename=name, + reason=reason, + )) + db.session.commit() + if (quantitychange < 0 + and quantitybefore > item.lowstockthreshold + and item.quantityonhand <= item.lowstockthreshold): + _send_lowstock_alert(item) + + +def _alert_recipients(): + """Merge selected shopdb users' account emails with the free-text list. + + Empty result means fall back to the site-wide alert_recipients.""" + from shopdb.api import User, Role + recipients = [] + userids = (Setting.get('printedparts_alert_userids') or '').strip() + for rawid in userids.split(','): + rawid = rawid.strip() + if not rawid.isdigit(): + continue + user = db.session.get(User, int(rawid)) + if user and user.isactive and user.email: + recipients.append(user.email) + roleids = (Setting.get('printedparts_alert_roleids') or '').strip() + for rawid in roleids.split(','): + rawid = rawid.strip() + if not rawid.isdigit(): + continue + role = db.session.get(Role, int(rawid)) + if role: + recipients.extend(member.email for member in role.users + if member.isactive and member.email) + extra = (Setting.get('printedparts_alert_email') or '').strip() + recipients.extend(address.strip() for address in extra.split(',') + if address.strip()) + # dedupe, order-preserving + return list(dict.fromkeys(recipients)) + + +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'

{item.itemname} ({item.itemcode}) is down ' + f'to {item.quantityonhand} ' + f'(threshold {item.lowstockthreshold}).

' + f'

Bin: {item.binlocation or "-"}

' + f'

Time to print more.

') + try: + recipients = _alert_recipients() + if recipients: + send_email(recipients, 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//restock', methods=['POST']) +@jwt_required() +@require_permission('printedparts.restock') +def restock_item(item_id: int): + """Add freshly printed stock. Body: {quantity, badge}.""" + item = db.session.get(PrintedItem, item_id) + if not item or not item.isactive: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + data = request.get_json() or {} + quantity = data.get('quantity') + if not isinstance(quantity, int) or quantity < 1: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'quantity must be a positive integer') + 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, 'restock', quantity, sso, name) + return success_response(item.to_dict(), message='Stock added') + + +@printedparts_bp.route('/items//adjust', methods=['POST']) +@jwt_required() +@require_permission('printedparts.restock') +def adjust_item(item_id: int): + """Correct the count (damage, recount). Body: {quantitychange, reason, badge}.""" + item = db.session.get(PrintedItem, item_id) + if not item or not item.isactive: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + data = request.get_json() or {} + quantitychange = data.get('quantitychange') + if not isinstance(quantitychange, int) or quantitychange == 0: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'quantitychange must be a non-zero integer') + reason = (data.get('reason') or '').strip() + if not reason: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'reason is required for an adjustment') + if item.quantityonhand + quantitychange < 0: + return error_response( + ErrorCodes.VALIDATION_ERROR, + f'Adjustment would drive stock below zero ' + f'(on hand: {item.quantityonhand})') + 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, '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. + +def _kiosk_find_item(itemcode): + """Resolve a scanned or typed code to an active item. + + Accepts the full code (WJRP0042) or bare digits from the touch keypad + (42 -> prefix + zero-pad), so manual entry never needs letters.""" + itemcode = (itemcode or '').strip() + item = PrintedItem.query.filter( + PrintedItem.itemcode == itemcode, + PrintedItem.isactive == True).first() + if not item and itemcode.isdigit(): + # The digits in a minted code ARE the row id, so id lookup keeps + # working even for labels printed under an older prefix. + candidate = db.session.get(PrintedItem, int(itemcode)) + if candidate and candidate.isactive: + item = candidate + return item + + +@printedparts_bp.route('/kiosk/item/', methods=['GET']) +def kiosk_item(itemcode): + """Item summary for a scanned bin barcode (open read for the kiosk).""" + item = _kiosk_find_item(itemcode) + 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 = _kiosk_find_item(data.get('itemcode')) + 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= (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= (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}) + + +# --- print files: append-only revisions per item ------------------------------ + +from flask_jwt_extended import get_jwt_identity + +from ..models import PrintedItemFile + +FILE_EXTENSIONS = {'.stl', '.3mf', '.gcode', '.gco', '.bgcode', '.step', + '.stp', '.obj', '.amf'} +MAX_FILE_BYTES = 100 * 1024 * 1024 + + +def _filedir(): + return os.path.join(current_app.instance_path, 'printedpartsfiles') + + +def _uploader_name(): + from shopdb.api import User + identity = get_jwt_identity() + try: + user = db.session.get(User, int(identity)) + if user: + return user.username + except (TypeError, ValueError): + pass + return str(identity) + + +@printedparts_bp.route('/items//files', methods=['GET']) +@jwt_required() +@require_permission('printedparts.view') +def list_item_files(item_id: int): + """Revision history, newest first.""" + files = (PrintedItemFile.query.filter_by(printeditemid=item_id) + .order_by(PrintedItemFile.revision.desc()).all()) + return success_response([f.to_dict() for f in files]) + + +@printedparts_bp.route('/items//files', methods=['POST']) +@jwt_required() +@require_permission('printedparts.edit') +def upload_item_file(item_id: int): + """Upload the next revision of the item's print file. + + multipart/form-data: file=, note=. + Revisions are append-only; nothing is replaced. + """ + item = db.session.get(PrintedItem, item_id) + if not item: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in FILE_EXTENSIONS: + return error_response( + ErrorCodes.VALIDATION_ERROR, + f'Unsupported file type {ext}; allowed: ' + + ', '.join(sorted(FILE_EXTENSIONS))) + + upload.stream.seek(0, os.SEEK_END) + filesize = upload.stream.tell() + upload.stream.seek(0) + if filesize > MAX_FILE_BYTES: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'File exceeds the 100 MB limit') + + latest = (db.session.query(db.func.max(PrintedItemFile.revision)) + .filter_by(printeditemid=item_id).scalar()) or 0 + revision = latest + 1 + + filedir = _filedir() + os.makedirs(filedir, exist_ok=True) + storedfilename = secure_filename( + f'printeditem-{item_id}-rev{revision}{ext}') + upload.save(os.path.join(filedir, storedfilename)) + + record = PrintedItemFile( + printeditemid=item_id, + revision=revision, + filename=secure_filename(upload.filename), + storedfilename=storedfilename, + filesize=filesize, + uploadnote=(request.form.get('note') or '').strip() or None, + uploadedby=_uploader_name(), + ) + db.session.add(record) + db.session.commit() + return success_response(record.to_dict(), + message=f'Revision {revision} uploaded', + http_code=201) + + +@printedparts_bp.route('/files//download', methods=['GET']) +@jwt_required(optional=True) +def download_item_file(file_id: int): + """Download a revision under its original filename.""" + from flask import send_from_directory + record = db.session.get(PrintedItemFile, file_id) + if not record: + return error_response(ErrorCodes.NOT_FOUND, 'File not found', + http_code=404) + return send_from_directory(_filedir(), record.storedfilename, + as_attachment=True, + download_name=record.filename) + + +@printedparts_bp.route('/files/', methods=['DELETE']) +@jwt_required() +@require_permission('printedparts.delete') +def delete_item_file(file_id: int): + """Remove a bad revision (wrong file uploaded). History otherwise stays.""" + record = db.session.get(PrintedItemFile, file_id) + if not record: + return error_response(ErrorCodes.NOT_FOUND, 'File not found', + http_code=404) + path = os.path.join(_filedir(), record.storedfilename) + if os.path.exists(path): + os.remove(path) + db.session.delete(record) + db.session.commit() + return success_response(message='Revision removed') diff --git a/plugins/printedparts/frontend-api-snippet.js b/plugins/printedparts/frontend-api-snippet.js new file mode 100644 index 0000000..7dfebb8 --- /dev/null +++ b/plugins/printedparts/frontend-api-snippet.js @@ -0,0 +1,35 @@ +/* + * Printedparts API client snippet. + * + * Paste this printedpartsApi block into frontend/src/api/index.js (next to the + * other per-resource blocks). Then, in the generated PrintedpartsList, PrintedpartsDetail, + * and PrintedpartsForm views, delete the local printedpartsApi const and import the shared + * one instead: + * + * import { printedpartsApi } from '../../api' + * + * The scaffolded views ship with an identical inline client so they build and + * run before you touch the shared api module. This file is NOT auto-merged into + * api/index.js on purpose; that module is hand-maintained and shared. + * + * The create/update/delete calls assume matching POST/PUT/DELETE routes exist + * on the backend. The scaffolded api/routes.py only ships list and get; add the + * write endpoints when you wire up the form. + */ +export const printedpartsApi = { + list(params = {}) { + return api.get('/printedparts', { params }) + }, + get(itemId) { + return api.get(`/printedparts/${itemId}`) + }, + create(data) { + return api.post('/printedparts', data) + }, + update(itemId, data) { + return api.put(`/printedparts/${itemId}`, data) + }, + remove(itemId) { + return api.delete(`/printedparts/${itemId}`) + } +} diff --git a/plugins/printedparts/manifest.json b/plugins/printedparts/manifest.json new file mode 100644 index 0000000..dfb0231 --- /dev/null +++ b/plugins/printedparts/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "printedparts", + "version": "0.1.0", + "description": "3D-printed parts inventory + kiosk checkout", + "display_name": "3D Printed Parts", + "author": "", + "dependencies": ["employees"], + "core_version": ">=0.12.0,<1.0.0", + "api_prefix": "/api/printedparts", + "default_enabled": false +} diff --git a/plugins/printedparts/migrations/env.py b/plugins/printedparts/migrations/env.py new file mode 100644 index 0000000..1490d7a --- /dev/null +++ b/plugins/printedparts/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the printedparts plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_printedparts (ADR-008). This plugin +is NEW (post-cutover): its 0001 baseline really CREATES its tables. +""" +import os + +os.environ['PLUGIN_NAME'] = 'printedparts' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/printedparts/migrations/script.py.mako b/plugins/printedparts/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/plugins/printedparts/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py b/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py new file mode 100644 index 0000000..f0bb348 --- /dev/null +++ b/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py @@ -0,0 +1,71 @@ +"""printedparts plugin baseline (real create). + +Post-ADR-008 plugin: this per-plugin chain is the sole authoritative creator +of printeditems and printeditemtransactions - the core chain never knew them. +Runs from `flask plugin install printedparts` (and `flask plugin upgrade-all`) +after `flask db upgrade` builds the core schema. + +Both tables are self-contained (the only FK is transactions -> items inside +the plugin), so the shared create_plugin_tables helper would work here; the +ops are written out explicitly anyway to match the measuringtools exemplar +and keep the baseline reviewable. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'printedparts0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'printeditems', + sa.Column('printeditemid', sa.Integer(), nullable=False), + sa.Column('itemcode', sa.String(length=20), nullable=True), + sa.Column('itemname', sa.String(length=120), nullable=False), + sa.Column('itemdescription', sa.String(length=500), nullable=True), + sa.Column('imageurl', sa.String(length=255), nullable=True), + sa.Column('quantityonhand', sa.Integer(), nullable=False), + sa.Column('lowstockthreshold', sa.Integer(), nullable=False), + sa.Column('binlocation', sa.String(length=100), nullable=True), + sa.Column('printnotes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('printeditemid'), + sa.UniqueConstraint('itemcode'), + ) + op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode']) + + op.create_table( + 'printeditemtransactions', + sa.Column('transactionid', sa.Integer(), nullable=False), + sa.Column('printeditemid', sa.Integer(), nullable=False), + sa.Column('transactiontype', sa.String(length=10), nullable=False), + sa.Column('quantitychange', sa.Integer(), nullable=False), + sa.Column('employeesso', sa.String(length=20), nullable=False), + sa.Column('employeename', sa.String(length=120), nullable=True), + sa.Column('reason', sa.String(length=255), nullable=True), + sa.Column('transactiondate', sa.DateTime(), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['printeditemid'], ['printeditems.printeditemid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('transactionid'), + ) + op.create_index('ix_printeditemtransactions_printeditemid', + 'printeditemtransactions', ['printeditemid']) + op.create_index('ix_printeditemtransactions_employeesso', + 'printeditemtransactions', ['employeesso']) + op.create_index('ix_printeditemtransactions_transactiondate', + 'printeditemtransactions', ['transactiondate']) + + +def downgrade(): + op.drop_table('printeditemtransactions') + op.drop_table('printeditems') diff --git a/plugins/printedparts/migrations/versions/0002_printeditemfiles.py b/plugins/printedparts/migrations/versions/0002_printeditemfiles.py new file mode 100644 index 0000000..d0b2c9a --- /dev/null +++ b/plugins/printedparts/migrations/versions/0002_printeditemfiles.py @@ -0,0 +1,43 @@ +"""Add printeditemfiles: append-only print-file revisions per item. + +The plugin's first incremental migration on top of its 0001 baseline - +the ADR-008 payoff: the plugin evolves its own schema without touching +the core chain. Applied by `flask plugin upgrade-all`. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'printedparts0002files' +down_revision = 'printedparts0001baseline' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'printeditemfiles', + sa.Column('fileid', sa.Integer(), nullable=False), + sa.Column('printeditemid', sa.Integer(), nullable=False), + sa.Column('revision', sa.Integer(), nullable=False), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('storedfilename', sa.String(length=191), nullable=False), + sa.Column('filesize', sa.Integer(), nullable=False), + sa.Column('uploadnote', sa.String(length=255), nullable=True), + sa.Column('uploadedby', sa.String(length=80), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['printeditemid'], + ['printeditems.printeditemid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('fileid'), + sa.UniqueConstraint('storedfilename'), + ) + op.create_index('ix_printeditemfiles_printeditemid', + 'printeditemfiles', ['printeditemid']) + + +def downgrade(): + op.drop_table('printeditemfiles') diff --git a/plugins/printedparts/models/__init__.py b/plugins/printedparts/models/__init__.py new file mode 100644 index 0000000..b39cb08 --- /dev/null +++ b/plugins/printedparts/models/__init__.py @@ -0,0 +1,11 @@ +"""Printedparts plugin models.""" + +from .printeditem import ( + PrintedItem, + PrintedItemTransaction, + PrintedItemFile, + TRANSACTION_TYPES, +) + +__all__ = ['PrintedItem', 'PrintedItemTransaction', 'PrintedItemFile', + 'TRANSACTION_TYPES'] diff --git a/plugins/printedparts/models/printeditem.py b/plugins/printedparts/models/printeditem.py new file mode 100644 index 0000000..9f924e1 --- /dev/null +++ b/plugins/printedparts/models/printeditem.py @@ -0,0 +1,135 @@ +"""Printedparts models. + +PrintedItem is a KIND of 3D-printed part with a quantity on hand - a +consumable, not an ADR-001 asset (which is one row per physical thing). +PrintedItemTransaction is the ledger: every take, restock, and adjust as a +signed quantity change attributed to a badge-resolved employee. The ledger is +the source of truth; quantityonhand is a cache moved in the same commit as +each ledger write, and the stock report reconciles the two. +""" + +from datetime import datetime, timezone + +from shopdb.api import db, BaseModel + + +def _utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +TRANSACTION_TYPES = ('take', 'restock', 'adjust') + + +class PrintedItem(BaseModel): + """A printable part the engineers stock in bins.""" + + __tablename__ = 'printeditems' + + printeditemid = db.Column(db.Integer, primary_key=True) + itemcode = db.Column(db.String(20), unique=True, index=True, + comment='Generated bin-label code, e.g. 3DP-0042') + itemname = db.Column(db.String(120), nullable=False) + itemdescription = db.Column(db.String(500)) + imageurl = db.Column(db.String(255)) + quantityonhand = db.Column(db.Integer, nullable=False, default=0) + lowstockthreshold = db.Column(db.Integer, nullable=False, default=5) + binlocation = db.Column(db.String(100)) + printnotes = db.Column(db.Text, comment='Material, print time, slicer file') + + transactions = db.relationship( + 'PrintedItemTransaction', backref='printeditem', + cascade='all, delete-orphan', passive_deletes=True, lazy='dynamic') + + @property + def islowstock(self): + return self.quantityonhand <= self.lowstockthreshold + + def to_dict(self): + return { + 'printeditemid': self.printeditemid, + 'itemcode': self.itemcode, + 'itemname': self.itemname, + 'itemdescription': self.itemdescription, + 'imageurl': self.imageurl, + 'quantityonhand': self.quantityonhand, + 'lowstockthreshold': self.lowstockthreshold, + 'islowstock': self.islowstock, + 'binlocation': self.binlocation, + 'printnotes': self.printnotes, + 'isactive': self.isactive, + 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, + 'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None, + } + + +class PrintedItemTransaction(BaseModel): + """One signed stock movement, always attributed to an employee.""" + + __tablename__ = 'printeditemtransactions' + + transactionid = db.Column(db.Integer, primary_key=True) + printeditemid = db.Column( + db.Integer, + db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'), + nullable=False, index=True) + transactiontype = db.Column(db.String(10), nullable=False, + comment='take, restock, or adjust') + quantitychange = db.Column(db.Integer, nullable=False, + comment='Negative for take, signed for adjust') + employeesso = db.Column(db.String(20), nullable=False, index=True) + employeename = db.Column(db.String(120)) + reason = db.Column(db.String(255)) + transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow, + index=True) + + def to_dict(self): + return { + 'transactionid': self.transactionid, + 'printeditemid': self.printeditemid, + 'transactiontype': self.transactiontype, + 'quantitychange': self.quantitychange, + 'employeesso': self.employeesso, + 'employeename': self.employeename, + 'reason': self.reason, + 'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None, + } + + +class PrintedItemFile(BaseModel): + """One uploaded revision of an item's print file (STL/3MF/gcode/...). + + Revisions are append-only per item: uploading assigns the next revision + number and never replaces earlier files, so the history of what was + actually printed stays reconstructible. The current file is simply the + highest revision. + """ + + __tablename__ = 'printeditemfiles' + + fileid = db.Column(db.Integer, primary_key=True) + printeditemid = db.Column( + db.Integer, + db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'), + nullable=False, index=True) + revision = db.Column(db.Integer, nullable=False) + filename = db.Column(db.String(255), nullable=False, + comment='Original upload name, used for download') + storedfilename = db.Column(db.String(191), nullable=False, unique=True, + comment='191: unique index fits the 767-byte MySQL prefix') + filesize = db.Column(db.Integer, nullable=False) + uploadnote = db.Column(db.String(255), + comment='What changed in this revision') + uploadedby = db.Column(db.String(80), nullable=False, + comment='Username of the uploader') + + def to_dict(self): + return { + 'fileid': self.fileid, + 'printeditemid': self.printeditemid, + 'revision': self.revision, + 'filename': self.filename, + 'filesize': self.filesize, + 'uploadnote': self.uploadnote, + 'uploadedby': self.uploadedby, + 'uploadeddate': self.createddate.isoformat() + 'Z' if self.createddate else None, + } diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py new file mode 100644 index 0000000..38a82c9 --- /dev/null +++ b/plugins/printedparts/plugin.py @@ -0,0 +1,147 @@ +"""Printedparts plugin main class. + +3D-printed parts inventory + kiosk checkout. Quantity-based consumables: +one row is a KIND of part with a count, not an individually tracked asset, +so unlike most plugins this one seeds NO AssetType (ADR-001 assets are +one-row-per-physical-thing). See docs/proposals/printedparts-plugin.md. +""" + +import json +import logging +from pathlib import Path +from typing import List, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, Setting + +from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile +from .api import printedparts_bp + +logger = logging.getLogger(__name__) + + +class PrintedpartsPlugin(BasePlugin): + """3D-printed parts inventory + kiosk checkout.""" + + def __init__(self): + manifest_path = Path(__file__).parent / 'manifest.json' + with open(manifest_path) as f: + self._manifest = json.load(f) + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest['name'], + version=self._manifest['version'], + description=self._manifest['description'], + author=self._manifest.get('author', ''), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0'), + api_prefix=self._manifest.get('api_prefix'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return printedparts_bp + + def get_models(self) -> List[Type]: + return [PrintedItem, PrintedItemTransaction, PrintedItemFile] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f'Printedparts plugin initialized (v{self.meta.version})') + + def get_permissions(self) -> List: + """RBAC permissions this plugin owns (seeded on install/enable).""" + return [ + ('printedparts.view', 'View 3D printed parts', 'printedparts'), + ('printedparts.create', 'Create printed parts', 'printedparts'), + ('printedparts.edit', 'Edit printed parts', 'printedparts'), + ('printedparts.delete', 'Retire printed parts', 'printedparts'), + ('printedparts.restock', 'Restock and adjust stock counts', + '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 [ + { + 'name': '3D Parts', + 'icon': 'box', + 'route': '/printedparts', + 'position': 46, + }, + ] + + def on_install(self, app: Flask) -> None: + with app.app_context(): + 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', + 'Prefix for generated item codes'), + ('printedparts_default_threshold', '5', 'integer', + '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'), + ('printedparts_alert_userids', '', 'string', + 'Comma-separated shopdb user ids whose account emails receive ' + 'low-stock alerts'), + ('printedparts_alert_roleids', '', 'string', + 'Comma-separated role ids; every active member of these roles ' + 'receives low-stock alerts'), + ] + for key, value, valuetype, description in defaults: + if Setting.get(key) is None: + Setting.set(key, value, valuetype=valuetype, + category='printedparts', description=description) + db.session.commit() diff --git a/plugins/printedparts/schemas/__init__.py b/plugins/printedparts/schemas/__init__.py new file mode 100644 index 0000000..adde023 --- /dev/null +++ b/plugins/printedparts/schemas/__init__.py @@ -0,0 +1,6 @@ +"""Printedparts plugin schemas (marshmallow). + +Add schema classes here when you need request/response validation +beyond the simple to_dict() output. The framework wires marshmallow +into the response helpers; see docs/PLUGIN-HOOKS.md for details. +""" diff --git a/plugins/printedparts/services/__init__.py b/plugins/printedparts/services/__init__.py new file mode 100644 index 0000000..21758a6 --- /dev/null +++ b/plugins/printedparts/services/__init__.py @@ -0,0 +1 @@ +"""Printedparts plugin services.""" diff --git a/plugins/printedparts/services/badges.py b/plugins/printedparts/services/badges.py new file mode 100644 index 0000000..81c7f43 --- /dev/null +++ b/plugins/printedparts/services/badges.py @@ -0,0 +1,69 @@ +"""Badge resolution for the printedparts plugin. + +Same input contract as the USB plugin (deliberately copied, not imported - +cross-plugin imports break the shopdb.api-only contract): + +- all digits -> an SSO typed or scanned directly +- 0BZ -> a physical badge wrapping a PayNo (keyboard-wedge + scanners emit this shape) +- anything else -> unresolvable + +Names come from the employees plugin's self-hosted directory, looked up by +SSO. The directory carries no PayNo column, so PayNo badges resolve only when +the wrapped digits are themselves the SSO (true at sites whose badges encode +the SSO); otherwise they fall to the unknown-badge policy. + +Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge +with no directory match; 'allow' records the SSO with an empty name. +""" + +import re + +from shopdb.api import Setting + +_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE) + + +class BadgeError(ValueError): + """Raised when a badge cannot be accepted under the site policy.""" + + +def _directory_name(sso): + """Best-effort display name from the employees plugin directory.""" + try: + from plugins.employees.models import DirectoryEmployee + from shopdb.api import db + if sso and str(sso).isdigit(): + employee = db.session.get(DirectoryEmployee, int(sso)) + if employee: + return f'{employee.firstname} {employee.lastname}'.strip() + except Exception: + pass + return None + + +def resolve_badge(badge): + """Return (sso, name) for a scanned badge, enforcing the site policy. + + Raises BadgeError with a kiosk-displayable message when the badge shape is + unrecognized or the policy denies an unmatched badge. + """ + badge = (badge or '').strip() + if not badge: + raise BadgeError('Scan or enter a badge') + + if badge.isdigit(): + sso = badge + else: + match = _PAYNO_BADGE.match(badge) + if not match: + raise BadgeError('Unrecognized badge format') + sso = match.group(1) + + name = _directory_name(sso) + if name is None: + policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower() + if policy != 'allow': + raise BadgeError('Badge not recognized - see the parts team') + return sso, '' + return sso, name diff --git a/plugins/printedparts/tests/__init__.py b/plugins/printedparts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/printedparts/tests/test_plugin.py b/plugins/printedparts/tests/test_plugin.py new file mode 100644 index 0000000..8ec0d05 --- /dev/null +++ b/plugins/printedparts/tests/test_plugin.py @@ -0,0 +1,30 @@ +"""Printedparts plugin smoke tests. + +Asserts the plugin loads cleanly and satisfies the framework contract. +Replace and extend with domain tests as you build the plugin out. +""" + +from plugins.printedparts.plugin import PrintedpartsPlugin + + +def test_printedparts_plugin_meta_is_valid(): + """PrintedpartsPlugin.meta returns a PluginMeta with the expected name.""" + plugin = PrintedpartsPlugin() + assert plugin.meta.name == 'printedparts' + assert plugin.meta.api_prefix == '/api/printedparts' + + +def test_printedparts_plugin_get_blueprint_returns_blueprint(): + """get_blueprint returns a Flask Blueprint, not None.""" + from flask import Blueprint + plugin = PrintedpartsPlugin() + assert isinstance(plugin.get_blueprint(), Blueprint) + + +def test_printedparts_plugin_get_models_returns_a_model(): + """get_models returns a list with at least one SQLAlchemy model.""" + plugin = PrintedpartsPlugin() + models = plugin.get_models() + assert len(models) >= 1 + for model in models: + assert hasattr(model, '__tablename__') diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 2a6857a..a209c4d 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -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.13.0' # Product release version (see ADR-007). The product version and the # plugin-contract version above are distinct series with independent diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index d4b2ac9..be2c5a0 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -44,6 +44,8 @@ from shopdb.core.models import ( OperatingSystem, AssetRelationship, RelationshipType, + User, + Role, ) # Response + pagination helpers for plugin API blueprints @@ -78,6 +80,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 +269,10 @@ __all__ = [ 'parse_import_datetime', # Legacy employee directory 'employee_connection', + 'send_email', + 'send_alert', + 'User', + 'Role', # CMMC USB check-in/out database 'cmmc_usb_connection', ] diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index ff7dd64..67c8702 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -53,6 +53,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'measuringtools': ('measuringtooltypes', 'measuringtools'), 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), 'notifications': ('notificationtypes', 'notifications'), + 'printedparts': ('printeditems', 'printeditemtransactions', + 'printeditemfiles'), 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'), 'slides': ('tvslides',), 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py index 75d4cd8..04a8fee 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -45,7 +45,12 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'} # shape as the exempt collector blueprint; the geenforce admin endpoints in # the same blueprint are JWT+permission gated and ARE swept. EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', - 'geenforce.post_report'} + 'geenforce.post_report', + # Deliberately open kiosk write: decrement-only, + # badge-attributed server-side. Decision record in + # docs/proposals/printedparts-plugin.md; justification in + # the plugin README. + 'printedparts.kiosk_take'} @pytest.fixture(autouse=True) diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 16e0269..f001ef8 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -54,6 +54,8 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo' # usb drops the dead usbcheckouts.machineid column on top of its anchor. EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid' +# printedparts is post-cutover: its 0001 really creates its tables. +EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0002files' # notifications indexes businessunitid on top of its anchor. EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx' diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py new file mode 100644 index 0000000..48f3aa3 --- /dev/null +++ b/tests/test_plugins/test_printedparts_ledger.py @@ -0,0 +1,340 @@ +"""Printedparts ledger + badge tests. + +The invariants that make the plugin trustworthy: itemcode minting, the +single-commit cache==ledger rule, the below-zero guard, quantity edits +forced through the ledger, and the badge contract (SSO digits, PayNo +wrap, unknown-badge policy). +""" + +import pytest + +from shopdb.api import db +from shopdb.core.models import Setting +from plugins.printedparts.models import PrintedItem, PrintedItemTransaction + + +@pytest.fixture +def item(app, db): + with app.app_context(): + row = PrintedItem(itemcode='3DP-9001', itemname='Test clip', + quantityonhand=0, lowstockthreshold=5) + db.session.add(row) + db.session.commit() + yield row.printeditemid + + +@pytest.fixture +def directory_employee(app): + with app.app_context(): + from plugins.employees.models import DirectoryEmployee + if not db.session.get(DirectoryEmployee, 502000001): + db.session.add(DirectoryEmployee( + sso=502000001, firstname='Pat', lastname='Printer')) + db.session.commit() + return '502000001' + + +def test_create_mints_itemcode(client, auth_headers): + response = client.post('/api/printedparts/items', json={'itemname': 'Bracket'}, + headers=auth_headers) + assert response.status_code == 201 + data = response.get_json()['data'] + assert data['itemcode'] == f"3DP{data['printeditemid']:04d}" + assert data['quantityonhand'] == 0 + + +def test_update_refuses_quantity(client, auth_headers, item): + response = client.put(f'/api/printedparts/items/{item}', + json={'quantityonhand': 50}, headers=auth_headers) + assert response.status_code == 400 + + +def test_restock_writes_ledger_and_cache(client, auth_headers, app, item, + directory_employee): + response = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 10, 'badge': directory_employee}, + headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['quantityonhand'] == 10 + with app.app_context(): + rows = PrintedItemTransaction.query.filter_by(printeditemid=item).all() + assert len(rows) == 1 + assert rows[0].transactiontype == 'restock' + assert rows[0].quantitychange == 10 + assert rows[0].employeename == 'Pat Printer' + cached = db.session.get(PrintedItem, item).quantityonhand + assert cached == sum(r.quantitychange for r in rows) + + +def test_payno_badge_shape_resolves(client, auth_headers, item, + directory_employee): + response = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 1, + 'badge': f'0{directory_employee}BZ'}, + headers=auth_headers) + assert response.status_code == 200 + + +def test_adjust_requires_reason_and_floors_at_zero(client, auth_headers, item, + directory_employee): + no_reason = client.post(f'/api/printedparts/items/{item}/adjust', + json={'quantitychange': -1, + 'badge': directory_employee}, + headers=auth_headers) + assert no_reason.status_code == 400 + + below_zero = client.post(f'/api/printedparts/items/{item}/adjust', + json={'quantitychange': -1, 'reason': 'test', + 'badge': directory_employee}, + headers=auth_headers) + assert below_zero.status_code == 400 + + +def test_unknown_badge_denied_then_allowed_by_policy(client, auth_headers, app, + item): + denied = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 1, 'badge': '999999999'}, + headers=auth_headers) + assert denied.status_code == 422 + + with app.app_context(): + Setting.set('printedparts_unknown_badge', 'allow', + valuetype='string', category='printedparts') + db.session.commit() + try: + allowed = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 1, 'badge': '999999999'}, + headers=auth_headers) + assert allowed.status_code == 200 + assert allowed.get_json()['data']['quantityonhand'] == 1 + finally: + with app.app_context(): + Setting.set('printedparts_unknown_badge', 'deny', + valuetype='string', category='printedparts') + db.session.commit() + + +def test_anonymous_cannot_mutate(client, item): + assert client.post('/api/printedparts/items', + json={'itemname': 'X'}).status_code == 401 + assert client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 1, 'badge': '1'}).status_code == 401 + + +def test_catalog_reads_require_view_permission(client, member_headers, item): + """Browsing the catalog is printedparts.view-gated; the kiosk stays open.""" + assert client.get('/api/printedparts/items').status_code == 401 + assert client.get(f'/api/printedparts/items/{item}').status_code == 401 + assert client.get('/api/printedparts/items', + headers=member_headers).status_code == 403 + assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200 + + +def test_member_without_permission_gets_403(client, member_headers, item): + """Authentication alone is not authorization: a role-less user is denied.""" + assert client.post('/api/printedparts/items', json={'itemname': 'X'}, + headers=member_headers).status_code == 403 + 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'] + + +def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app, + item, directory_employee, + monkeypatch): + """Selected shopdb users' account emails merge with the free-text list, + deduped; inactive users are skipped.""" + import shopdb.api as contract_surface + captured = {} + monkeypatch.setattr(contract_surface, 'send_email', + lambda to, subject, html, text=None: + captured.setdefault('to', to) or True) + + with app.app_context(): + from shopdb.api import User + from werkzeug.security import generate_password_hash + active = User(username='partslead', email='lead@site.test', + passwordhash=generate_password_hash('x'), isactive=True) + inactive = User(username='oldtimer', email='gone@site.test', + passwordhash=generate_password_hash('x'), + isactive=False) + db.session.add_all([active, inactive]) + db.session.commit() + Setting.set('printedparts_alert_userids', + f'{active.userid},{inactive.userid}', + valuetype='string', category='printedparts') + Setting.set('printedparts_alert_email', + 'extra@site.test, lead@site.test', + valuetype='string', category='printedparts') + db.session.commit() + + client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 10, 'badge': directory_employee}, + headers=auth_headers) + take = client.post('/api/printedparts/kiosk/take', + json={'itemcode': '3DP-9001', + 'badge': directory_employee, 'quantity': 6}) + assert take.status_code == 200 # 4 on hand: crossed threshold 5 + + assert captured['to'] == ['lead@site.test', 'extra@site.test'] + + +def test_retire_hides_and_restore_returns(client, auth_headers, item): + """Retire drops the item from the default list and the kiosk; restore + brings it back with history intact.""" + assert client.delete(f'/api/printedparts/items/{item}', + headers=auth_headers).status_code == 200 + + listed = client.get('/api/printedparts/items', + headers=auth_headers).get_json()['data'] + assert all(row['printeditemid'] != item for row in listed) + kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001') + assert kiosk.status_code == 404 + + including = client.get('/api/printedparts/items?active=false', + headers=auth_headers) + assert any(row['printeditemid'] == item + for row in including.get_json()['data']) + + assert client.post(f'/api/printedparts/items/{item}/restore', + headers=auth_headers).status_code == 200 + assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200 + + +def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path): + """Uploads mint sequential revisions; download returns the original name.""" + import io + + first = client.post(f'/api/printedparts/items/{item}/files', + data={'file': (io.BytesIO(b'solid part'), 'clip_v1.stl'), + 'note': 'initial'}, + headers=auth_headers, + content_type='multipart/form-data') + assert first.status_code == 201, first.get_json() + assert first.get_json()['data']['revision'] == 1 + + second = client.post(f'/api/printedparts/items/{item}/files', + data={'file': (io.BytesIO(b'G1 X0 Y0'), 'clip_v2.gcode')}, + headers=auth_headers, + content_type='multipart/form-data') + assert second.get_json()['data']['revision'] == 2 + + bad = client.post(f'/api/printedparts/items/{item}/files', + data={'file': (io.BytesIO(b'x'), 'malware.exe')}, + headers=auth_headers, + content_type='multipart/form-data') + assert bad.status_code == 400 + + listing = client.get(f'/api/printedparts/items/{item}/files', + headers=auth_headers).get_json()['data'] + assert [f['revision'] for f in listing] == [2, 1] + + fileid = listing[1]['fileid'] + download = client.get(f'/api/printedparts/files/{fileid}/download') + assert download.status_code == 200 + assert download.data == b'solid part' + assert 'clip_v1.stl' in download.headers['Content-Disposition'] + + +def test_alert_role_members_receive(client, auth_headers, app, item, + directory_employee, monkeypatch): + """Every active member of a selected role gets the alert.""" + import shopdb.api as contract_surface + captured = {} + monkeypatch.setattr(contract_surface, 'send_email', + lambda to, subject, html, text=None: + captured.setdefault('to', to) or True) + + with app.app_context(): + from shopdb.api import User, Role + from werkzeug.security import generate_password_hash + role = Role(rolename='partscrew', description='3D parts crew') + member = User(username='crewone', email='crewone@site.test', + passwordhash=generate_password_hash('x'), isactive=True) + member.roles.append(role) + db.session.add_all([role, member]) + db.session.commit() + Setting.set('printedparts_alert_roleids', str(role.roleid), + valuetype='string', category='printedparts') + db.session.commit() + + client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 10, 'badge': directory_employee}, + headers=auth_headers) + take = client.post('/api/printedparts/kiosk/take', + json={'itemcode': '3DP-9001', + 'badge': directory_employee, 'quantity': 6}) + assert take.status_code == 200 + assert captured['to'] == ['crewone@site.test']