31 Commits

Author SHA1 Message Date
cproudlock
3a3dff285e printedparts stage 17: gage-lab asset tag + print-files redesign
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The gage lab assigns real WJRP asset numbers, so identity splits: the
internal itemcode stays auto-minted and a new optional unique
gagelabtag (migration 0003) carries the lab's number - settable on
create/edit, searchable, and resolved by the kiosk for scans and bare
keypad digits against the numeric tail of either identifier
(unique-match only). The print-files table becomes stacked revision
cards - filename with rev/current badges, one meta line, delete pinned
right - ending the horizontal scroll in that column.
2026-07-17 14:01:09 -04:00
cproudlock
6160a5142a Dark mode: dropdown arrow no longer tiles across selects
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The dark .form-control override used the background shorthand, which
resets a select's background-repeat and position; the dark select rule
then re-added the arrow image without them, tiling it from the top
left. Use background-color in the overrides and restate
no-repeat/position on the select rule.
2026-07-17 13:37:24 -04:00
cproudlock
d75e80ce79 Plugin lab rewritten as the literal type-along walkthrough
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The milestone workbook becomes a from-scratch guide with the actual
code inline for every core stage: models, the real migration baseline,
read routes and the list page, mutations and minting, the badge
resolver (final mode-aware form), the single-commit ledger invariant,
RBAC gating, both kiosk endpoints with the wedge-input and focus-guard
mechanics, the 1x0.5in label CSS, and the reconcile query. Field
extensions stay summarized against their tags. New section: how to
contribute a plugin through GitHub (branch, stage commits, the three
CI gates, PR expectations, review checklist, and how publication
folds PRs into release commits).
2026-07-17 13:35:02 -04:00
cproudlock
ee80d684d4 Shopfloor feed resolves employee names live when none is stored
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Photos already resolved through the directory at read time, but names
only came from the stored employeename column - empty after a
shopdb-only import, so recertification/recognition cards showed bare
SSOs. New resolve_employee_display_name in the employees plugin
(mode-aware: self-hosted table or external HR) backs a fallback in
both the single-card and split-per-employee paths; stored names still
win when present.
2026-07-17 13:29:34 -04:00
cproudlock
bc9159742c printedparts lab: post-stage polish addendum and closing lesson
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
2026-07-17 13:24:11 -04:00
cproudlock
e9235de8ec Floor-map previews honor the mount path
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The uploaded-blueprint thumbnails on the map settings page and the
setup wizard used the raw setting value (/api/settings/map-blueprint/
...), which resolves at the server root and 404s under a subpath
mount - while the map itself resolves through blueprintUrlFor and
worked. Wrap the previews in withBase.
2026-07-17 11:43:06 -04:00
cproudlock
4f3ea2848a GE monogram avatar fallback + per-page document titles
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
Users without a profile photo (and broken photo URLs) show the GE
monogram instead of nothing/initials - sidebar identity, employee
detail hero, and the directory list thumbs; the shopfloor cards
already did this. Document titles become
'<Facility> ShopDB - <Page>' via a router afterEach (facility from
public settings, page label from meta.title or a prettified route
name with spellings for PCs/USB/GE-Enforce/3D Printed Parts/...), so
copied links and browser tabs identify the page.
2026-07-17 11:31:38 -04:00
cproudlock
5625608bd0 Employees: external photo base URL is a setting
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
External HR Picture values are relative paths; the resolver hardcoded
/static/employees/ (which the SPA then mounts under the subpath, e.g.
/ops/static/...), but sites like WJ serve those photos from the
classic EmployeeDBAPP on another URL entirely. New setting
employee_photo_base_url (blank keeps the old behavior; a full URL like
https://host/EmployeeDBAPP/images/ passes through withBase untouched),
declared in the plugin config schema.
2026-07-17 11:16:04 -04:00
cproudlock
0cc205d25e Users: deleting a user clears their API tokens and detaches audit rows
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Deleting any user who owned an API token or appeared in the audit log
hit the users FK and 500ed - the import's 'importer' account being the
guaranteed case (its PAT plus every audit row the import wrote).
Tokens are revoked outright; audit history is kept but detached
(userid NULL), so the trail survives the account.
2026-07-17 10:54:47 -04:00
cproudlock
bb5308bae0 printedparts: badge resolution honors the employee directory mode
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The resolver only read the self-hosted directory table, which is empty
at sites running the external HR directory - every kiosk badge fell to
the deny policy. It now branches on employee_directory_mode like the
usb plugin: selfhosted looks up DirectoryEmployee by SSO; external
queries the HR directory via employee_connection, resolving PayNo
badges by their real PayNo column and recovering the employee's SSO.
2026-07-17 10:39:11 -04:00
cproudlock
d297c5b75d IIS runbook: app pool needs Modify on instance/
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The plugin registry (instance/plugins.json), uploaded logos, floor
plans, item photos, and print files all write under instance/; with
the app pool at read-only, toggling a plugin in Settings surfaces as
an internal error and every upload fails. Grant Modify in step 7.3
and add the troubleshooting row.
2026-07-17 10:34:52 -04:00
cproudlock
deb6dd2162 Ignore the publication clone's _transfer bundle folder
Some checks failed
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
2026-07-17 10:01:34 -04:00
cproudlock
02ed88c7c5 Merge printedparts: 3D-printed parts storefront, kiosk, labels, alerts
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The plugin-lab exemplar built end to end: catalog with photos and
print-file revisions, badge-attributed stock ledger, touch kiosk with
an open decrement-only take endpoint (decision record), 1x0.5in bin
labels, low-stock alerts to users/roles/emails, reports with a
reconcile check, per-plugin migrations 0001+0002, contract 0.13.0
(mailer + User/Role on the plugin surface).
2026-07-17 09:20:43 -04:00
cproudlock
d1357defc4 printedparts: catalog access is printedparts.view-gated
Some checks failed
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
Browsing the catalog (item list, detail, file listings) now requires
authentication plus the view permission, and the /printedparts pages
and the label print page require login. Still deliberately open: the
kiosk endpoints per the decision record, the image serve and file
download (img tags and anchor downloads cannot carry a JWT), and the
reports (product-wide jwt-optional convention). Grant
printedparts.view to the roles that should see the catalog.
2026-07-17 09:19:30 -04:00
cproudlock
96e48e0f50 printedparts kiosk: keypad and entry-panel visual polish
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
The keypad becomes a proper terminal pad: fixed 3-column grid of
rounded square buttons with tabular numerals, press feedback, and
muted Clear/backspace actions. Each manual step (item number, SSO,
quantity) shares one card panel - boxed entry display with placeholder
styling, keypad, and a full-width action button.
2026-07-17 09:13:27 -04:00
cproudlock
4dfdb167d5 printedparts stage 16: kiosk touch fixes from first hands-on use
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The tap-anywhere wedge refocus stole focus from the manual-entry field
the moment it was tapped - the handler now only reclaims focus from
dead space, never from a real control. Manual entry works without a
physical keyboard: badge entry uses the TouchKeypad (an SSO is
digits), and item lookup accepts bare digits resolved by row id - the
digits in a minted code are the id, which also keeps labels printed
under an older prefix scannable after the prefix changes.
2026-07-17 09:04:52 -04:00
cproudlock
aa4bfcd41c printedparts stage 15: print-file revision history + role-based alerts
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
printeditemfiles lands as the plugin's first incremental migration
(0002 on the plugin chain - the ADR-008 payoff). Revisions are
append-only per item: upload assigns the next number, records the
uploader from the JWT, enforces an extension allowlist and a 100 MB
cap; download serves the original filename; a permission-gated delete
covers wrong-file mistakes. The detail page gains the revision table
with a current badge. Unique storedfilename is sized 191 so the index
fits MySQL's 767-byte prefix - the per-plugin chain does not apply the
core env's ROW_FORMAT hook.

Alert recipients gain roles: Role joins the 0.13.0 surface, a role
picker on the settings page, and every active member of the selected
roles is folded into the deduped recipient list.
2026-07-17 08:47:41 -04:00
cproudlock
26b6b6b32f printedparts: Parts Kiosk link in the sidebar Displays section
Beside Shopfloor Dashboard and TV Slideshow, opening in a new tab and
shown only while the plugin is enabled - kiosk-style pages get
launched from the Displays group, not the Information nav.
2026-07-17 08:38:57 -04:00
cproudlock
eab225e1e6 printedparts stage 14: retire/restore in the UI, dashless item codes
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
Retire button with confirmation on the detail page (item leaves the
storefront and the kiosk rejects its code; ledger history and label
survive), Restore on retired items, and an Include-retired list toggle
with a badge. Restore is its own permission-gated POST - the generic
update still cannot flip isactive. New codes mint as WJRP0042 style
without the dash; existing codes are immutable bin labels and keep
their form.
2026-07-17 08:35:31 -04:00
cproudlock
a8a6baf979 printedparts stage 13: pick alert recipients from shopdb users
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Contract 0.13.0 puts the User model on the plugin surface. The
settings page gains a checkbox picker over the user list; selected
users receive low-stock alerts at their account email, merged and
deduped with the free-text address list, inactive accounts skipped,
site alert_recipients still the fallback when both are empty.
2026-07-17 08:30:04 -04:00
cproudlock
427eb0de8c printedparts stage 12: admin settings page + settings-rail card
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
PrintedPartsSettings edits the four plugin settings (code prefix,
default threshold, kiosk badge policy, alert recipients) through the
core settings API; the route rides the plugin's router file and the
settings shell nests it into the rail; get_settings_cards contributes
the catalog card while the plugin is enabled.
2026-07-17 08:25:33 -04:00
cproudlock
df918ed38f printedparts stage 11: low-stock email alerts on threshold crossing
Some checks failed
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Contract 0.12.0: send_email/send_alert join the plugin surface (the
mailer was core-only), PLUGIN-HOOKS and status docs updated, manifest
pins the new floor. The alert fires inside _ledger_write only when a
decrement CROSSES the item's threshold - one alert per depletion,
rearmed by restocking above - and is best-effort after the commit so
mail trouble can never fail a take. Recipients come from
printedparts_alert_email, falling back to the site alert_recipients.
on_enable re-seeds settings idempotently so existing installs pick up
new keys. Crossing/rearm semantics proven by test.
2026-07-17 08:15:50 -04:00
cproudlock
fc0d48a6a7 printedparts stage 10: closeout - lab guide rewritten from the real build
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The lab is now a build-along mirroring what actually happened: ten
stages, each with the goal, the divergences, a see-it-work check, and
the errors genuinely hit while building (empty Migration error from a
broken model import, the migration-guard KeyError, the missing Lucide
icon, nested-app-context test writes, Decimal sums, and the authz
sweep catching the deliberately open kiosk take). That last one gets
its explicit EXEMPT_ENDPOINTS entry with a pointer to the decision
record - the net stays, the exception is reviewable. Full suite: 993
backend tests, 49 vitest, frontend build, naming hook, all green.
2026-07-17 08:11:36 -04:00
cproudlock
b68e927ef6 printedparts stage 9: reports - stock w/ reconcile, consumption, by-person
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Three jwt-optional endpoints with ?format=csv, merged into the reports
hub via get_reports while the plugin is enabled. The stock report's
ledgerdelta column is the reconcile check: 0 for every item whose
stock moved through the ledger, nonzero for anything that bypassed it
(the hand-seeded dev rows demonstrate the catch). MySQL SUM returns
Decimal - cast to int or the delta serializes as a string.
2026-07-17 08:04:09 -04:00
cproudlock
6439d1ccd9 printedparts stage 8: 1x0.5in bin labels
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
New public print view at /print/printedparts-labels following the
plugin-owned USB label precedent: multi-select with per-item copies,
CODE128 of the item code via JsBarcode (a QR at this size is at the
edge of scanner tolerance), one label per page on 1in x 0.5in roll
stock via a new @page size. The Detail page's Bin Label button
preselects its item through ?item=<id>; the list header gains a batch
Print Labels button.
2026-07-17 08:00:35 -04:00
cproudlock
6ed3da1b64 printedparts stage 7: the kiosk - scan bin, scan badge, keypad, take
Some checks failed
CI / backend (push) Failing after 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Two open endpoints: an item lookup by scanned code and the take POST -
the product's first unauthenticated write, held to the decision
record's bar (decrement-only, badge-attributed server-side, bounded,
physically rate-limited; justification in the plugin README). The
/parts-kiosk route is a full-screen no-auth view beside /shopfloor: a
hidden always-focused input consumes keyboard-wedge scans for
whichever step is active, TouchKeypad (net-new 3x4 grid) takes the
quantity, and a success screen resets after a few seconds. Manual
type-in fallbacks cover damaged labels. Kiosk test proves open access,
the over-take guard, the badge policy, and cache==ledger afterward.
2026-07-17 07:49:13 -04:00
cproudlock
d6a78a72ff printedparts stage 6: RBAC - declared permissions gate every mutation
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
get_permissions declares view/create/edit/delete/restock (seeded on
install/enable and by flask seed permissions); every write route adds
require_permission on top of jwt_required. New test proves
authentication alone is not authorization: a role-less member gets
403 where an admin succeeds.
2026-07-17 07:42:07 -04:00
cproudlock
6dfc8906c4 printedparts stage 5: the ledger - restock/adjust with badge attribution
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Badge resolver copied from the USB contract (SSO digits, 0<digits>BZ
PayNo wrap) with names from the employees directory and the
unknown-badge policy setting; deliberately copied rather than
cross-imported so the contract test stays green. Restock and adjust
write the ledger row and move the cached quantity in one commit -
the single-commit invariant every write path must use. Adjust
requires a reason and refuses to drive stock below zero. Detail page
gains Restock/Adjust modals. Seven tests cover minting, the
cache==ledger invariant, badge shapes, policy toggle, and auth.
2026-07-17 07:41:18 -04:00
cproudlock
cb367a38f9 printedparts stage 4: catalog mutations, item photos, detail + form
Some checks failed
CI / backend (push) Failing after 1m40s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
POST/PUT/DELETE for items: create mints the itemcode from the
configured prefix plus the flushed row id, update refuses
quantityonhand (ledger-managed - restock/adjust arrive next stage),
delete soft-retires. The image upload/serve/delete trio replicates the
models.py pattern into instance/printedpartsimages/ with a public GET.
PrintedItemDetail follows the unified detail skeleton (hero photo,
info list, transaction history table); PrintedItemForm covers
create/edit plus photo management on edit.
2026-07-17 07:36:54 -04:00
cproudlock
d1c844d533 printedparts stage 3: read API + list page (first visible win)
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
GET /items (paginated, search across code/name/description/bin,
lowstock filter) and GET /items/<id> with recent transactions, both
open reads. printedpartsApi client, router file repointed at the
renamed views, PrintedItemsList with image thumbs and a red/green
quantity badge against the per-item threshold. Nav entry '3D Parts'
with a new 'box' Lucide icon mapping (the sidebar renders nothing for
unknown icon names - lab gotcha).
2026-07-16 17:10:42 -04:00
cproudlock
f5cfac33b4 printedparts stage 2: models, real 0001 baseline, tables live
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
PrintedItem (catalog: code, name, image, cached quantityonhand,
per-item threshold, bin) and PrintedItemTransaction (the ledger:
signed quantity change attributed to a badge-resolved employee).
Both registered in PLUGIN_TABLE_OWNERS; 0001 is a post-cutover real
baseline. The migration-guard test learns the new expected head.
Routes are a placeholder ping until the next stage - the scaffold's
list route imported the deleted scaffold model, which surfaces as an
empty 'Migration error' because the alembic env imports the models
package.
2026-07-16 16:57:21 -04:00
51 changed files with 4870 additions and 1182 deletions

3
.gitignore vendored
View File

@@ -76,3 +76,6 @@ secrets.yml
*_secrets
credentials.json
scripts/site_imports/wjf/idmap.json
# work-PC publication clone: bundle drop folder for the transfer pipeline
_transfer/

View File

@@ -43,7 +43,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
### Active state
- 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
- `__contract_version__` at 0.11.0 (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- `__contract_version__` at 0.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).

View File

@@ -151,6 +151,8 @@ Two supported deployment methods:
```powershell
icacls APP_ROOT /grant "IIS AppPool\shopdbflask:(OI)(CI)RX" /T
icacls APP_ROOT\logs /grant "IIS AppPool\shopdbflask:(OI)(CI)M" /T
mkdir APP_ROOT\instance 2>NUL
icacls APP_ROOT\instance /grant "IIS AppPool\shopdbflask:(OI)(CI)M" /T
```
4. **Unlock the handler sections** (locked server-wide by default; without this
IIS returns **HTTP 500.19**):
@@ -236,6 +238,7 @@ each gets its own site, app pool, port, and venv.
| IIS **500.52** after enabling the rewrite block | `allowedServerVariables` locked at server level - `appcmd unlock config -section:system.webServer/rewrite/allowedServerVariables`. |
| Audit log shows only **127.0.0.1** with the rewrite block active | waitress strips untrusted proxy headers - `--trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for` missing from the waitress `arguments`. |
| **500** with an empty HttpPlatform log | app-pool identity can't read `APP_ROOT` / run the venv (step 7.3), or `.env` missing/invalid. |
| "internal error" toggling plugins, or uploads fail | app pool cannot WRITE `APP_ROOT\instance` (plugin registry, logos, photos, files live there) - step 7.3 grants it Modify. |
| "No time zone found with key America/New_York" | `tzdata` not installed (`pip install tzdata`). |
| Nav missing Equipment/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. |
| Method B: blank page / assets 404 under `/ops` | frontend `dist` built without `VITE_BASE_PATH=/ops/` (step 7b.1). |

View File

@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`:
```python
__contract_version__ = '0.11.0'
__contract_version__ = '0.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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -628,18 +628,22 @@ input[type="radio"] {
theme store always stamps it at startup) - a bare prefers-color-scheme
query here leaks dark widget styles into light mode on dark-OS machines. */
[data-theme="dark"] .form-control {
background: var(--bg);
/* background-COLOR, not the shorthand: the shorthand resets a select's
background-repeat/position and the dropdown arrow tiles across the box. */
background-color: var(--bg);
border-color: var(--border);
}
[data-theme="dark"] .form-control:focus {
background: var(--bg);
background-color: var(--bg);
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
}
[data-theme="dark"] select.form-control {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-color: var(--bg);
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
[data-theme="dark"] select.form-control option {

View File

@@ -0,0 +1,54 @@
<template>
<div class="touch-keypad">
<button v-for="digit in digits" :key="digit" type="button"
class="keypad-button" @click="$emit('digit', digit)">
{{ digit }}
</button>
<button type="button" class="keypad-button keypad-action"
@click="$emit('clear')">Clear</button>
<button type="button" class="keypad-button" @click="$emit('digit', '0')">0</button>
<button type="button" class="keypad-button keypad-action"
aria-label="Backspace" @click="$emit('backspace')">&#9003;</button>
</div>
</template>
<script setup>
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
defineEmits(['digit', 'clear', 'backspace'])
</script>
<style scoped>
.touch-keypad {
display: grid;
grid-template-columns: repeat(3, 5.2rem);
gap: 0.65rem;
justify-content: center;
}
.keypad-button {
height: 4.4rem;
font-size: 1.9rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
border-radius: 0.75rem;
border: 1px solid var(--border);
background: var(--bg-card-solid, var(--bg-card));
color: var(--text);
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
transition: transform 0.05s ease, background 0.1s ease;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.keypad-button:hover { border-color: var(--primary); }
.keypad-button:active {
background: var(--primary);
color: #fff;
transform: scale(0.96);
}
.keypad-action {
font-size: 1.1rem;
font-weight: 500;
color: var(--text-light);
}
.keypad-action:active { color: #fff; }
</style>

View File

@@ -5,6 +5,7 @@ import SettingsLayout from '../views/settings/SettingsLayout.vue'
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState'
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast'
import { getFacilityName } from '../utils/siteSettings'
// Auto-discover all route modules from routes/ directory
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
@@ -65,6 +66,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 +118,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,
@@ -121,6 +138,43 @@ const router = createRouter({
routes
})
// --- Document titles: "<Facility> ShopDB - <Page>" ---------------------------
// Facility name comes from public settings (cached after first fetch); the
// page label comes from meta.title when a route sets one, else a prettified
// route name with spellings for the odd ones.
const TITLE_SPELLINGS = {
'pcs': 'PCs',
'usb': 'USB Devices',
'geenforce': 'GE-Enforce',
'knowledgebase': 'Knowledge Base',
'printedparts': '3D Printed Parts',
'parts-kiosk': 'Parts Kiosk',
'tv': 'TV Slideshow',
'shopfloor': 'Shopfloor Dashboard',
'measuringtools': 'Measuring Tools',
'networkdevices': 'Network',
}
function pageTitleFor(route) {
if (route.meta?.title) return route.meta.title
const name = String(route.name || '')
if (!name) return ''
const base = name.replace(/-(new|edit|detail)$/, '')
if (TITLE_SPELLINGS[base]) return TITLE_SPELLINGS[base]
return base.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
router.afterEach(async (to) => {
let siteTitle = 'ShopDB'
try {
const facility = await getFacilityName()
if (facility && facility !== 'ShopDB') siteTitle = `${facility} ShopDB`
} catch (titleError) { /* settings unavailable - plain ShopDB */ }
const page = pageTitleFor(to)
document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle
})
// Navigation guard
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()

View File

@@ -11,25 +11,31 @@ export default [
{
path: 'printedparts',
name: 'printedparts',
component: () => import('../../views/printedparts/PrintedpartsList.vue'),
meta: { plugin: 'printedparts' }
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/new',
name: 'printedparts-new',
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/:id',
name: 'printedparts-detail',
component: () => import('../../views/printedparts/PrintedpartsDetail.vue'),
meta: { plugin: 'printedparts' }
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/:id/edit',
name: 'printedparts-edit',
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'settings/printedparts',
name: 'settings-printedparts',
component: () => import('../../views/settings/PrintedPartsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' }
}
]

View File

@@ -27,6 +27,8 @@
<div class="nav-section">Displays</div>
<a :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
<a :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
<a v-if="isPluginEnabled('printedparts')" :href="withBase('/parts-kiosk')"
target="_blank" class="external-link">Parts Kiosk</a>
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
</nav>
@@ -40,7 +42,8 @@
<div class="user-menu">
<template v-if="authStore.isAuthenticated">
<div class="user-identity">
<img v-if="authStore.avatarUrl" :src="authStore.avatarUrl" class="user-avatar"
<img :src="authStore.avatarUrl || fallbackAvatar" class="user-avatar"
:class="{ 'ge-avatar-fallback': !authStore.avatarUrl }"
:alt="authStore.displayName" @error="onAvatarError" />
<div class="user-ident">
<div class="username">{{ authStore.displayName }}</div>
@@ -101,13 +104,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 +151,7 @@ const iconMap = {
'image': Image,
'shield': ShieldCheck,
'ruler': Ruler,
'box': Box,
}
// Default navigation (used as fallback if API fails)
@@ -241,9 +246,12 @@ async function handleLogout() {
router.push('/login')
}
// Hide a broken avatar (photo filename set but file missing).
const fallbackAvatar = withBase('/ge-monogram.svg')
// A broken avatar (photo set but file missing) degrades to the GE monogram.
function onAvatarError(event) {
event.target.style.display = 'none'
event.target.src = fallbackAvatar
event.target.classList.add('ge-avatar-fallback')
}
</script>
@@ -253,6 +261,7 @@ function onAvatarError(event) {
.user-menu { display: flex; flex-direction: column; gap: 0.6rem; }
.user-identity { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
.user-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); flex-shrink: 0; }
.ge-avatar-fallback { object-fit: contain; padding: 4px; background: #fff; }
.user-ident { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; }
.user-ident .username { margin-bottom: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-sso { font-size: 0.72rem; color: var(--text-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

View File

@@ -96,12 +96,12 @@
<div class="form-group">
<label>Blueprint image (light theme)</label>
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<img v-if="blueprintLight" :src="blueprintLight" class="wizard-map-thumb" alt="light blueprint" />
<img v-if="blueprintLight" :src="withBase(blueprintLight)" class="wizard-map-thumb" alt="light blueprint" />
</div>
<div class="form-group">
<label>Blueprint image (dark theme)</label>
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<img v-if="blueprintDark" :src="blueprintDark" class="wizard-map-thumb dark" alt="dark blueprint" />
<img v-if="blueprintDark" :src="withBase(blueprintDark)" class="wizard-map-thumb dark" alt="dark blueprint" />
</div>
<div class="form-row">
<div class="form-group">
@@ -166,6 +166,7 @@
</template>
<script setup>
import { withBase } from '../utils/basePath'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { settingsApi, pluginsApi, setupApi } from '../api'

View File

@@ -6,10 +6,10 @@
<template v-else-if="employee">
<div class="hero-card">
<div class="hero-image" v-if="employee.photourl">
<img :src="employee.photourl" :alt="fullName" />
<img :src="employee.photourl" :alt="fullName" @error="onPhotoError" />
</div>
<div class="hero-image placeholder" v-else>
<span class="initials">{{ initials }}</span>
<img :src="fallbackAvatar" alt="GE Aerospace" class="ge-avatar-fallback-lg" />
</div>
<div class="hero-content">
<h1 class="hero-title">{{ fullName }}</h1>
@@ -149,6 +149,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { withBase } from '../../utils/basePath'
import { employeesApi, usbApi, notificationsApi } from '@/api'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '../../composables/toast'
@@ -192,6 +193,13 @@ const fullName = computed(() => {
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
})
const fallbackAvatar = withBase('/ge-monogram.svg')
function onPhotoError(event) {
event.target.src = fallbackAvatar
event.target.classList.add('ge-avatar-fallback-lg')
}
const initials = computed(() => {
if (!employee.value) return '?'
const first = employee.value.First_Name?.trim()?.[0] || ''
@@ -291,6 +299,12 @@ function formatDate(dateStr) {
color: white;
}
.ge-avatar-fallback-lg {
width: 60%;
height: 60%;
object-fit: contain;
opacity: 0.85;
}
.initials {
font-size: 3rem;
font-weight: 600;

View File

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

View File

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

View File

@@ -0,0 +1,375 @@
<template>
<div class="detail-page">
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="item">
<div class="hero-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
:alt="item.itemname" class="hero-image" />
<div class="hero-content">
<h2 class="hero-title">{{ item.itemname }}</h2>
<div class="hero-meta">
<span class="badge badge-secondary">{{ item.itemcode }}</span>
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
{{ item.quantityonhand }} on hand
</span>
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
</div>
<div class="hero-details">
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
</div>
<div class="hero-actions">
<button class="btn btn-primary btn-sm" @click="openLedger('restock')">
Restock
</button>
<button class="btn btn-secondary btn-sm" @click="openLedger('adjust')">
Adjust
</button>
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
class="btn btn-secondary btn-sm">Edit</router-link>
<router-link :to="`/print/printedparts-labels?item=${item.printeditemid}`"
class="btn btn-secondary btn-sm">Bin Label</router-link>
<button v-if="item.isactive" class="btn btn-danger btn-sm"
@click="retireItem">Retire</button>
<button v-else class="btn btn-primary btn-sm"
@click="restoreItem">Restore</button>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Details</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Item code</span>
<span class="info-value">{{ item.itemcode }}</span>
</div>
<div class="info-row" v-if="item.gagelabtag">
<span class="info-label">Gage lab tag</span>
<span class="info-value">{{ item.gagelabtag }}</span>
</div>
<div class="info-row">
<span class="info-label">Bin location</span>
<span class="info-value">{{ item.binlocation || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Quantity on hand</span>
<span class="info-value">{{ item.quantityonhand }}</span>
</div>
<div class="info-row">
<span class="info-label">Low-stock threshold</span>
<span class="info-value">{{ item.lowstockthreshold }}</span>
</div>
<div class="info-row" v-if="item.printnotes">
<span class="info-label">Print notes</span>
<span class="info-value">{{ item.printnotes }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Print files</h3>
<div class="file-upload-row">
<input ref="fileInput" type="file"
accept=".stl,.3mf,.gcode,.gco,.bgcode,.step,.stp,.obj,.amf" />
<input v-model="fileNote" type="text" class="form-control"
placeholder="What changed? (optional)" />
<button class="btn btn-primary btn-sm" :disabled="fileUploading"
@click="uploadRevision">
{{ fileUploading ? 'Uploading...' : 'Upload revision' }}
</button>
</div>
<div v-if="fileError" class="error-message">{{ fileError }}</div>
<ul class="file-revision-list">
<li v-for="revision in files" :key="revision.fileid"
:class="{ 'current-revision': revision === files[0] }">
<div class="file-main">
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)"
class="file-name">
{{ revision.filename }}
</a>
<span class="badge badge-secondary">rev {{ revision.revision }}</span>
<span v-if="revision === files[0]" class="badge badge-success">current</span>
</div>
<div class="file-meta">
{{ formatSize(revision.filesize) }} -
{{ revision.uploadedby }} -
{{ formatDate(revision.uploadeddate) }}
<span v-if="revision.uploadnote"> - {{ revision.uploadnote }}</span>
</div>
<button class="btn btn-secondary btn-sm file-delete"
@click="removeRevision(revision)">Delete</button>
</li>
<li v-if="files.length === 0" class="empty-state">
No print file uploaded yet
</li>
</ul>
</div>
<div class="section-card">
<h3 class="section-title">Recent transactions</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>When</th>
<th>Type</th>
<th>Qty</th>
<th>Who</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr v-for="transaction in item.recenttransactions"
:key="transaction.transactionid">
<td>{{ formatDate(transaction.transactiondate) }}</td>
<td>{{ transaction.transactiontype }}</td>
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
</td>
<td>{{ transaction.employeename || transaction.employeesso }}</td>
<td>{{ transaction.reason || '-' }}</td>
</tr>
<tr v-if="!item.recenttransactions?.length">
<td colspan="5" class="empty-state">No transactions yet</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="audit-footer">
Created {{ formatDate(item.createddate) }} -
Modified {{ formatDate(item.modifieddate) }}
</div>
</template>
<div v-else class="card">Item not found</div>
<Modal v-model="ledgerOpen" :title="ledgerMode === 'restock' ? 'Restock' : 'Adjust count'">
<div v-if="ledgerError" class="error-message">{{ ledgerError }}</div>
<div class="form-group">
<label>{{ ledgerMode === 'restock' ? 'Quantity printed' : 'Change (+/-)' }}</label>
<input v-model.number="ledgerQuantity" type="number" class="form-control" />
</div>
<div v-if="ledgerMode === 'adjust'" class="form-group">
<label>Reason *</label>
<input v-model="ledgerReason" type="text" class="form-control"
placeholder="e.g., damaged parts scrapped, recount" />
</div>
<div class="form-group">
<label>Your badge / SSO *</label>
<input v-model="ledgerBadge" type="text" class="form-control"
placeholder="Scan badge or type SSO" />
</div>
<template #footer>
<button class="btn btn-primary" :disabled="ledgerSaving" @click="submitLedger">
{{ ledgerSaving ? 'Saving...' : 'Submit' }}
</button>
<button class="btn btn-secondary" @click="ledgerOpen = false">Cancel</button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
import Modal from '../../components/Modal.vue'
const route = useRoute()
const item = ref(null)
const loading = ref(true)
onMounted(async () => {
try {
const response = await printedpartsApi.get(route.params.id)
item.value = response.data.data
await loadFiles()
} catch (loadError) {
console.error('Error loading printed item:', loadError)
} finally {
loading.value = false
}
})
const files = ref([])
const fileInput = ref(null)
const fileNote = ref('')
const fileUploading = ref(false)
const fileError = ref('')
async function loadFiles() {
try {
const response = await printedpartsApi.listFiles(route.params.id)
files.value = response.data.data || []
} catch (filesError) {
console.error('Error loading files:', filesError)
}
}
async function uploadRevision() {
const file = fileInput.value?.files?.[0]
if (!file) { fileError.value = 'Choose a file first'; return }
fileUploading.value = true
fileError.value = ''
try {
await printedpartsApi.uploadFile(route.params.id, file, fileNote.value)
fileNote.value = ''
fileInput.value.value = ''
await loadFiles()
} catch (uploadError) {
fileError.value =
uploadError.response?.data?.data?.error?.message || 'Upload failed'
} finally {
fileUploading.value = false
}
}
async function removeRevision(revision) {
if (!window.confirm(
`Delete revision ${revision.revision} (${revision.filename})?`)) return
try {
await printedpartsApi.removeFile(revision.fileid)
await loadFiles()
} catch (removeError) {
fileError.value = 'Delete failed'
console.error(removeError)
}
}
function formatSize(bytes) {
if (!bytes && bytes !== 0) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1048576).toFixed(1)} MB`
}
const ledgerOpen = ref(false)
const ledgerMode = ref('restock')
const ledgerQuantity = ref(null)
const ledgerReason = ref('')
const ledgerBadge = ref('')
const ledgerSaving = ref(false)
const ledgerError = ref('')
function openLedger(mode) {
ledgerMode.value = mode
ledgerQuantity.value = null
ledgerReason.value = ''
ledgerBadge.value = ''
ledgerError.value = ''
ledgerOpen.value = true
}
async function submitLedger() {
ledgerSaving.value = true
ledgerError.value = ''
try {
if (ledgerMode.value === 'restock') {
await printedpartsApi.restock(item.value.printeditemid, {
quantity: ledgerQuantity.value, badge: ledgerBadge.value
})
} else {
await printedpartsApi.adjust(item.value.printeditemid, {
quantitychange: ledgerQuantity.value,
reason: ledgerReason.value,
badge: ledgerBadge.value
})
}
ledgerOpen.value = false
const response = await printedpartsApi.get(item.value.printeditemid)
item.value = response.data.data
} catch (submitError) {
ledgerError.value =
submitError.response?.data?.data?.error?.message ||
submitError.response?.data?.error?.message || 'Submit failed'
} finally {
ledgerSaving.value = false
}
}
async function retireItem() {
if (!window.confirm(
`Retire ${item.value.itemname}? It leaves the storefront and kiosk; `
+ 'history and the bin label stay, and it can be restored later.')) return
try {
await printedpartsApi.remove(item.value.printeditemid)
const response = await printedpartsApi.get(item.value.printeditemid)
item.value = response.data.data
} catch (retireError) {
console.error('Retire failed:', retireError)
}
}
async function restoreItem() {
try {
const response = await printedpartsApi.restore(item.value.printeditemid)
item.value = response.data.data
} catch (restoreError) {
console.error('Restore failed:', restoreError)
}
}
function formatDate(value) {
if (!value) return '-'
return new Date(value).toLocaleString()
}
</script>
<style scoped>
.hero-actions { margin-top: 0.75rem; }
.qty-out { color: var(--danger); }
.file-upload-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.file-revision-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.file-revision-list li {
position: relative;
border: 1px solid var(--border);
border-radius: 0.45rem;
padding: 0.6rem 5.5rem 0.6rem 0.8rem;
}
.file-revision-list li.current-revision { border-color: var(--primary); }
.file-main {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.file-name {
font-weight: 600;
overflow-wrap: anywhere;
}
.file-meta {
color: var(--text-light);
font-size: 0.85rem;
margin-top: 0.2rem;
}
.file-delete {
position: absolute;
top: 0.55rem;
right: 0.6rem;
}
.qty-in { color: var(--success); }
</style>

View File

@@ -0,0 +1,175 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Part' : 'Add Part' }}</h2>
</div>
<div class="card form-card">
<div v-if="error" class="error-message">{{ error }}</div>
<form @submit.prevent="save">
<div class="form-row">
<div class="form-group">
<label>Name *</label>
<input v-model="form.itemname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Bin location</label>
<input v-model="form.binlocation" type="text" class="form-control"
placeholder="e.g., Bin A3" />
</div>
</div>
<div class="form-group">
<label>Description</label>
<input v-model="form.itemdescription" type="text" class="form-control"
maxlength="500" placeholder="Brief description shown on the storefront" />
</div>
<div class="form-row">
<div class="form-group">
<label>Low-stock threshold</label>
<input v-model.number="form.lowstockthreshold" type="number" min="0"
class="form-control" />
</div>
<div class="form-group">
<label>Gage lab asset tag</label>
<input v-model="form.gagelabtag" type="text" class="form-control"
placeholder="e.g. WJRP0117" />
<p class="form-hint">
Assigned by the gage lab; optional. The kiosk finds parts by
this tag or by the internal code.
</p>
</div>
</div>
<div class="form-group">
<label>Print notes</label>
<textarea v-model="form.printnotes" class="form-control" rows="3"
placeholder="Material, print time, slicer file path"></textarea>
</div>
<div v-if="isEdit" class="form-group">
<label>Photo</label>
<div class="image-row">
<img v-if="imageurl" :src="withBase(imageurl)" class="image-preview" />
<input type="file" accept="image/*" @change="onImagePicked" />
<button v-if="imageurl" type="button" class="btn btn-secondary btn-sm"
@click="removeImage">Remove photo</button>
</div>
</div>
<p v-else class="form-hint">Save first, then add a photo from the edit page.</p>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<router-link :to="cancelTarget" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const cancelTarget = computed(() =>
isEdit.value ? `/printedparts/${route.params.id}` : '/printedparts')
const form = ref({
itemname: '',
gagelabtag: '',
itemdescription: '',
lowstockthreshold: null,
binlocation: '',
printnotes: ''
})
const imageurl = ref(null)
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (!isEdit.value) return
try {
const response = await printedpartsApi.get(route.params.id)
const item = response.data.data
for (const key of Object.keys(form.value)) {
form.value[key] = item[key]
}
imageurl.value = item.imageurl
} catch (loadError) {
error.value = 'Could not load the item'
console.error(loadError)
}
})
async function save() {
saving.value = true
error.value = ''
try {
const payload = { ...form.value }
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
delete payload.lowstockthreshold
}
if (!payload.gagelabtag) payload.gagelabtag = ''
let printeditemid
if (isEdit.value) {
await printedpartsApi.update(route.params.id, payload)
printeditemid = route.params.id
} else {
const response = await printedpartsApi.create(payload)
printeditemid = response.data.data.printeditemid
}
router.push(`/printedparts/${printeditemid}`)
} catch (saveError) {
error.value = saveError.response?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
async function onImagePicked(event) {
const file = event.target.files?.[0]
if (!file) return
try {
const response = await printedpartsApi.uploadImage(route.params.id, file)
imageurl.value = response.data.data.imageurl
} catch (uploadError) {
error.value = uploadError.response?.data?.error?.message || 'Image upload failed'
}
}
async function removeImage() {
try {
await printedpartsApi.deleteImage(route.params.id)
imageurl.value = null
} catch (deleteError) {
error.value = 'Could not remove the image'
console.error(deleteError)
}
}
</script>
<style scoped>
.image-row {
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview {
width: 6rem;
height: 6rem;
object-fit: cover;
border-radius: 0.35rem;
border: 1px solid var(--border);
}
.form-hint { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,156 @@
<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
<div class="header-actions">
<router-link to="/print/printedparts-labels" class="btn btn-secondary">
Print Labels
</router-link>
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
</div>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search code, name, description, bin..."
@input="debouncedSearch"
/>
<label class="lowstock-filter">
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
Low stock only
</label>
<label class="lowstock-filter">
<input v-model="includeRetired" type="checkbox" @change="loadItems" />
Include retired
</label>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th></th>
<th>Code</th>
<th>Name</th>
<th>Quantity</th>
<th>Bin</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
:key="item.printeditemid"
class="clickable-row"
@click="$router.push(`/printedparts/${item.printeditemid}`)"
>
<td class="thumb-cell">
<img
v-if="item.imageurl"
:src="withBase(item.imageurl)"
:alt="item.itemname"
class="item-thumb"
/>
</td>
<td>{{ item.itemcode || '-' }}</td>
<td>
{{ item.itemname }}
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
</td>
<td>
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
{{ item.quantityonhand }}
</span>
</td>
<td>{{ item.binlocation || '-' }}</td>
<td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="6" class="empty-state">No printed parts found</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:total-pages="totalPages"
@change="setPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { printedpartsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import { withBase } from '../../utils/basePath'
const items = ref([])
const loading = ref(true)
const lowstockOnly = ref(false)
const includeRetired = ref(false)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (lowstockOnly.value) params.lowstock = 'true'
if (includeRetired.value) params.active = 'false'
const response = await printedpartsApi.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading printed parts:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => setSearch(search.value), 300)
}
</script>
<style scoped>
.item-thumb {
width: 2.2rem;
height: 2.2rem;
object-fit: cover;
border-radius: 0.25rem;
}
.thumb-cell { width: 3rem; }
.truncate-cell {
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions { display: flex; gap: 0.5rem; }
.lowstock-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--text-light);
cursor: pointer;
}
</style>

View File

@@ -1,149 +0,0 @@
<template>
<div class="detail-page" v-if="item">
<div class="hero-card">
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ item.name || item.assetnumber || 'Printedparts' }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/printedparts/${itemId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-details">
<div class="detail-item" v-if="item.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ item.assetnumber }}</span>
</div>
<div class="detail-item" v-if="item.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ item.serialnumber }}</span>
</div>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Printedparts Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Example Field</span>
<span class="info-value">{{ item.examplefield || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ item.assetnumber || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ item.name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/printedparts/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
</div>
</div>
<div v-else-if="loading" class="loading-container">
<div class="loading">Loading...</div>
</div>
<div v-else class="error-container">
<p>Record not found</p>
<router-link to="/printedparts" class="btn btn-secondary">Back to Printedparts</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
// local api client. move into src/api/index.js (see
// plugins/printedparts/frontend-api-snippet.js) then swap for:
// import { printedpartsApi } from '../../api'
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}`) }
}
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const itemId = route.params.id
const item = ref(null)
const loading = ref(true)
onMounted(loadItem)
async function loadItem() {
loading.value = true
try {
const response = await printedpartsApi.get(itemId)
item.value = response.data.data
} catch (error) {
console.error('Error loading printedparts:', error)
item.value = null
} finally {
loading.value = false
}
}
async function confirmDelete() {
if (confirm('Delete this record?')) {
try {
await printedpartsApi.remove(itemId)
router.push('/printedparts')
} catch (error) {
console.error('Error deleting printedparts:', error)
}
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.action-bar {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.loading-container,
.error-container {
text-align: center;
padding: 3rem;
color: var(--text-light);
}
</style>

View File

@@ -1,228 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Printedparts' : 'Add Printedparts' }}</h2>
</div>
<div class="card form-card">
<form @submit.prevent="submitForm">
<fieldset>
<legend>Asset Information</legend>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
:disabled="isEdit"
/>
</div>
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<fieldset>
<legend>Printedparts Details</legend>
<div class="form-row">
<div class="form-group">
<label for="examplefield">Example Field</label>
<input
id="examplefield"
v-model="form.examplefield"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
// local api client. move into src/api/index.js (see
// plugins/printedparts/frontend-api-snippet.js) then swap for:
// import { printedpartsApi } from '../../api'
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}`) }
}
const route = useRoute()
const router = useRouter()
const itemId = route.params.id
const isEdit = computed(() => !!itemId)
const form = ref({
assetnumber: '',
name: '',
serialnumber: '',
examplefield: ''
})
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (isEdit.value) {
await loadItem()
}
})
async function loadItem() {
try {
const response = await printedpartsApi.get(itemId)
const data = response.data.data
form.value.assetnumber = data.assetnumber || ''
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.examplefield = data.examplefield || ''
} catch (loadError) {
console.error('Error loading printedparts:', loadError)
error.value = 'Failed to load record'
}
}
async function submitForm() {
saving.value = true
error.value = ''
try {
const payload = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
examplefield: form.value.examplefield || null
}
let redirectId = itemId
if (isEdit.value) {
await printedpartsApi.update(itemId, payload)
} else {
const response = await printedpartsApi.create(payload)
redirectId = response.data.data?.assetid
}
router.push(redirectId ? `/printedparts/${redirectId}` : '/printedparts')
} catch (submitError) {
console.error('Error saving printedparts:', submitError)
error.value = 'Failed to save record'
} finally {
saving.value = false
}
}
function cancel() {
if (isEdit.value) {
router.push(`/printedparts/${itemId}`)
} else {
router.push('/printedparts')
}
}
</script>
<style scoped>
.form-card {
max-width: 800px;
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 0.375rem;
font-weight: 500;
color: var(--text);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--danger);
color: white;
border-radius: 6px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,142 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Printedparts</h2>
<router-link to="/printedparts/new" class="btn btn-primary">Add Printedparts</router-link>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Name</th>
<th>Example Field</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.assetid">
<td>{{ item.assetnumber || '-' }}</td>
<td>{{ item.name || '-' }}</td>
<td>{{ item.examplefield || '-' }}</td>
<td class="actions">
<router-link
:to="`/printedparts/${item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No printedparts records found
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
// local api client. move into src/api/index.js (see
// plugins/printedparts/frontend-api-snippet.js) then swap for:
// import { printedpartsApi } from '../../api'
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}`) }
}
const items = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(25)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
const response = await printedpartsApi.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading printedparts:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadItems()
}, 300)
}
function goToPage(target) {
if (target >= 1 && target <= totalPages.value) {
page.value = target
loadItems()
}
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadItems()
}
</script>
<style scoped>
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
</style>

View File

@@ -36,8 +36,8 @@
<td>{{ e.Team || '-' }}</td>
<td>{{ e.Role || '-' }}</td>
<td>
<img v-if="e.photourl" :src="e.photourl" alt="Photo" class="photo-thumb" />
<span v-else class="mono">-</span>
<img :src="e.photourl || fallbackAvatar" alt="Photo" class="photo-thumb"
:class="{ 'ge-thumb-fallback': !e.photourl }" />
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(e)">Edit</button>
@@ -295,6 +295,7 @@ async function doImport() {
.form-row .form-group { flex: 1; }
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
.page-info { color: var(--text-light); font-size: 0.85rem; }
.ge-thumb-fallback { object-fit: contain; padding: 2px; background: #fff; }
.photo-thumb { width: 36px; height: 36px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
.photo-manage { display: flex; align-items: center; gap: 1rem; }
.photo-thumb-lg { width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); }

View File

@@ -26,7 +26,7 @@
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_light" :src="settings.map_blueprint_light" class="map-thumb" alt="light blueprint" />
<img v-if="settings.map_blueprint_light" :src="withBase(settings.map_blueprint_light)" class="map-thumb" alt="light blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
</label>
@@ -44,7 +44,7 @@
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_dark" :src="settings.map_blueprint_dark" class="map-thumb map-thumb-dark" alt="dark blueprint" />
<img v-if="settings.map_blueprint_dark" :src="withBase(settings.map_blueprint_dark)" class="map-thumb map-thumb-dark" alt="dark blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
</label>
@@ -88,6 +88,7 @@
</template>
<script setup>
import { withBase } from '../../utils/basePath'
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'

View File

@@ -0,0 +1,177 @@
<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group">
<label>Item code prefix</label>
<input v-model="values.printedparts_code_prefix" type="text"
class="form-control" maxlength="8" />
<p class="field-hint">
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
Changing it does not rename existing items.
</p>
</div>
<div class="form-group">
<label>Default low-stock threshold</label>
<input v-model.number="values.printedparts_default_threshold"
type="number" min="0" class="form-control" />
<p class="field-hint">Seed value for new items; each item can override.</p>
</div>
<div class="form-group">
<label>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control">
<option value="deny">Deny - refuse badges with no directory match</option>
<option value="allow">Allow - record the SSO with no name</option>
</select>
</div>
<div class="form-group">
<label>Alert shopdb users</label>
<div class="user-picker">
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
<input type="checkbox" :value="String(candidate.userid)"
v-model="selectedUserids" />
<span>{{ candidate.username }}</span>
<span class="user-email">{{ candidate.email }}</span>
</label>
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
</div>
<p class="field-hint">
Selected users receive low-stock alerts at their account email.
</p>
</div>
<div class="form-group">
<label>Alert roles</label>
<div class="user-picker">
<label v-for="role in roles" :key="role.roleid" class="user-row">
<input type="checkbox" :value="String(role.roleid)"
v-model="selectedRoleids" />
<span>{{ role.rolename }}</span>
<span class="user-email">{{ role.description }}</span>
</label>
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
</div>
<p class="field-hint">
Every active member of a selected role receives low-stock alerts.
</p>
</div>
<div class="form-group">
<label>Additional alert emails</label>
<input v-model="values.printedparts_alert_email" type="text"
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
<p class="field-hint">
Comma-separated. Empty uses the site-wide alert recipients
(Settings &gt; System &gt; Email). Alerts fire once when an item
crosses its threshold; restocking above re-arms.
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi, usersApi } from '@/api'
const KEYS = [
'printedparts_code_prefix',
'printedparts_default_threshold',
'printedparts_unknown_badge',
'printedparts_alert_email',
'printedparts_alert_userids',
'printedparts_alert_roleids'
]
const values = ref({
printedparts_code_prefix: '3DP',
printedparts_default_threshold: 5,
printedparts_unknown_badge: 'deny',
printedparts_alert_email: '',
printedparts_alert_userids: '',
printedparts_alert_roleids: ''
})
const users = ref([])
const selectedUserids = ref([])
const roles = ref([])
const selectedRoleids = ref([])
const saving = ref(false)
const message = ref('')
const error = ref('')
onMounted(async () => {
try {
const response = await settingsApi.list({ category: 'printedparts' })
const rows = response.data.data || []
for (const row of rows) {
if (KEYS.includes(row.key)) values.value[row.key] = row.value
}
values.value.printedparts_default_threshold =
parseInt(values.value.printedparts_default_threshold, 10) || 0
selectedUserids.value = (values.value.printedparts_alert_userids || '')
.split(',').map(id => id.trim()).filter(Boolean)
const usersResponse = await usersApi.list()
users.value = (usersResponse.data.data || []).filter(
candidate => candidate.isactive && candidate.email)
selectedRoleids.value = (values.value.printedparts_alert_roleids || '')
.split(',').map(id => id.trim()).filter(Boolean)
const rolesResponse = await usersApi.roles.list()
roles.value = rolesResponse.data.data || []
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
values.value.printedparts_alert_userids = selectedUserids.value.join(',')
values.value.printedparts_alert_roleids = selectedRoleids.value.join(',')
for (const key of KEYS) {
await settingsApi.update(key, String(values.value[key] ?? ''))
}
message.value = 'Settings saved'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.user-picker {
max-height: 12rem;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 0.35rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.user-row {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.user-email { color: var(--text-light); font-size: 0.85rem; }
</style>

View File

@@ -43,9 +43,10 @@ EMPLOYEE_PHOTO_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
# URL prefix a served upload resolves to (self-hosted mode).
EMPLOYEE_PHOTO_URL_PREFIX = '/api/employees/photo/'
# URL prefix external HR relative picture paths resolve under. The HR employees
# table stores Picture as a relative path (e.g. 'Support/210009518.png') that
# the site serves from /static/employees/; this matches the shopfloor feed.
# Fallback URL prefix external HR relative picture paths resolve under when
# the employee_photo_base_url setting is unset. Sites whose photos live on
# another host (e.g. the classic EmployeeDBAPP) set the setting to a full URL
# such as https://host/EmployeeDBAPP/images/ instead.
EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/'
@@ -96,7 +97,10 @@ def _external_photo_url(picture):
return None
if text.startswith(('http://', 'https://', '/')):
return text
return EMPLOYEE_PHOTO_STATIC_PREFIX + text
from shopdb.api import Setting
base = (Setting.get('employee_photo_base_url') or '').strip() \
or EMPLOYEE_PHOTO_STATIC_PREFIX
return base.rstrip('/') + '/' + text.lstrip('/')
def _hr_picture(sso):
@@ -112,6 +116,36 @@ def _hr_picture(sso):
return None
def resolve_employee_display_name(sso):
"""Display name ("First Last") for an SSO in either directory mode.
The shopfloor feed uses this as a live fallback when a notification has
no stored employeename (e.g. imported without the employee source).
None on any miss."""
if sso is None or not str(sso).isdigit():
return None
if _selfhosted():
emp = db.session.get(DirectoryEmployee, int(sso))
if emp:
return f'{emp.firstname} {emp.lastname}'.strip() or None
return None
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute(
'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s',
(int(sso),))
row = cur.fetchone()
conn.close()
if row:
first = row.get('First_Name') or ''
last = row.get('Last_Name') or ''
return f'{first.strip()} {last.strip()}'.strip() or None
except Exception:
pass
return None
def resolve_employee_photo_url(sso, external_picture=None):
"""Single resolver both consumers share: the display photo URL for an SSO.

View File

@@ -79,6 +79,11 @@ class EmployeesPlugin(BasePlugin):
"""Employee directory DB connection. Host/name/user are settings the
wizard can edit; the password stays in .env (emitted, not stored)."""
return [
{'key': 'employee_photo_base_url', 'label': 'Photo base URL',
'type': 'text', 'secret': False,
'help': 'Where external HR relative Picture paths resolve, e.g. '
'https://host/EmployeeDBAPP/images/. Blank = this '
'site\'s /static/employees/.'},
{'key': 'employee_db_host', 'label': 'Employee DB host', 'type': 'text',
'secret': False, 'default': 'localhost',
'help': 'This DB must expose an "employees" table or view with columns '

View File

@@ -149,6 +149,16 @@ def _config_version():
return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12]
def _employee_name(sso):
"""Live directory name for an SSO; None on any miss. Used as the fallback
when a notification has no stored employeename (see the shopfloor feed)."""
try:
from plugins.employees.api.routes import resolve_employee_display_name
return resolve_employee_display_name(sso)
except Exception:
return None
def _employee_picture(sso):
"""Resolved display photo URL for an SSO, via the shared employees-plugin
resolver so kiosk cards match EmployeeDetail in both directory modes
@@ -723,7 +733,12 @@ def get_shopfloor_notifications():
result['employeepicture'] = employee_override.get('picture')
else:
result['employeesso'] = n.employeesso
result['employeename'] = n.employeename
# Stored name first (import/manual entry), else resolve live from
# the directory so shopdb-only imports still show names.
name = n.employeename
if not name and n.employeesso and ',' not in n.employeesso:
name = _employee_name(n.employeesso)
result['employeename'] = name
result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None
return result
@@ -742,7 +757,8 @@ def get_shopfloor_notifications():
return [
notification_to_shopfloor(n, {
'sso': sso,
'name': names[i] if i < len(names) else sso,
'name': (names[i] if i < len(names) and names[i] else None)
or _employee_name(sso) or sso,
'picture': _employee_picture(sso) if show_photo else None,
})
for i, sso in enumerate(ssos)

View File

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

View File

@@ -1,45 +1,711 @@
"""Printedparts plugin API routes."""
"""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 <img> 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 Printedparts
from ..models import PrintedItem
printedparts_bp = Blueprint('printedparts', __name__)
@printedparts_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_printedparts():
"""List printedparts assets, paginated."""
@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 = Printedparts.query
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.gagelabtag.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)
data = [item.to_dict() for item in items]
return paginated_response(data, page, per_page, total)
return paginated_response(
[item.to_dict() for item in items], page, per_page, total)
@printedparts_bp.route('/<int:assetid>', methods=['GET'])
@jwt_required(optional=True)
def get_printedparts(assetid: int):
"""Get a single printedparts by assetid."""
item = Printedparts.query.get(assetid)
@printedparts_bp.route('/items/<int:item_id>', 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 internal itemcode is always auto-minted; the OPTIONAL gagelabtag
carries the gage lab's assigned WJRP asset number (unique-checked)."""
data = request.get_json() or {}
itemname = (data.get('itemname') or '').strip()
if not itemname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
gagelabtag = (data.get('gagelabtag') or '').strip().upper()
if gagelabtag and PrintedItem.query.filter_by(gagelabtag=gagelabtag).first():
return error_response(ErrorCodes.CONFLICT,
f'Gage lab tag {gagelabtag} is already in use',
http_code=409)
threshold = data.get('lowstockthreshold')
if threshold is None:
threshold = int(Setting.get('printedparts_default_threshold') or 5)
item = PrintedItem(
itemname=itemname,
gagelabtag=gagelabtag or None,
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/<int:item_id>', 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.NOT_FOUND,
f'Printedparts with assetid {assetid} not found',
http_code=404,
)
ErrorCodes.VALIDATION_ERROR,
'quantityonhand is ledger-managed; use restock or adjust')
if 'gagelabtag' in data:
gagelabtag = (data.get('gagelabtag') or '').strip().upper()
if gagelabtag:
clash = PrintedItem.query.filter(
PrintedItem.gagelabtag == gagelabtag,
PrintedItem.printeditemid != item.printeditemid).first()
if clash:
return error_response(
ErrorCodes.CONFLICT,
f'Gage lab tag {gagelabtag} is already in use',
http_code=409)
item.gagelabtag = gagelabtag or None
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/<int:item_id>', 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/<int:item_id>/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/<int:item_id>/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=<image>)."""
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/<path:filename>', 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/<int:item_id>/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'<p><strong>{item.itemname}</strong> ({item.itemcode}) is down '
f'to <strong>{item.quantityonhand}</strong> '
f'(threshold {item.lowstockthreshold}).</p>'
f'<p>Bin: {item.binlocation or "-"}</p>'
f'<p>Time to print more.</p>')
try:
recipients = _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/<int:item_id>/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/<int:item_id>/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."""
scanned = (itemcode or '').strip().upper()
item = PrintedItem.query.filter(
or_(PrintedItem.itemcode == scanned,
PrintedItem.gagelabtag == scanned),
PrintedItem.isactive == True).first()
if not item and scanned.isdigit():
# Bare digits from the touch keypad match the NUMBER inside either
# identifier (internal code or gage-lab tag). Small catalog: scan
# actives and compare numeric tails; only a UNIQUE match counts.
wanted = int(scanned)
matches = []
for candidate in PrintedItem.query.filter_by(isactive=True).all():
for value in (candidate.itemcode, candidate.gagelabtag):
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
if tail and int(tail) == wanted:
matches.append(candidate)
break
if len(matches) == 1:
item = matches[0]
return item
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
def kiosk_item(itemcode):
"""Item summary for a scanned bin barcode (open read for the kiosk)."""
item = _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=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItem.itemcode,
PrintedItem.itemname,
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.join(PrintedItemTransaction,
PrintedItemTransaction.printeditemid == PrintedItem.printeditemid)
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname)
rows = [{'itemcode': code, 'itemname': name, 'takes': takes,
'quantitytaken': int(taken)}
for code, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['itemcode', 'itemname', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-consumption.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})
@printedparts_bp.route('/reports/by-person', methods=['GET'])
@jwt_required(optional=True)
def report_by_person():
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItemTransaction.employeesso,
func.max(PrintedItemTransaction.employeename),
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItemTransaction.employeesso)
rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes,
'quantitytaken': int(taken)}
for sso, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['employeesso', 'employeename', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-by-person.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})
# --- 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/<int:item_id>/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/<int:item_id>/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=<stl/3mf/gcode/...>, note=<what changed>.
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/<int:file_id>/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/<int:file_id>', 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')

View File

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

View File

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

View File

@@ -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"}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
"""Add printeditems.gagelabtag: the gage-lab assigned asset tag.
The gage lab issues WJRP asset numbers for printed parts; the internal
itemcode stays auto-generated, and this optional unique tag carries the
lab's number. The kiosk resolves scans/typed digits against both.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'printedparts0003gagetag'
down_revision = 'printedparts0002files'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('printeditems',
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
['gagelabtag'], unique=True)
def downgrade():
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
op.drop_column('printeditems', 'gagelabtag')

View File

@@ -1,5 +1,11 @@
"""Printedparts plugin models."""
from .printedparts import Printedparts
from .printeditem import (
PrintedItem,
PrintedItemTransaction,
PrintedItemFile,
TRANSACTION_TYPES,
)
__all__ = ['Printedparts']
__all__ = ['PrintedItem', 'PrintedItemTransaction', 'PrintedItemFile',
'TRANSACTION_TYPES']

View File

@@ -0,0 +1,138 @@
"""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')
gagelabtag = db.Column(db.String(50), unique=True, index=True,
comment='Gage-lab assigned asset tag, e.g. WJRP0117')
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,
'gagelabtag': self.gagelabtag,
'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,
}

View File

@@ -1,32 +0,0 @@
"""Printedparts model.
This is an Asset extension table keyed by assetid. The Asset row holds
the platform fields (assetnumber, name, vendorid, locationid, etc.);
this table holds the printedparts-specific fields. Replace the example fields
below with your domain model.
"""
from shopdb.api import db, BaseModel
class Printedparts(BaseModel):
"""Printedparts domain entity, extending Asset by assetid."""
__tablename__ = 'printedparts'
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
primary_key=True,
)
# TODO: replace these example fields with your domain fields.
examplefield = db.Column(db.String(255), nullable=True)
asset = db.relationship('Asset', backref=db.backref('printedparts', uselist=False))
def to_dict(self):
return {
'assetid': self.assetid,
'examplefield': self.examplefield,
}

View File

@@ -16,7 +16,7 @@ from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, Setting
from .models import Printedparts
from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile
from .api import printedparts_bp
logger = logging.getLogger(__name__)
@@ -46,16 +46,82 @@ class PrintedpartsPlugin(BasePlugin):
return printedparts_bp
def get_models(self) -> List[Type]:
return [Printedparts]
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',
@@ -64,6 +130,15 @@ class PrintedpartsPlugin(BasePlugin):
'Default low-stock threshold for new items'),
('printedparts_unknown_badge', 'deny', 'string',
'Kiosk policy when a badge resolves to no employee: allow or deny'),
('printedparts_alert_email', '', 'string',
'Comma-separated low-stock alert recipients; empty uses the '
'site alert_recipients'),
('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:

View File

@@ -0,0 +1 @@
"""Printedparts plugin services."""

View File

@@ -0,0 +1,113 @@
"""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
- 0<digits>BZ -> a physical badge wrapping a PayNo (keyboard-wedge
scanners emit this shape)
- anything else -> unresolvable
Name lookup honors the site's employee directory mode (the same setting the
employees/usb plugins use):
- selfhosted: the employees plugin's directoryemployees table, by SSO. The
table has no PayNo column, so a PayNo badge only resolves when the wrapped
digits are themselves the SSO.
- external: the HR directory via employee_connection() - SSO badges by SSO,
PayNo badges by PayNo (which also recovers the real SSO to record).
Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge
with no directory match; 'allow' records the SSO with an empty name.
"""
import logging
import re
from shopdb.api import Setting, employee_connection
logger = logging.getLogger(__name__)
_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 _parse_badge(badge):
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape."""
badge = (badge or '').strip()
if not badge:
raise BadgeError('Scan or enter a badge')
if badge.isdigit():
return 'sso', badge
match = _PAYNO_BADGE.match(badge)
if match:
return 'payno', match.group(1)
raise BadgeError('Unrecognized badge format')
def _selfhosted_lookup(digits):
"""(sso, name) from the employees plugin directory, or None."""
try:
from plugins.employees.models import DirectoryEmployee
from shopdb.api import db
employee = db.session.get(DirectoryEmployee, int(digits))
if employee:
return digits, f'{employee.firstname} {employee.lastname}'.strip()
except Exception:
logger.exception('Self-hosted directory lookup failed for %s', digits)
return None
def _external_lookup(kind, digits):
"""(sso, name) from the HR directory, or None. PayNo badges resolve to
the employee's real SSO."""
try:
conn = employee_connection()
except Exception:
logger.exception('HR directory connection failed')
return None
try:
with conn.cursor() as cursor:
column = 'SSO' if kind == 'sso' else 'PayNo'
cursor.execute(
f'SELECT SSO, First_Name, Last_Name FROM employees '
f'WHERE {column} = %s', (digits,))
row = cursor.fetchone()
if row:
sso = str(row[0] if not isinstance(row, dict) else row['SSO'])
first = row[1] if not isinstance(row, dict) else row['First_Name']
last = row[2] if not isinstance(row, dict) else row['Last_Name']
return sso, f'{(first or "").strip()} {(last or "").strip()}'.strip()
except Exception:
logger.exception('HR directory lookup failed for %s %s', kind, digits)
finally:
try:
conn.close()
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.
"""
kind, digits = _parse_badge(badge)
mode = (Setting.get('employee_directory_mode') or 'selfhosted').lower()
if mode == 'external':
resolved = _external_lookup(kind, digits)
else:
resolved = _selfhosted_lookup(digits)
if resolved is None:
policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower()
if policy != 'allow':
raise BadgeError('Badge not recognized - see the parts team')
return digits, ''
return resolved

View File

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

View File

@@ -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',
]

View File

@@ -1,389 +1,399 @@
"""User management API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
users_bp = Blueprint('users', __name__)
@users_bp.route('', methods=['GET'])
@jwt_required()
@require_role('admin')
def list_users():
"""List all users."""
users = User.query.order_by(User.username).all()
return success_response([user_to_dict(u) for u in users])
@users_bp.route('/<int:userid>', methods=['GET'])
@jwt_required()
def get_user(userid: int):
"""Get a single user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
return success_response(user_to_dict(user))
@users_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_user():
"""Create a new user."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
# Validate required fields
if not data.get('username'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required')
if not data.get('email'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required')
if not data.get('password'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required')
# Check uniqueness
if User.query.filter_by(username=data['username']).first():
return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409)
if User.query.filter_by(email=data['email']).first():
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
# Admin-created accounts are forced to change the password on first login
# unless the admin explicitly opts out.
mustchange = data.get('mustchangepassword', True)
user = User(
username=data['username'],
email=data['email'],
passwordhash=generate_password_hash(data['password']),
firstname=data.get('firstname'),
lastname=data.get('lastname'),
isactive=data.get('isactive', True),
mustchangepassword=bool(mustchange)
)
# Assign roles
role_ids = data.get('roles', [])
if role_ids:
roles = Role.query.filter(Role.roleid.in_(role_ids)).all()
user.roles = roles
db.session.add(user)
# Audit log
AuditLog.log('created', 'User', entityname=user.username)
db.session.commit()
# Best-effort welcome email. The account exists regardless of mail outcome;
# a failure is surfaced as a warning in the response, never a hard error.
warning = None
if data.get('sendwelcome', True) and user.email:
sent = _send_welcome_email(user, data['password'])
if not sent:
warning = 'User created but the welcome email could not be sent.'
payload = user_to_dict(user)
if warning:
payload['warning'] = warning
return success_response(payload, message='User created', http_code=201)
def _send_welcome_email(user, temp_password):
"""Send a new-user welcome email with sign-in details. Returns True on send.
Best-effort: any failure (including email being disabled) returns False so
the caller can surface a soft warning without failing user creation.
"""
from shopdb.core.api.settings import get_cached_settings
from shopdb.utils.mailer import render_email, send_email
settings = get_cached_settings() or {}
facility = settings.get('facility_name') or 'ShopDB'
base_url = (settings.get('site_base_url') or '').rstrip('/')
login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page'
body = (
f'<p>An account has been created for you at <strong>{facility}</strong>.</p>'
'<table style="border-collapse:collapse;font-size:14px;margin:12px 0;">'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Username</td>'
f'<td><strong>{user.username}</strong></td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Temporary password</td>'
f'<td><code>{temp_password}</code></td></tr>'
'</table>'
f'<p>Sign in at {login_link}. You will be asked to set a new password '
'the first time you log in.</p>'
)
html, text = render_email(f'Welcome to {facility}', body)
return send_email(user.email, f'Your {facility} account', html, text=text)
@users_bp.route('/<int:userid>', methods=['PUT'])
@jwt_required()
def update_user(userid: int):
"""Update a user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
changes = {}
# Update fields
if 'email' in data and data['email'] != user.email:
if User.query.filter(User.email == data['email'], User.userid != userid).first():
return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409)
changes['email'] = {'old': user.email, 'new': data['email']}
user.email = data['email']
if 'firstname' in data:
if data['firstname'] != user.firstname:
changes['firstname'] = {'old': user.firstname, 'new': data['firstname']}
user.firstname = data['firstname']
if 'lastname' in data:
if data['lastname'] != user.lastname:
changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
user.lastname = data['lastname']
# Admin-only fields (inline: gates a subset of fields on a shared route)
if current_user.hasrole('admin'):
if 'isactive' in data:
if data['isactive'] != user.isactive:
changes['isactive'] = {'old': user.isactive, 'new': data['isactive']}
user.isactive = data['isactive']
if 'roles' in data:
old_roles = [r.rolename for r in user.roles]
roles = Role.query.filter(Role.roleid.in_(data['roles'])).all()
new_roles = [r.rolename for r in roles]
if set(old_roles) != set(new_roles):
changes['roles'] = {'old': old_roles, 'new': new_roles}
user.roles = roles
# Unlock user
if data.get('unlock'):
user.lockeduntil = None
user.failedlogins = 0
changes['unlocked'] = {'old': True, 'new': False}
# Password change
if 'password' in data and data['password']:
user.passwordhash = generate_password_hash(data['password'])
changes['password'] = {'old': '***', 'new': '***'}
if changes:
AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes)
db.session.commit()
return success_response(user_to_dict(user), message='User updated')
@users_bp.route('/<int:userid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_user(userid: int):
"""Delete a user."""
if current_user.userid == userid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
username = user.username
db.session.delete(user)
AuditLog.log('deleted', 'User', entityid=userid, entityname=username)
db.session.commit()
return success_response(None, message='User deleted')
# Permissions endpoints
@users_bp.route('/permissions', methods=['GET'])
@jwt_required()
def list_permissions():
"""List assignable permissions grouped by category.
Driven by full_permission_catalog() (core plus ENABLED plugins) so a
disabled plugin's permissions drop out of the role grid. Roles assign by
name; the permissionid comes from the seeded Permission row when present.
"""
idbyname = {p.name: p.permissionid for p in Permission.query.all()}
catalog = full_permission_catalog()
catalog.sort(key=lambda e: (e[2], e[0]))
grouped = {}
flat = []
for name, description, category in catalog:
entry = {
'permissionid': idbyname.get(name),
'name': name,
'description': description,
}
grouped.setdefault(category, []).append(entry)
flat.append({**entry, 'category': category})
return success_response({'permissions': flat, 'grouped': grouped})
# Roles endpoints
@users_bp.route('/roles', methods=['GET'])
@jwt_required()
def list_roles():
"""List all roles with their permissions."""
roles = Role.query.order_by(Role.rolename).all()
return success_response([{
'roleid': r.roleid,
'rolename': r.rolename,
'description': r.description,
'usercount': r.users.count(),
'permissions': [p.name for p in r.permissions],
'isadmin': r.rolename == 'admin'
} for r in roles])
@users_bp.route('/roles', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_role():
"""Create a new role."""
data = request.get_json()
if not data or not data.get('rolename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
if Role.query.filter_by(rolename=data['rolename']).first():
return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409)
role = Role(
rolename=data['rolename'],
description=data.get('description')
)
# Assign permissions
if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
role.permissions = perms
db.session.add(role)
AuditLog.log('created', 'Role', entityname=role.rolename)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role created', http_code=201)
@users_bp.route('/roles/<int:roleid>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_role(roleid: int):
"""Update a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
# Cannot modify admin role permissions
if role.rolename == 'admin' and 'permissions' in request.get_json():
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions')
data = request.get_json()
changes = {}
if 'description' in data:
if data['description'] != role.description:
changes['description'] = {'old': role.description, 'new': data['description']}
role.description = data['description']
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
new_perms = [p.name for p in perms]
if set(old_perms) != set(new_perms):
changes['permissions'] = {'old': old_perms, 'new': new_perms}
role.permissions = perms
if changes:
AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role updated')
@users_bp.route('/roles/<int:roleid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_role(roleid: int):
"""Delete a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
if role.rolename == 'admin':
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
if role.users.count() > 0:
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
rolename = role.rolename
db.session.delete(role)
AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename)
db.session.commit()
return success_response(None, message='Role deleted')
def user_to_dict(user: User) -> dict:
"""Convert user to dict for API response."""
return {
'userid': user.userid,
'username': user.username,
'email': user.email,
'firstname': user.firstname,
'lastname': user.lastname,
'isactive': user.isactive,
'islocked': user.islocked,
'mustchangepassword': bool(user.mustchangepassword),
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
}
"""User management API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
users_bp = Blueprint('users', __name__)
@users_bp.route('', methods=['GET'])
@jwt_required()
@require_role('admin')
def list_users():
"""List all users."""
users = User.query.order_by(User.username).all()
return success_response([user_to_dict(u) for u in users])
@users_bp.route('/<int:userid>', methods=['GET'])
@jwt_required()
def get_user(userid: int):
"""Get a single user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
return success_response(user_to_dict(user))
@users_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_user():
"""Create a new user."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
# Validate required fields
if not data.get('username'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required')
if not data.get('email'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required')
if not data.get('password'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required')
# Check uniqueness
if User.query.filter_by(username=data['username']).first():
return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409)
if User.query.filter_by(email=data['email']).first():
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
# Admin-created accounts are forced to change the password on first login
# unless the admin explicitly opts out.
mustchange = data.get('mustchangepassword', True)
user = User(
username=data['username'],
email=data['email'],
passwordhash=generate_password_hash(data['password']),
firstname=data.get('firstname'),
lastname=data.get('lastname'),
isactive=data.get('isactive', True),
mustchangepassword=bool(mustchange)
)
# Assign roles
role_ids = data.get('roles', [])
if role_ids:
roles = Role.query.filter(Role.roleid.in_(role_ids)).all()
user.roles = roles
db.session.add(user)
# Audit log
AuditLog.log('created', 'User', entityname=user.username)
db.session.commit()
# Best-effort welcome email. The account exists regardless of mail outcome;
# a failure is surfaced as a warning in the response, never a hard error.
warning = None
if data.get('sendwelcome', True) and user.email:
sent = _send_welcome_email(user, data['password'])
if not sent:
warning = 'User created but the welcome email could not be sent.'
payload = user_to_dict(user)
if warning:
payload['warning'] = warning
return success_response(payload, message='User created', http_code=201)
def _send_welcome_email(user, temp_password):
"""Send a new-user welcome email with sign-in details. Returns True on send.
Best-effort: any failure (including email being disabled) returns False so
the caller can surface a soft warning without failing user creation.
"""
from shopdb.core.api.settings import get_cached_settings
from shopdb.utils.mailer import render_email, send_email
settings = get_cached_settings() or {}
facility = settings.get('facility_name') or 'ShopDB'
base_url = (settings.get('site_base_url') or '').rstrip('/')
login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page'
body = (
f'<p>An account has been created for you at <strong>{facility}</strong>.</p>'
'<table style="border-collapse:collapse;font-size:14px;margin:12px 0;">'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Username</td>'
f'<td><strong>{user.username}</strong></td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Temporary password</td>'
f'<td><code>{temp_password}</code></td></tr>'
'</table>'
f'<p>Sign in at {login_link}. You will be asked to set a new password '
'the first time you log in.</p>'
)
html, text = render_email(f'Welcome to {facility}', body)
return send_email(user.email, f'Your {facility} account', html, text=text)
@users_bp.route('/<int:userid>', methods=['PUT'])
@jwt_required()
def update_user(userid: int):
"""Update a user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
changes = {}
# Update fields
if 'email' in data and data['email'] != user.email:
if User.query.filter(User.email == data['email'], User.userid != userid).first():
return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409)
changes['email'] = {'old': user.email, 'new': data['email']}
user.email = data['email']
if 'firstname' in data:
if data['firstname'] != user.firstname:
changes['firstname'] = {'old': user.firstname, 'new': data['firstname']}
user.firstname = data['firstname']
if 'lastname' in data:
if data['lastname'] != user.lastname:
changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
user.lastname = data['lastname']
# Admin-only fields (inline: gates a subset of fields on a shared route)
if current_user.hasrole('admin'):
if 'isactive' in data:
if data['isactive'] != user.isactive:
changes['isactive'] = {'old': user.isactive, 'new': data['isactive']}
user.isactive = data['isactive']
if 'roles' in data:
old_roles = [r.rolename for r in user.roles]
roles = Role.query.filter(Role.roleid.in_(data['roles'])).all()
new_roles = [r.rolename for r in roles]
if set(old_roles) != set(new_roles):
changes['roles'] = {'old': old_roles, 'new': new_roles}
user.roles = roles
# Unlock user
if data.get('unlock'):
user.lockeduntil = None
user.failedlogins = 0
changes['unlocked'] = {'old': True, 'new': False}
# Password change
if 'password' in data and data['password']:
user.passwordhash = generate_password_hash(data['password'])
changes['password'] = {'old': '***', 'new': '***'}
if changes:
AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes)
db.session.commit()
return success_response(user_to_dict(user), message='User updated')
@users_bp.route('/<int:userid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_user(userid: int):
"""Delete a user."""
if current_user.userid == userid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
username = user.username
# Rows that reference the user would otherwise block the delete:
# revoke their API tokens outright, and DETACH their audit history
# (userid -> NULL) - the log rows themselves are kept, entityname and
# details still tell the story.
from shopdb.core.models import ApiToken
ApiToken.query.filter_by(userid=userid).delete(synchronize_session=False)
AuditLog.query.filter_by(userid=userid).update(
{'userid': None}, synchronize_session=False)
db.session.delete(user)
AuditLog.log('deleted', 'User', entityid=userid, entityname=username)
db.session.commit()
return success_response(None, message='User deleted')
# Permissions endpoints
@users_bp.route('/permissions', methods=['GET'])
@jwt_required()
def list_permissions():
"""List assignable permissions grouped by category.
Driven by full_permission_catalog() (core plus ENABLED plugins) so a
disabled plugin's permissions drop out of the role grid. Roles assign by
name; the permissionid comes from the seeded Permission row when present.
"""
idbyname = {p.name: p.permissionid for p in Permission.query.all()}
catalog = full_permission_catalog()
catalog.sort(key=lambda e: (e[2], e[0]))
grouped = {}
flat = []
for name, description, category in catalog:
entry = {
'permissionid': idbyname.get(name),
'name': name,
'description': description,
}
grouped.setdefault(category, []).append(entry)
flat.append({**entry, 'category': category})
return success_response({'permissions': flat, 'grouped': grouped})
# Roles endpoints
@users_bp.route('/roles', methods=['GET'])
@jwt_required()
def list_roles():
"""List all roles with their permissions."""
roles = Role.query.order_by(Role.rolename).all()
return success_response([{
'roleid': r.roleid,
'rolename': r.rolename,
'description': r.description,
'usercount': r.users.count(),
'permissions': [p.name for p in r.permissions],
'isadmin': r.rolename == 'admin'
} for r in roles])
@users_bp.route('/roles', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_role():
"""Create a new role."""
data = request.get_json()
if not data or not data.get('rolename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
if Role.query.filter_by(rolename=data['rolename']).first():
return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409)
role = Role(
rolename=data['rolename'],
description=data.get('description')
)
# Assign permissions
if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
role.permissions = perms
db.session.add(role)
AuditLog.log('created', 'Role', entityname=role.rolename)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role created', http_code=201)
@users_bp.route('/roles/<int:roleid>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_role(roleid: int):
"""Update a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
# Cannot modify admin role permissions
if role.rolename == 'admin' and 'permissions' in request.get_json():
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions')
data = request.get_json()
changes = {}
if 'description' in data:
if data['description'] != role.description:
changes['description'] = {'old': role.description, 'new': data['description']}
role.description = data['description']
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
new_perms = [p.name for p in perms]
if set(old_perms) != set(new_perms):
changes['permissions'] = {'old': old_perms, 'new': new_perms}
role.permissions = perms
if changes:
AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role updated')
@users_bp.route('/roles/<int:roleid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_role(roleid: int):
"""Delete a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
if role.rolename == 'admin':
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
if role.users.count() > 0:
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
rolename = role.rolename
db.session.delete(role)
AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename)
db.session.commit()
return success_response(None, message='Role deleted')
def user_to_dict(user: User) -> dict:
"""Convert user to dict for API response."""
return {
'userid': user.userid,
'username': user.username,
'email': user.email,
'firstname': user.firstname,
'lastname': user.lastname,
'isactive': user.isactive,
'islocked': user.islocked,
'mustchangepassword': bool(user.mustchangepassword),
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
}

View File

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

View File

@@ -45,7 +45,12 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
# shape as the exempt collector blueprint; the geenforce admin endpoints in
# the same blueprint are JWT+permission gated and ARE swept.
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
'geenforce.post_report'}
'geenforce.post_report',
# Deliberately open kiosk write: decrement-only,
# badge-attributed server-side. Decision record in
# docs/proposals/printedparts-plugin.md; justification in
# the plugin README.
'printedparts.kiosk_take'}
@pytest.fixture(autouse=True)

View File

@@ -0,0 +1,29 @@
def test_delete_user_with_tokens_and_audit_history(client, auth_headers, app, db):
"""Deleting a user revokes their API tokens and detaches (not deletes)
their audit rows - the importer-user case."""
from werkzeug.security import generate_password_hash
from shopdb.core.models import User, ApiToken, AuditLog
with app.app_context():
user = User(username='importer2', email='importer2@test.local',
passwordhash=generate_password_hash('x'), isactive=True)
db.session.add(user)
db.session.flush()
db.session.add(ApiToken(userid=user.userid, name='import token',
tokenprefix='deadbeef', tokenhash='x' * 64))
db.session.add(AuditLog(userid=user.userid, action='created',
entitytype='Asset', entityid=1,
entityname='imported thing'))
db.session.commit()
userid = user.userid
response = client.delete(f'/api/users/{userid}', headers=auth_headers)
assert response.status_code == 200, response.get_json()
with app.app_context():
assert db.session.get(User, userid) is None
assert ApiToken.query.filter_by(userid=userid).count() == 0
detached = AuditLog.query.filter_by(entityname='imported thing').one()
assert detached.userid is None

View File

@@ -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'] = 'printedparts0003gagetag'
# notifications indexes businessunitid on top of its anchor.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'

View File

@@ -0,0 +1,369 @@
"""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']
def test_gagelabtag_assigned_searched_and_kiosk_resolved(client, auth_headers):
"""The internal code stays auto-minted; the gage-lab tag is optional,
unique, searchable, and the kiosk resolves it (exact and bare digits)."""
created = client.post('/api/printedparts/items',
json={'itemname': 'Gage block holder',
'gagelabtag': 'wjrp0117'},
headers=auth_headers)
assert created.status_code == 201
data = created.get_json()['data']
assert data['gagelabtag'] == 'WJRP0117'
assert data['itemcode'].startswith('3DP') # internal code untouched
duplicate = client.post('/api/printedparts/items',
json={'itemname': 'Other',
'gagelabtag': 'WJRP0117'},
headers=auth_headers)
assert duplicate.status_code == 409
searched = client.get('/api/printedparts/items?search=WJRP0117',
headers=auth_headers).get_json()['data']
assert len(searched) == 1
by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117')
assert by_tag.status_code == 200
by_digits = client.get('/api/printedparts/kiosk/item/117')
assert by_digits.status_code == 200
assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'

View File

@@ -70,3 +70,32 @@ def test_single_employee_recognition_stays_single_card(client, db):
current = resp.get_json()['data']['current']
assert len(current) == 1
assert current[0]['employeesso'] == '111'
def test_shopfloor_names_resolve_live_when_not_stored(client, app, db):
"""A notification imported without employeename shows the directory name,
not the bare SSO - single and split-per-employee paths both."""
from plugins.employees.models import DirectoryEmployee
from plugins.notifications.models import Notification, NotificationType
from shopdb.core.models import Setting
with app.app_context():
Setting.set('employee_directory_mode', 'selfhosted',
valuetype='string', category='employees')
db.session.add(DirectoryEmployee(
sso=502000777, firstname='Recert', lastname='Person'))
ntype = NotificationType(typename='Recertification',
typecolor='recertification',
splitperemployee=True)
db.session.add(ntype)
db.session.flush()
db.session.add(Notification(
notificationtypeid=ntype.notificationtypeid,
notification='Recert due', isshopfloor=True,
employeesso='502000777', employeename=None))
db.session.commit()
feed = client.get('/api/notifications/shopfloor').get_json()['data']
cards = feed['current'] + feed['upcoming']
card = next(c for c in cards if c['notification'] == 'Recert due')
assert card['employeename'] == 'Recert Person'