flask plugin new output, minus the scaffold's AssetType seeding:
printed parts are quantity-based consumables, not ADR-001 assets.
on_install seeds the three plugin settings instead. Manifest pins
core >=0.11.0, depends on employees (badge name resolution), ships
disabled until a site opts in.
State that this is a bundled plugin whose frontend and three small core
edits land in this repo, list the three deliberate divergences from the
scaffold before the learner hits them, suggest a per-milestone solution
branch for instructors, and point out the earliest visible win (wire
the bare list page as soon as the GET endpoint works).
The kiosk take endpoint is the product's first unauthenticated
mutation; spell out the acceptance criteria (decrement-only, badge
attributed, bounded, physically rate-limited) so future open-write
endpoints meet the same bar. The dashboard-widget milestone is marked
optional: get_dashboard_widgets predates the ADR-010 data-only
renderers and needs a core component to render.
Design for a 3D-printed-parts storefront: item catalog with images and
quantity on hand, a transaction ledger attributing every take/restock/
adjust to a badge-scanned employee, an unauthenticated touch kiosk
(scan bin barcode, scan badge, keypad quantity), 1x0.5in CODE128 bin
labels, and stock/consumption/by-person reports.
The lab guide walks a developer through building it in seven
checkpointed milestones, reusing the USB badge contract, the
measuringtools migration baseline, the models-image upload trio, and
the open kiosk-endpoint precedents.
The X-Forwarded-For rewrite rule alone is not enough: waitress 2+
strips forwarded headers from untrusted proxies by default, so the app
still saw 127.0.0.1 with the rule active. Trust the loopback proxy and
consume x-forwarded-for on the waitress command line; waitress then
rewrites remote_addr to the real client. Runbook gains the
allowedServerVariables unlock (500.52) and both troubleshooting rows.
The form-control and notification-tint blocks applied on the OS
prefers-color-scheme alone, so a machine in OS dark mode leaked dark
widget styling into the app's explicit light theme - dropdown options
rendered near-black on black. The theme store always stamps data-theme
at startup, so scope these rules to [data-theme=dark]. Dropdown options
in dark mode use the solid card background instead of the text color.
Loader: bool() on pymysql bit(1) bytes is always true - isinstallable
and isshopfloor imported as 1 for every row; route through _truthy_bit.
The employee source DB is now optional (shopdb-only imports).
Frontend: under a subpath mount the 401 interceptor stored the browser
path (mount base included) as the login redirect and the router applied
its base again (/ops/ops). New stripBase() keeps redirects base-free.
tools/export-github.sh automates the publication flow: prune + scrub +
commit into ~/projects/shopdb-flask-pub and emit a transfer bundle.
Overview and plugin list cover all twelve plugins (geenforce, network
subnets, measuring tools, warranty, USB); naming examples use living
tables/columns instead of retired pctypes/isvnc; API params match the
implementation (perpage, dir, assettype); import section points at the
IMPORT-API surface and the wjf reference loader. DATA_MIGRATION_GUIDE
is now a pointer stub (its direct-DB approach is superseded).
Import-surface docs and docstrings describe the automation as a
migration script; status-doc references in CHANGELOG/ADR-009/ROADMAP
point at repo files. Screenshot/verify tools write to /tmp/shopdb-shots
(created on import) instead of a machine-specific directory.
The app can run as an IIS Application under an existing site
(e.g. https://host/ops/) instead of its own site + port:
- frontend: vite base via VITE_BASE_PATH; router history, axios
baseURL, and root-absolute asset/route paths resolve through
utils/basePath.js withBase()
- backend: MOUNT_PATH (env or .env) wraps the app in a WSGI
middleware that shifts the prefix into SCRIPT_NAME, so one knob
serves API + SPA under the mount
- docs: INSTALL-WINDOWS-IIS.md section 7b runbook + troubleshooting
rows; DEPLOY-WINDOWS-IIS.md pointer; commented examples in
deploy/windows/web.config and .env.example
Root deployment unchanged (MOUNT_PATH unset, base '/'). Also folds
two stray root-absolute callers into the shared plumbing
(MachineForm relationship-types fetch, reports CSV window.open).
Replace the free-text/datalist vendor field with a plain select populated from
/api/vendors, so a warranty vendor is always one of the site's known vendors. An
existing warranty's vendor is kept selectable even if it is absent from the
catalog, so editing never blanks it.
- Vendor is now a datalist combobox seeded from the site vendor catalog
(/api/vendors), so users pick a known vendor instead of retyping it, while
still allowing free text. Kept as a string, not an FK, to keep the warranty
plugin decoupled from core.
- Renamed the "Provider" select to "Lookup source" with helper text. It was
confusingly synonymous with Vendor; it actually means where coverage data
comes from (manual entry vs a maker's warranty API that supports Refresh).
Classic ASP keeps retired machines in the machines table as history (isactive=0);
every other stage already filters isactive=1, but the assets hub, metrology,
locations and the verify count read machines unfiltered, so ~240 retired units
(incl. all G-prefix hostnames and 61 dead metrology PCs that each synthesized a
phantom measuring tool) landed as live assets. Add the isactive=1 filter to
those four queries. Downstream stages resolve via the id crosswalk, so warranties
/comms/relationships/installs for retired machines now drop automatically.
- Perf: list_warranties did a db.session.get(Asset) per link per warranty
(~1.8s for the full list). Eager-load the links and batch-fetch every linked
asset in one query -> ~0.19s.
- Filters: the "Status" label wrapped its select onto a second line, so the
dropdown sat above the search box; keep the label inline so they align.
- Covers: each asset chip now shows the asset name (often the hostname/alias) on
hover, keeping the machine number as the label.
A freshly-imported DB left setup_complete unset, so the admin was bounced into
the first-run wizard even though the instance is fully populated. The harness now
sets setup_complete=true (it already mints the admin), so an imported instance
goes straight to the app.
Routing by pctype wrongly swept ~105 metrology PCs (CMM/Genspect/Keyence/Wax)
into measuringtools - but a PC that drives an instrument is still a computer;
the physical CMM/gauge is the tool. Dropped the pctype override: those PCs now
import as computers (measuringtools drops to the 48 real instrument rows).
Classic has no separate tool row for a metrology PC, so they'd be orphaned. New
metrology stage synthesizes a measuring-tool asset per metrology PC (typed by
its pctype: CMM / Form Tracer / Vision System / Genspect) and a Controls
relationship PC -> tool, mirroring what the runtime collector does.
Result: computers 663->751, measuringtools = 48 real + 88 synthesized (each
linked to its controlling PC), 88 new Controls relationships, no orphans.
808->966 tests (+ the migrations-mysql CI job), 11->12 bundled plugins (add
geenforce), Alembic head 7d24->7d25 (32 migrations; env.py sql_mode note for
strict MySQL 8), date to 2026-07-13, and a legacy-import pointer
(IMPORT-API/ADOPTION/PILOT-DEPLOY + the WJ reference loader).
.actions sets display:inline-flex (correct for a button container), but it was
applied directly to <td class="actions">, pulling the cell out of the table row
box so its bottom border rendered ~1px off from the other columns. Override
td.actions back to display:table-cell and space multiple buttons with a margin
instead of the flex gap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Row-click: the whole table row now navigates to the item detail (machines, PCs,
printers, network devices, measuring tools, applications), matching the Networks
list. The actions cell is @click.stop so View/Edit/Delete still work
independently; a shared .clickable-row style gives the cursor + hover.
Network hub: drop the VLANs tab - a subnet belongs to a VLAN (each Networks row
already shows its VLAN) so a sibling tab was redundant; VLAN naming stays in
Settings. Hub is now Devices | Networks.
Verified: row-click navigates on machines/pcs/network; hub shows two tabs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the two flat "Network Devices" + "View Networks" nav entries with a
single "Network" entry opening a tabbed hub: Devices | Networks | VLANs
(NetworkHub renders the existing device list, the subnet browse, and the VLAN
list; VLANs is now reachable outside Settings). /network -> hub; /networks
redirects to the Networks tab; subnet detail stays at /networks/:id.
Subnet "Devices on this network" now matches ANY asset whose primary IP falls in
the CIDR (PCs, printers, machines, measuring tools - not just network devices),
computed on the core Communication + Asset tables; each row links to its typed
detail (extension id resolved lazily/guarded per plugin). Fixes the empty list -
printers and PCs carry IPs and now appear (e.g. 35 devices on 10.80.92.0/24).
Also: subnet-browse search uses the standard form-control styling; dropped the
redundant per-tab page header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Subnets previously lived only under Settings, easily confused with the Network
Devices asset list. Add a front-facing browse + detail:
- Nav: rename "Network" -> "Network Devices"; add "View Networks" (subnets), both
under Assets (network plugin get_navigation_items; frontend fallback matched).
- /networks (SubnetsBrowse): all subnets with name / CIDR / type / VLAN / notes,
searchable, row-click to detail.
- /networks/:id (SubnetDetail): the subnet (CIDR, network address, type, VLAN,
gateway, notes) plus the network devices whose primary IP falls inside its
CIDR - get_subnet now computes that membership in Python (a device's IP lives
in a Communication row, so it is not a plain SQL join).
Verified on the import DB: 37 networks list (real WJ subnets), detail renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.relationships-section had card styling but lacked the margin-bottom +
break-inside:avoid that .section-card has, so it sat flush against the Notes
card below it and looked like one merged card (and could split across a multicol
break). Add both to match section-card. Affects every asset detail page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
USB Checkout History rendered every record unbounded (long table for heavy
users), inconsistent with the recognitions list right above it. Add the same
limit (10) + "Show N more" / "Show less" toggle recognitions use. Client-side
only; no API change (per-user history is small at current scale).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Classic ShopDB listed an application's related KB articles on its page; the flask
app detail didn't, even though KB articles carry an appid FK. get_application now
returns a knowledgebase list (KB rows linked by appid; lazy + plugin-guarded so
core stays decoupled), and ApplicationDetail.vue renders a Knowledge Base section
linking each article.
Verified: an app returns its linked KB (e.g. 77 articles) and the section renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The applications stage dropped applicationlink and documentationpath, so the app
detail's "Launch Application" and "Documentation" links were always empty. Map
both from the classic applications table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_search_employees always queried the external HR database (employee_connection),
so a site running the selfhosted employee directory (the app-owned
directoryemployees table) got zero employee results - searching an SSO or name
found nothing, and there was no way to reach the employee profile. Made it
mode-aware via the employee_directory_mode setting: selfhosted -> query the
DirectoryEmployee table (lazy, plugin-guarded); external -> the HR DB as before.
Verified: SSO 210009518 -> Jeff Pierce -> /employees/210009518; name "Pierce"
-> Pierce Cox, Andy Pierce, Jeff Pierce.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A recognition/training notification for many people prefixed the calendar title
with the entire roster ("Name1, Name2, ... , Name20: description"), burying the
description. to_calendar_event now shows "First Person +N" when more than one
person is listed; single-person titles are unchanged and the detail popup still
shows the full employeename. Fixes the cluttered month grid, especially after
the loader began resolving employee SSOs to names.
31 notification/calendar tests pass; naming green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notifications imported with only employeesso, leaving employeename null - the
model displays "employeename or employeesso", so recognition/training cards
showed a bare SSO instead of a name. Build an SSO -> "First Last" map from the
employee source and populate employeename (comma-separated SSOs -> joined
names). SSOs not in the directory (former/non-WJF) stay null and fall back to
the SSO, as before.
Verified on the import DB: 261 notifications re-imported, names resolved
(Brandon Saltz, Jon Kolkmann, ...).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found by test-deploying on a Windows + MySQL 8.0 VM: migration 7a01 seeds the
canonical relationship types with a raw INSERT that omits the NOT-NULL
createddate/modifieddate columns (the ORM supplies those via Python defaults at
runtime, but a raw migration INSERT does not). MySQL 5.x's lax default sql_mode
accepted it; strict MySQL 8 rejects it with 1364 "Field 'createddate' doesn't
have a default value", so a fresh `flask db upgrade` died at 7a01. Dev runs
MySQL 5.6, so this never surfaced locally.
migrations/env.py now sets the migration session sql_mode to
NO_ENGINE_SUBSTITUTION (dropping STRICT_TRANS_TABLES) for the migration run
only - the app's own runtime connections keep their mode. Makes the whole chain
portable across MySQL versions. Guarded for non-MySQL (sqlite tests).
68 migration/smoke tests pass; fresh upgrade to head verified on MySQL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs/PILOT-DEPLOY.md ties the generic per-site deploy (DEPLOY.md) to the legacy
import: pre-flight, stand up an empty instance, enable all plugins (incl usb),
load the three classic dumps into scratch DBs, run the WJ loader against the
pilot DB, verify (row-count audit + UI spot-check checklist), a parallel-run
window, cutover, rollback, and post-cutover (backups, photos, GE-Enforce).
Includes the expected import magnitudes from the dev run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the mapper retirement (the new docs missed the prior commit's
staging). Adds docs/IMPORT-ADOPTION.md (two-layer import story + stage/crosswalk
guidance), scripts/migration/README.md (dir superseded, points at the API +
loader), and updates the WJ loader README to complete status.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cleanup after the reference loader (scripts/site_imports/wjf/) proved out.
Removed the drifted direct-SQL migrators - migrate_assets/communications/
notifications/usb.py, run_migration.py, verify_migration.py, and
scripts/import_from_mysql.py. They targeted a nonexistent equipment table, the
retired Machine model, and columns that no longer exist; nothing imported them.
scripts/migration/README.md now points at the import API + the site loader.
Kept the one-time SQL fixups (fix_legacy_schema.sql, one-offs/).
Added docs/IMPORT-ADOPTION.md: the two-layer import story (stable IMPORT-API
contract + per-site loader), stage-ordering + crosswalk guidance, the
agent-assisted mapping path, and what the WJ loader demonstrates. Updated the
loader README to complete status (all 15 stages, final counts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
printers: printers come from the printers TABLE (not the machines hub), so a
dedicated stage - assetnumber synthesized PRN-{printerid}, IP folded via the
create route, host machineid resolved to a location when it is a LocationOnly
row. Skips inactive. 50 of 56 imported.
relationships: a "Controlled By" edge now flips to the forward Controls
direction (source/target swapped, mapped to the Controls type) instead of
importing a redundant inverse type.
Final fresh full run: 983 assets (933 machines-hub + 50 printers), zero endpoint
errors, all 15 stages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the loader end to end. Verified on a fresh scratch target, zero
endpoint errors:
- locations: the 24 islocationonly rows -> core Locations (crosswalk machineid
-> locationid).
- relationships: 93 active edges imported (206 of 299 dropped because an
endpoint became a Location / was skipped / dedup-lost); types folded onto the
seeded canonical set; dedup on (source,target,type).
- subnets: 37 (full CIDR reconstructed as INET_NTOA(ipstart)+suffix; VLANs
lookup-or-create by number; 3 duplicate CIDRs first-wins-skipped).
- usb: 18 cmmc devices + 232 check-in/out events, paired with per-device open
state so unpaired log rows do not 400. Needs the usb plugin enabled + usb
directory mode selfhosted.
- verify: source-vs-target row-count audit (assets 1167->933 by the skip rules,
applications 121=121, employees 415=415, KB 342->341).
Full pipeline default runs all 14 stages in order. NOTE: the import target needs
every bundled plugin enabled (usb ships disabled in this dev registry - enable
it before importing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Of the 158 machinetypeid=1 rows, only 24 carry the islocationonly bit (real
named areas: DT Office, IT Closet, Materials, ...). The other 134 are active,
modelled shop machines just left untyped - routing all 158 to Locations dropped
those 134 real assets. Route on the bit instead; the 134 untyped rows import as
machines with a null subtype (machinetypeid=1 is not a real machine subtype, so
catalog skips seeding one).
Also process asset routes in richness order (computer > measuringtool > network
> machine) so on a duplicate machinenumber the PC - which carries installs + IP
a bare untyped machine does not - wins first-come.
Result on the scratch target: 933 assets (computer 663, machine 76, network 58,
measuringtool 136), 24 locations (was mis-routing 158), installs 850 (was 653 -
PCs no longer lose their numbers to bare machines), warranties 464, comms 461.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All consume the machineid->assetid crosswalk from the assets hub. Verified
against the scratch target, zero endpoint errors:
- communications: 435 primary IPs folded onto assets. No bulk endpoint exists,
so this is the plan's documented direct-ORM gap (reads the source
communications table where comstypeid=1 AND isprimary=1, not machines.ipaddress1
which is empty).
- applications: supportteams 45, applications 121 (colliding names dedup via the
unique-appname 409-resolve), appversions 47, installs 653 (machineid ->
assetid -> computerid; only computer assets take installs).
- warranties: 424 linked, vendor hardcoded Dell (source has none).
- notifications: types 6, notifications 261 (2099 sentinel endtime clamped).
- knowledgebase: 341 (appid resolved through the applications name map).
Inactive rows skipped everywhere per the decisions. Remaining loader stages:
locations (the 158 LocationOnly rows), relationships (301 active edges),
subnets/VLANs, usb (cmmc pairing), verify.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the keystone stages to the reference loader.
catalog: seeds modeltypes (from classic machinetypes + category), the per-plugin
asset subtypes routed by machinetype (machines/network/measuringtool types),
computer subtypes (from pctype), the 5-row controllertypes vendor/model split,
and the models catalog - each with a persisted legacy->new crosswalk. Verified:
modeltypes 31, computertypes 12, models 118.
assets (the hub): fans classic machines out to the right endpoint by
machinetypeid (+ the pctype metrology override), applying the resolved
decisions - assetnumber = machinenumber else hostname, skip 9999, skip duplicate
machinenumbers, LocationOnly/printer/USB routed out. Persists the machineid ->
assetid crosswalk every downstream stage needs. Verified against a fresh scratch
target: 884 assets (computer 623, machine 68, network 58, measuringtool 135),
zero endpoint errors, idempotent re-run (stays 884). Skips: location 158, dup 69,
other 53, 9999 1.
Harness now runs each plugin's idempotent on_install so the AssetType rows exist
(a DB built with plugin upgrade-all instead of a fresh install lacks them, and
the create routes 500 without them). 409-resolve lookups page through per_page.
Remaining stages: communications (primary IP fold - source is the communications
table, not machines.ipaddress1), applications/installs, warranties, notifications,
KB, subnets/VLANs, usb, verify.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Layer 2 of the import design (see the memory + scratchpad/IMPORT-PLAN.md): a
SITE-SPECIFIC reference loader that maps WJ's classic-ASP schema onto the
maintained, schema-agnostic IMPORT-API contract. Other sites copy the pattern
against their own source DB; nobody runs this loader as-is.
Harness (scripts/site_imports/wjf/harness.py): builds the app against the
current DATABASE_URL (point it at a throwaway import DB), mints an unscoped
admin PAT in-process, and drives the real import endpoints through the app test
client with Authorization: Bearer + X-Import-Mode - exercising the same
routes/authz/validation an HTTP client would, no running server needed.
Read-only pymysql access to the three scratch source DBs; legacy-id -> new-id
crosswalks persist to JSON so a crashed run resumes and later stages resolve FKs.
Stages implemented + verified idempotent against a fresh scratch target
(shopdb_flask_import): reference (vendors 46, businessunits 13, operatingsystems
11) and employees (directory 415, re-run updated-not-duplicated). Remaining
stages (models, applications, assets hub + crosswalk, dependents, network, usb,
verify) are stubbed with the same shape; README documents the adoption playbook.
idmap.json is generated state (gitignored).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
POST /api/applications/{id}/versions now calls apply_import_timestamps so an
imported version's original dateadded/releasedate survives (was skipped, unlike
the app + install endpoints). No-op outside import mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The legacy-import surface (docs/IMPORT-API.md) is the schema-agnostic contract
every adopting site targets; these close two gaps found while mapping the WJ
classic import.
Network device IP: POST/PUT /api/network now accept an `ipaddress` and
materialize a primary Communication (mirroring the printer route), and GET
(list + detail + create/update result) surface it. Previously a network
device's IP - which lives in the communications table, not on the extension -
had no HTTP import path at all.
communicationtypes seed: `flask seed reference-data` now seeds the eight
canonical communication types (IP/Serial/Network_Interface/USB/Parallel/VNC/
FTP/DNC), which IMPORT-API.md already documents as a prerequisite. The IP type
must exist before any asset import so printer/network routes can attach an IP.
There is no CRUD endpoint for these, so seeding is the only path.
Tests: network create/update IP upsert + GET surfacing + seed creates IP type.
204 targeted tests pass; naming + pyflakes green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the database review (verdict: sound-with-minor-issues). Applies the
actionable findings.
Redundant indexes: five non-unique secondary indexes duplicated a named idx_*
or a unique index on the same column - ix_communications_assetid,
ix_computers_hostname, ix_networkdevices_hostname, ix_printers_hostname (each
shadowing an idx_*), and idx_usb_serial (shadowing the serialnumber unique
index). Removed the redundant index source from the models (column index=True /
the extra db.Index) and added core migration 7d25 dropping the live duplicates.
The unique ix_*_assetid indexes are kept (they enforce assetid uniqueness).
Dead column: usbcheckouts.machineid was a NOT NULL soft-ref to the retired
machines table storing sentinel 0 (ADR-001). Dropped from the model + the
machineid=0 literal in selfhosted checkout; usb plugin migration 0002 drops it
live (downgrade restores it default 0).
Index: notifications.businessunitid (filtered by the shopfloor feed) was
unindexed; added index=True + notifications migration 0002.
CI: new migrations-mysql job proves the real multi-site deploy path - fresh
`flask db upgrade` + per-plugin install on utf8mb4 MySQL from empty, asserting
table count + charset and a clean second-run no-op. The pytest suite only
exercises SQLite create_all(), so a regression in the Alembic chain on MySQL
would otherwise ship undetected.
Verified: fresh core upgrade on a scratch utf8mb4 MySQL builds clean + no-op on
rerun (redundant indexes absent, unique assetid kept); plugin migrations applied
+ verified on the dev DB (machineid gone, bu index present). 953 backend tests
pass; naming + pyflakes green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DB review found four DateTime columns defaulting to db.func.now() (MySQL
session-timezone wall clock) while the rest of the schema stores naive UTC, so
one schema mixed two clocks and to_dict() labelled the local values UTC with a
'Z' suffix. Switch application.dateadded, computers.installeddate,
knowledgebase.lastupdated (default + onupdate), and slides.uploadeddate to the
module-level naive-UTC _utcnow callable already used elsewhere (apitoken.py).
ORM-side default only - no column-type change, no data migration; affects
new/updated rows.
Targeted tests pass (154); naming + pyflakes green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Naming convention (LOCKED): rename the ManifestEditor simulate state sim ->
simulateInputs / simResult -> simulateResult (+ .sim-result CSS class) - 'sim'
was banned standalone shorthand. Rename AssetRelationships props assetId ->
assetid and machineNumber -> machinenumber so a prop holding a DB field value
mirrors it verbatim; updated the five detail-page call sites (:assetid=).
CSS variables: SearchResults per-domain badge palette moved into CSS variables
on the container; the duplicated prefers-color-scheme dark block collapses to a
single set of variable overrides instead of restating all ten selectors.
frontend build green; vitest 49 pass; naming green; search badges + detail
relationships verified rendering with no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review flagged ROADMAP as six contract versions stale. Set opening version to
0.11.0; mark phase 6 (multi-site distribution) done and name the real last
milestone (legacy import + prod pilot); drop the three completed items
(per-plugin Alembic chains per ADR-008, local font bundling - Inter is bundled
via @fontsource, measuringtools plugin built); reframe the frontend item to the
part that actually remains (external plugin UI packaging - hooks + gating
already shipped via ADR-009/010); add ADR-007..012 to the decision-log pointers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fills the review's highest-value coverage gaps in the framework's own core
feature.
New tests/test_plugin_lifecycle.py: manager enable/disable dependency guards
(beta depends on alpha - enable-beta-first refused, disable-alpha-while-beta-on
refused) via synthetic plugins; enable seeds a plugin's RBAC permissions
idempotently; registry disable-survives-reload, corrupt-file recovery, and the
equipment->machines rename migration; `flask seed settings` idempotency.
test_plugin_contract.py: BUNDLED_PLUGINS now covers all bundled plugins incl.
geenforce, measuringtools, warranty (was 9, contradicting the CLAUDE.md "all
bundled satisfy the contract" claim); the structural checks now run against them.
test_authz.py: account lockout auto-unlock path (expired lockeduntil -> correct
password logs in and clears the lock state), previously untested.
All new tests pass; naming green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings exposure (review medium): GET /api/settings and /api/settings/<key>
now return the full table only to an authenticated principal. Unauthenticated
callers (kiosk dashboards, print pages, login screen, setup router) get just a
public allowlist - categories branding + map plus a named set (site_base_url,
facility_name, printer_hostname_template, contact_email_domain,
servicenow_enabled, setup_complete). A non-public single-key GET returns 404 so
existence is not confirmed. Secrets stay masked in both cases. Closes the
unauthenticated enumeration of smtp_host / employee_db_host / zabbix_url /
servicenow URLs. Allowlist mirrors the keys siteSettings.js + mapConfig.js +
setupState.js read before login.
audit.view (review low): the three audit-read routes (list, entity-history,
stats) were jwt_required only despite a defined-but-unwired audit.view
permission; now gated by it (seeded to admin), so a role-less member or unscoped
PAT can no longer read the cross-user audit trail.
flask seed test-user (review low): refuses outside DEBUG/TESTING - it creates
the well-known admin/admin123; production sites use `flask seed admin`.
Tests: unauthenticated allowlist + authed-full-masked + private-key-404, and
member-403 / admin-200 on audit routes. 336 authz tests pass; naming green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plugins list now carries a displayname (manifest display_name, else the
machine name title-cased). Adds display_name to the four whose title-case
was wrong: GE-Enforce, USB, Measuring Tools, Knowledge Base. The setup
wizard Features step and Settings > Plugins render it, so "Geenforce"/"Usb"
are gone.
Finish step shows a pointer when GE-Enforce is enabled: it still needs a
scoped service token (Settings > API Tokens) and a share export root
(GE-Enforce page) before the fleet uses it - operational config the wizard
does not collect.
frontend build green; naming green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>