Security hardening, multi-site portability, and a documentation and API-surface sweep

This commit is contained in:
2026-08-14 16:28:34 -04:00
parent 72f8da0e58
commit 265ca2eb01
76 changed files with 3684 additions and 811 deletions

View File

@@ -66,6 +66,132 @@ ADR-007 and ADR-002.
components nobody wrote. Cards are ordered by severity, hide themselves when
there is nothing to report, gate on a permission, and each fetches
independently so one broken endpoint cannot blank the board.
- Tech Tools: a section for the small conversions and code generation that
otherwise happen in a browser tab on somebody's phone. Starts with barcode and
QR codes laid out for the label stock actually in the printers.
- Kiosks report what they are. The dispatcher already read
`C:\Enrollment\display-type.txt` to pick its target; now it tells ShopDB, so
the Asset column on an enforcement report shows a reported value instead of
one inferred from a mapping table nobody filled in.
- Enforcement reports carry what the PC is, where it is, and whether it is
backed up: the host links to its own page, the Asset column names the machine
or instrument the PC controls, a map pin shows the location on hover, and the
backup column judges the chain rather than printing its last date.
- `flask relationships audit`, read-only, listing links that cannot both be
true, plus an inverse guard that refuses to create one.
- An employees endpoint that says WHY a name did not resolve, rather than
returning nothing and leaving the caller to guess.
- The display client module updates itself through the manifest, so a client
change no longer means hands on every kiosk.
- `docs/START-HERE.md` routes a reader by what they came to do, and is now the
wiki's landing page - which previously recommended the one install document
the docs themselves forbid for a new site. `docs/FLEET-ARCHITECTURE.md` is the
page nothing else could assume: how the server, GE-Enforce, the asset
reporter, the backup collectors and EventSaver relate, where credentials live
at both ends, and which of the five to open for a given symptom.
- `docs/ADOPTING-AT-ANOTHER-SITE.md`: how another site repoints the asset
reporter and the EventSaver screensaver at its own ShopDB, with worked
examples for Intune (including Machine Configuration/DSC), a GE-Enforce
manifest entry, and manual installation. Both tools were already built to be
repointed - the URL, the key and the targeting are inputs - but nothing said
so, and the two things that catch people out are written down: EventSaver's
compiled-in fallback path is not neutral, and a config enforced by hash
reverts a hand edit by design.
- `docs/PROJECT-MAP.md`, generated by `scripts/gen_project_map.py`: versions,
the plugin inventory, every Alembic chain head, the ADR index and the size of
the codebase, derived from the code. The hand-written equivalents had drifted
by two contract releases and 400 tests.
### Changed
- Plugin contract **0.19.0**: `get_dashboard_widgets` returns data and shape,
not a component name. The old shape named a Vue component per widget, which
cannot survive a lean build (ADR-013) and had five plugins pointing at
components nobody wrote. The change itself shipped earlier without a bump and
with `BasePlugin` and PLUGIN-HOOKS.md still teaching the dead shape; this
records it and corrects both.
- Plugin contract **0.17.0**: `DashboardDefault` is part of the `shopdb.api`
surface, so a plugin can resolve a display without reaching into core.
- Plugin contract **0.18.0**: one name per display role, and it is the kiosk's
own (`Dashboard`, `Lobby`, `3DPrintRoom`). Server and dispatcher had three
vocabularies for the same three kiosks, and a display could report a role core
could not store. `normalize_display_role` resolves case and the retired
spelling; an unknown value is kept verbatim so a typo stays visible.
Contract versions are their own series (ADR-002) and belong in this file;
0.17.0 and 0.18.0 shipped without an entry, which is what this note corrects.
- Publishing a GE-Enforce manifest is refused when PCs reporting for that scope
run an older client library than the manifest needs, naming the hosts. A minor
version bump that NARROWS behaviour is not backward compatible: an old library
reads a gated entry as ungated and installs every version it cannot detect.
- Shadow mode runs inside the enforce cycle instead of on its own schedule. The
share is mounted only for the length of a cycle, so a task on its own clock
woke to a missing drive and reported 0 installed / 0 skipped / 0 failed, which
is indistinguishable from a healthy no-op.
- A GE-Enforce report is judged stale on both clocks, the server's and the
client's asserted check-in, so a PC cannot mask real silence with a future
timestamp. A PC that has gone quiet stops reading as healthy.
### Fixed
- Documentation a reader could not follow. 98 curl examples single-quoted
`$TOKEN`, so the shell never expanded it and the server answered 422; a JSON
body carried a `//` comment, which made the server report a missing hostname
about a body that has one; the IIS install omitted `flask plugin upgrade-all`,
which is the 1054 error a deploy hits days later; a pilot loop exited 1 on
every iteration; and the external-plugin guide pinned a contract version six
releases stale, so a plugin built by following it is refused at startup. Nine
documents now point at the generated project map rather than typing a version,
and a test enforces that. The API inventory gained the 22 routes that were
serving traffic undocumented, with a parity test against the live URL map so
the next one cannot slip in silently.
- **Every plugin migration chain re-ran its head on each MySQL deploy.** Core's
Alembic env got a `connection.commit()`; the per-plugin template did not, so
the last migration of every run lost its version stamp while its DDL survived.
Invisible for as long as every head happened to be idempotent - and two were
not. The backups head cleared a column for every row, and the geenforce head
added a column unconditionally and failed on the second attempt. Both are now
bounded and guarded, and the template commits.
- A part-marker PC reported 200 on the cycle that created its marker and 500 on
every cycle after. Both of the links it makes were looked up by label, so a row
made by hand or by the legacy import was invisible and the insert violated the
uniqueness of (source, target, type); and the marker's asset number is derived
from the PC, so it could already be taken.
- A CMM reported the same way: the instrument IS the reported bay, so adoption
was relabelling the row the machine sync owns and finding it by that label.
- A reported identifier is matched exactly rather than with `ilike`, so `_` and
`%` in a file on a shopfloor PC are no longer wildcards. `MT-600_` had been
adopting MT-6001, and a bare `%` adopted whatever active asset came first.
- Measuring tools are adopted before they are minted. Minting derived the asset
number from the HOSTNAME, so a permanent instrument took the identity of
whichever PC drove it that week, and the collector could not see a tool it had
not created - which left legacy instruments shadowed by minted twins.
- An unanchored rsync exclude in the export was matching at any depth, so a
whole plugin was silently dropped from the published payload. Root excludes
are anchored and the export now aborts if a plugin manifest fails to survive.
- Backup revisions are ordered in Python, not with `ORDER BY ... NULLS LAST`,
which SQLite accepts and MySQL rejects.
### Security
- The Windows installer breaks ACL inheritance on the roots it creates. A
directory made directly under `C:\` inherits an inherit-only Modify grant for
Authenticated Users, so `C:\shopdb-flask`, `C:\Python314` and `C:\MySQL84`
were writable by any logged-on user. Two files inside them make that a local
privilege escalation: `shopdb-admin.ps1`, which self-elevates, and
`instance/config.py`, which the app executes unconditionally. The MySQL data
directory is locked down as well, since it holds the password hashes. A Python
installation this installer did not create is reported rather than seized.
- Site-specific values no longer ship in product code (ADR-015). The backups
share root defaults to blank instead of one site's file server, the shadow
client no longer overwrites a site's own ShopDB URL in the registry on every
cycle, the kiosk dispatcher takes its URL from the `site_base_url` setting and
refuses rather than guessing, and one site's hostname, FQDN and internal
subnets are out of the documentation, the generated API specs and the UI
placeholders. The ADR-015 scanner now covers PowerShell, the installer, the
seeds and generated JSON, case-insensitively, and FAILS the build; deliberate
organisation-wide defaults are declared with an `ADR-015-OK` marker. Both
publication gates carry the site patterns.
## [0.9.0] - 2026-08-11

View File

@@ -436,6 +436,43 @@ function Protect-File {
Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL'
}
function Protect-InstallRoot {
# A directory created directly under C:\ INHERITS the root's DACL, and the
# default C:\ DACL carries an inherit-only Modify grant for Authenticated
# Users. So C:\shopdb-flask, C:\Python314 and C:\MySQL84 are writable by
# every logged-on user until inheritance is broken.
#
# That is a local privilege escalation here specifically, because this
# installer puts two things a non-admin could then replace inside those
# roots: shopdb-admin.ps1, which self-elevates with -Verb RunAs, and
# instance\config.py, which the app loads unconditionally through
# from_pyfile. Overwrite either, wait for an administrator or the app pool
# to run it, and the code runs as them.
#
# RE-APPLIED EVERY TIME, not only on creation - same reasoning as the
# ProgramData backups directory. An upgrade over an installation that
# predates this must repair the ACL, or the hole survives the fix.
#
# The app pool's own RX/Modify grants are applied in stage 4, AFTER this,
# so breaking inheritance here does not lock the site out of its own tree.
param([string] $Path)
if (-not (Test-Path $Path)) { return }
Write-Log "hardening $Path (breaking inheritance from its drive root)"
Protect-File $Path -Directory
}
function Test-UserWritableRoot {
# Whether a non-admin can write to a path. Used ONLY on roots this
# installer did not create: hardening a Python another application already
# depends on would break that application, so a pre-existing root is
# reported rather than seized. Silence would be worse - the app would be
# running on an interpreter any user can replace.
param([string] $Path)
if (-not (Test-Path $Path)) { return $false }
$acl = & icacls.exe $Path 2>$null
return [bool]($acl -match '(Authenticated Users|BUILTIN\\Users|Everyone):\([^)]*\)?\(?(M|F|W)\)')
}
function New-Secret {
param([int] $Bytes = 48)
$b = New-Object byte[] $Bytes
@@ -461,6 +498,9 @@ function Test-SamePath {
}
# Paths derived once.
# Default so a stage that runs on its own still knows where the interpreter is;
# the Python stage overwrites it with what it actually installed.
$script:PyRoot = 'C:\Python314'
$Py = Join-Path $AppRoot 'venv\Scripts\python.exe'
$Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
$Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe'
@@ -956,6 +996,10 @@ Microsoft Visual C++ 2015-2022 Redistributable (x64) on this server by hand.
# bootstrapper returned 0 while installing to the wrong directory. Verify.
if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" }
Track 'mysql-files' $MysqlRoot
# The service is registered with `mysqld --install` and no obj=, so it runs
# as LocalSystem - the SYSTEM grant covers it. Nothing else needs to write
# here; a user who can replace mysqld.exe owns the service account.
Protect-InstallRoot $MysqlRoot
Write-Log 'MySQL binaries installed' 'OK'
# --- config -------------------------------------------------------------
@@ -1003,6 +1047,11 @@ data, STOP: you are about to destroy a database.
Write-Log 'initializing data directory'
Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize'
Track 'mysql-datadir' $MysqlDataDir
# The data directory holds the users table and its password hashes, and it
# sits under ProgramData, which grants Users read by inheritance - the same
# exposure the backup directory already had to be locked against. mysqld
# runs as LocalSystem, so Administrators + SYSTEM is sufficient.
Protect-File $MysqlDataDir -Directory
# --- service ------------------------------------------------------------
# Argument ORDER MATTERS: --install <name> BEFORE --defaults-file. Verified
@@ -1048,6 +1097,9 @@ directory to install cleanly.
# with default ACLs and populated afterwards is readable in the gap.
$sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql'
New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null
# Before the file goes in: this may be the first thing that creates AppRoot,
# and the file below holds the root and app database passwords.
Protect-InstallRoot $AppRoot
New-Item -ItemType File -Force -Path $sqlPath | Out-Null
Protect-File $sqlPath
$sql = @"
@@ -1150,8 +1202,17 @@ function Invoke-Stage2 {
# Same class of failure as MySQL's --defaults-file. Never put a space in a
# path this installer controls.
$pyTarget = 'C:\Python314'
# Stage 4 needs it to grant the app pool read access; a venv's python.exe is
# a copy, but python314.dll and the stdlib still come from here.
$script:PyRoot = $pyTarget
if (Test-Path (Join-Path $pyTarget 'python.exe')) {
Write-Log "Python already present at $pyTarget" 'OK'
# NOT hardened: something else installed it and may depend on the ACL it
# has. Say so instead - the app would otherwise run on an interpreter
# any logged-on user can replace.
if (Test-UserWritableRoot $pyTarget) {
Write-Log "$pyTarget is writable by non-administrators; this installer did not create it, so it is left alone. Harden it or reinstall Python under an ACL only administrators can write." 'WARN'
}
} elseif ($null -eq $pyInstaller) {
Fail "no Python installer in $BundleRoot\python"
} else {
@@ -1164,6 +1225,9 @@ function Invoke-Stage2 {
'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget"
) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641
Track 'python' $pyTarget
# Ours, so ours to lock down. InstallAllUsers=1 puts it at C:\Python314
# with the drive root's inherited Authenticated Users Modify.
Protect-InstallRoot $pyTarget
}
}
$sysPy = Join-Path $pyTarget 'python.exe'
@@ -1174,6 +1238,7 @@ function Invoke-Stage2 {
New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null
Track 'dir' $AppRoot
}
Protect-InstallRoot $AppRoot
# --- upgrade detection ---------------------------------------------------
$script:BundleVersion = Get-BundleVersion
$script:InstalledVersion = Get-InstalledVersion
@@ -2022,6 +2087,13 @@ has to come from the bundle either way.
$ident = "IIS AppPool\$AppPool"
Write-Log "granting $ident access"
Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot'
# The Python root no longer inherits from the drive root, so the pool has
# no access to it by default. The venv's python.exe is a copy; the DLL
# and the standard library are still read from the base install, so
# without this grant the site 500s on every request.
if ($script:PyRoot -and (Test-Path $script:PyRoot)) {
Invoke-Native 'icacls.exe' @($script:PyRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on Python root'
}
foreach ($sub in @('logs','instance')) {
Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub"
}

202
frontend/src/utils/codes.js Normal file
View File

@@ -0,0 +1,202 @@
// One place that knows how to draw a barcode or a QR code for a label.
//
// Seven views were generating codes - three in core, four in plugins - each
// importing qrcode/jsbarcode directly and each carrying its own answer to the
// same questions: what margin, what width, which error correction, how big a
// module has to be before a scanner can read it. The answers had already
// drifted (margin 0 in one place and 2 in another, width 150 against 160), and
// none of the label pages knew the printer-resolution arithmetic the Tech Tools
// generator worked out.
//
// So the knowledge lives here and the views pass what they need. Nothing about
// a specific label belongs in this file; nothing about how to render a code
// belongs anywhere else.
//
// WHY SVG, MOSTLY. A code renders to an SVG data URI rather than a canvas or a
// PNG for two reasons found the hard way on the label pages: an <img> prints
// reliably where a live canvas or inline SVG does not, and SVG rasterises at
// the printer's resolution with hard module edges. A PNG gets downscaled to the
// label size and smears exactly the edges a scanner reads. The one exception is
// a QR with a logo composited into it, which needs a canvas to composite on.
import QRCode from 'qrcode'
import JsBarcode from 'jsbarcode'
import { getQrLogo } from '@/utils/siteSettings'
// Label stock actually loaded in the printers, with the code size, padding and
// quiet zone that fit each one. Sizes are inches, because that is what the
// stock is sold as and what @page takes.
export const LABEL_PRESETS = [
{ id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 },
{ id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 },
{ id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 },
{ id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 },
{ id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 },
]
// CODE128 is what the scanners on the floor are configured for, and a label
// carries its text separately, so the barcode never renders its own.
export const BARCODE_DEFAULTS = {
format: 'CODE128',
displayValue: false,
margin: 0,
width: 2,
height: 100,
}
export function svgDataUri(svg) {
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
}
/**
* Modules across a QR for this content and error correction, quiet zone
* excluded. 0 when the content cannot be encoded at all.
*/
export function qrModuleCount(text, errorCorrection = 'M') {
if (!text) return 0
try {
return QRCode.create(text, { errorCorrectionLevel: errorCorrection }).modules.size
} catch {
return 0
}
}
/**
* Whether a QR of `sizeInches` can actually be printed at `dpi`.
*
* A scanner reads modules, not inches. Below about two printer dots per module
* the edges blur into each other and the code stops scanning reliably - and a
* fractional dots-per-module means some modules are a dot wider than others,
* which is the difference between a label that always works and one that works
* on most printers. `snapped` is the nearest size that divides evenly.
*/
export function qrFit(text, { sizeInches, dpi = 203, errorCorrection = 'M' } = {}) {
const modules = qrModuleCount(text, errorCorrection)
if (!modules || !sizeInches) return null
const dotsPerModule = (sizeInches * dpi) / modules
const whole = Math.floor(dotsPerModule)
return {
modules,
dotsPerModule,
wholeDots: whole,
snapped: whole > 0 ? (modules * whole) / dpi : 0,
moduleMm: whole > 0 ? (whole / dpi) * 25.4 : 0,
// Below two dots per module the code is not reliably scannable, whatever
// it looks like on screen.
tooSmall: whole < 2,
even: whole > 0 && Math.abs((modules * whole) / dpi - sizeInches) < 0.002,
}
}
/**
* A QR as an SVG data URI.
*
* margin 0 by default: on a label the quiet zone is blank label supplied by the
* layout, so spending the code box on white the page cannot then adjust makes
* the printed code smaller for no benefit. A caller rendering to a screen, or
* onto something with no controlled surround, should pass a margin.
*/
export async function qrSvgDataUri(text, { errorCorrection = 'M', margin = 0 } = {}) {
const svg = await QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: errorCorrection,
margin,
})
return svgDataUri(svg)
}
/**
* A QR as a PNG data URL. Use only where a raster is genuinely needed - a
* composited logo, or an existing page built around <img src=png>.
*/
export async function qrPngDataUrl(text, { width = 160, margin = 0, errorCorrection = 'M' } = {}) {
return QRCode.toDataURL(text, { width, margin, errorCorrectionLevel: errorCorrection })
}
/**
* Render a barcode into an existing SVG or canvas element.
*
* Kept as a thin pass-through because several pages hold an element ref and
* want JsBarcode to draw straight into it; the value here is the shared
* defaults, not the indirection.
*/
export function barcodeInto(element, text, options = {}) {
JsBarcode(element, text, { ...BARCODE_DEFAULTS, ...options })
return element
}
/** A barcode as an SVG data URI, for pages that print an <img>. */
export function barcodeSvgDataUri(text, options = {}) {
const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
barcodeInto(element, text, options)
return svgDataUri(new XMLSerializer().serializeToString(element))
}
// -- QR with a logo composited in the middle ---------------------------------
//
// Moved here from views/print/qrLogo.js, which core label pages and the printer
// QR pages both reached into. The overlay image is a site setting; the monogram
// below is the last-resort fallback when that setting is empty or unreachable,
// so a label still carries a mark rather than a hole.
const FALLBACK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
let logoImage = null
function loadLogo(url) {
if (logoImage) return Promise.resolve(logoImage)
return new Promise(resolve => {
const img = new Image()
img.onload = () => { logoImage = img; resolve(img) }
img.onerror = () => {
const fallback = new Image()
fallback.onload = () => { logoImage = fallback; resolve(fallback) }
fallback.onerror = () => resolve(null)
fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(FALLBACK_LOGO_SVG)
}
img.src = url
})
}
function drawLogoOverlay(canvas, logo) {
const canvasContext = canvas.getContext('2d')
const size = canvas.width
const logoSize = Math.round(size * 0.22)
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// White circle behind the mark, so it sits on the code rather than in it.
canvasContext.beginPath()
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
canvasContext.fillStyle = '#fff'
canvasContext.fill()
if (logo) {
canvasContext.drawImage(logo, x, y, logoSize, logoSize)
}
}
/**
* A QR with the configured logo composited in the centre, as a PNG data URL.
*
* Error correction H, because up to 30% of the code can be obscured and still
* decode - which is what makes covering the middle of it survivable at all.
* Empty qr_logo setting means no overlay. Returns '' on failure: a label with
* no code is better than a page that throws while printing a batch.
*/
export async function renderQrDataUrl(url, { width = 144, margin = 0 } = {}) {
const qrLogoUrl = await getQrLogo()
const canvas = document.createElement('canvas')
try {
await QRCode.toCanvas(canvas, url, { width, margin, errorCorrectionLevel: 'H' })
if (qrLogoUrl) {
const logo = await loadLogo(qrLogoUrl)
drawLogoOverlay(canvas, logo)
}
return canvas.toDataURL('image/png')
} catch (err) {
console.error('QR error:', err)
return ''
}
}

View File

@@ -0,0 +1,93 @@
// The arithmetic in codes.js decides whether a printed label scans, and it is
// the part no amount of looking at a screen will verify - a QR that is fine at
// 96 dpi on a monitor can be unreadable at 203 dpi on 0.5in stock.
import { describe, it, expect } from 'vitest'
import {
LABEL_PRESETS, BARCODE_DEFAULTS, qrModuleCount, qrFit, svgDataUri,
} from './codes'
describe('qrModuleCount', () => {
it('grows with the content', () => {
const short = qrModuleCount('AP-01')
const long = qrModuleCount('AP-01/'.repeat(20))
expect(short).toBeGreaterThan(0)
expect(long).toBeGreaterThan(short)
})
it('grows with error correction, because the redundancy is stored in modules', () => {
expect(qrModuleCount('WKSTN0042', 'H')).toBeGreaterThanOrEqual(
qrModuleCount('WKSTN0042', 'L'))
})
it('returns 0 rather than throwing on empty content', () => {
expect(qrModuleCount('')).toBe(0)
expect(qrModuleCount(null)).toBe(0)
})
})
describe('qrFit', () => {
it('calls a code too small when a module gets under two printer dots', () => {
// A long payload squeezed onto the 1.00 x 0.50in stock: the case the Tech
// Tools generator exists to warn about before 300 labels are printed.
const fit = qrFit('https://shopdb.example.net/assets/1234?from=label&sig=abcdef123456', {
sizeInches: 0.4286, dpi: 203, errorCorrection: 'H',
})
expect(fit.tooSmall).toBe(true)
expect(fit.dotsPerModule).toBeLessThan(2)
})
it('reports a whole number of dots per module as even', () => {
const modules = qrModuleCount('WKSTN0042', 'M')
// Choose the size that divides exactly at 4 dots per module.
const fit = qrFit('WKSTN0042', {
sizeInches: (modules * 4) / 203, dpi: 203, errorCorrection: 'M',
})
expect(fit.wholeDots).toBe(4)
expect(fit.even).toBe(true)
expect(fit.snapped).toBeCloseTo(fit.snapped, 5)
})
it('offers the nearest evenly-dividing size when the chosen one is uneven', () => {
const fit = qrFit('WKSTN0042', { sizeInches: 0.9, dpi: 203, errorCorrection: 'M' })
expect(fit.even).toBe(false)
expect(fit.snapped).toBeLessThanOrEqual(0.9)
expect(fit.snapped * 203 / fit.modules).toBeCloseTo(fit.wholeDots, 6)
})
it('is null when there is nothing to measure', () => {
expect(qrFit('', { sizeInches: 1 })).toBeNull()
expect(qrFit('WKSTN0042', {})).toBeNull()
})
})
describe('shared defaults', () => {
it('barcodes are CODE128 with no margin and no printed text', () => {
// The label draws its own caption, and the quiet zone is blank label. Both
// had drifted per-view before this module existed.
expect(BARCODE_DEFAULTS.format).toBe('CODE128')
expect(BARCODE_DEFAULTS.margin).toBe(0)
expect(BARCODE_DEFAULTS.displayValue).toBe(false)
})
it('every label preset is a complete, usable stock definition', () => {
expect(LABEL_PRESETS.length).toBeGreaterThan(0)
for (const preset of LABEL_PRESETS) {
expect(preset.id).toBeTruthy()
expect(preset.labelwidth).toBeGreaterThan(0)
expect(preset.labelheight).toBeGreaterThan(0)
// A code has to fit inside its label with its quiet zone on both sides.
expect(preset.codesize + 2 * preset.quiet).toBeLessThanOrEqual(preset.labelwidth)
}
})
})
describe('svgDataUri', () => {
it('escapes markup so the URI survives being put in a src attribute', () => {
const uri = svgDataUri('<svg><rect fill="#000"/></svg>')
expect(uri.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true)
expect(uri).not.toContain('<')
expect(uri).not.toContain('"')
})
})

View File

@@ -119,7 +119,7 @@ export async function getServicenowUrls() {
// Printer hostname template. {ip} is replaced with the dash-separated IP.
export async function getPrinterHostnameTemplate() {
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net')
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
}
// Email domain appended to a contact SSO to build email/Teams links

View File

@@ -19,7 +19,7 @@
<p class="hint">Used for the dashboard header and QR / absolute links.</p>
<div class="form-group">
<label>Facility name</label>
<input v-model="form.facility_name" type="text" class="form-control" placeholder="West Jefferson" />
<input v-model="form.facility_name" type="text" class="form-control" placeholder="Main Plant" />
</div>
<div class="form-group">
<label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label>

View File

@@ -128,8 +128,7 @@
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import JsBarcode from 'jsbarcode'
import { renderQrDataUrl } from './qrLogo'
import { renderQrDataUrl, barcodeInto } from '@/utils/codes'
import { getSetting } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath'
import {
@@ -208,9 +207,7 @@ async function renderCode() {
await nextTick()
if (!barcodeEl.value) return
try {
JsBarcode(barcodeEl.value, text, {
format: 'CODE128', displayValue: false, width: 2, height: 70, margin: 0,
})
barcodeInto(barcodeEl.value, text, { height: 70 })
} catch (err) {
console.error('Barcode error:', err)
}

View File

@@ -140,8 +140,7 @@
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import JsBarcode from 'jsbarcode'
import { renderQrDataUrl } from './qrLogo'
import { renderQrDataUrl, barcodeInto } from '@/utils/codes'
import { getSetting } from '@/utils/siteSettings'
import {
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
@@ -273,10 +272,7 @@ function renderBarcodes() {
const text = codeMap.value[asset.assetid]
if (!el || !text) continue
try {
JsBarcode(el, text, {
format: 'CODE128', displayValue: false, width, height, margin: 0,
background: 'transparent',
})
barcodeInto(el, text, { width, height, background: 'transparent' })
} catch (err) {
console.error('Barcode error:', asset.assetnumber, err)
}

View File

@@ -31,7 +31,7 @@ import { useRoute } from 'vue-router'
import { machinesApi } from '../../api'
import { getBadgeLogo } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath'
import JsBarcode from 'jsbarcode'
import { barcodeInto } from '@/utils/codes'
const route = useRoute()
const loading = ref(true)
@@ -75,12 +75,10 @@ onMounted(async () => {
function generateBarcode() {
if (!barcodeEl.value || !machine.value) return
try {
JsBarcode(barcodeEl.value, machine.value.assetnumber, {
format: 'CODE39',
displayValue: false,
width: 2,
height: 70,
margin: 0
// CODE39, not the CODE128 default: the badge readers predate the
// shop-floor scanners and only decode CODE39.
barcodeInto(barcodeEl.value, machine.value.assetnumber, {
format: 'CODE39', height: 70,
})
} catch (e) {
console.error('Barcode generation error:', e)

View File

@@ -1,62 +0,0 @@
// Shared GE monogram + QR-with-logo rendering for the printer QR label pages.
// Both PrinterQRBatch and PrinterQRSingle render each QR to a data-URL image
// (canvases print unreliably) with the GE monogram composited in the center.
import QRCode from 'qrcode'
import { getQrLogo } from '@/utils/siteSettings'
const GE_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
let logoImage = null
// Load the configured QR overlay image. On any load failure, fall back to the
// built-in GE monogram (last-resort constant above).
function loadLogo(url) {
if (logoImage) return Promise.resolve(logoImage)
return new Promise(resolve => {
const img = new Image()
img.onload = () => { logoImage = img; resolve(img) }
img.onerror = () => {
const fallback = new Image()
fallback.onload = () => { logoImage = fallback; resolve(fallback) }
fallback.onerror = () => resolve(null)
fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG)
}
img.src = url
})
}
function drawLogoOverlay(canvas, logo) {
const canvasContext = canvas.getContext('2d')
const size = canvas.width
const logoSize = Math.round(size * 0.22)
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// White circle background behind the monogram
canvasContext.beginPath()
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
canvasContext.fillStyle = '#fff'
canvasContext.fill()
if (logo) {
canvasContext.drawImage(logo, x, y, logoSize, logoSize)
}
}
// Render a QR for `url` to a PNG data URL with the configured logo composited
// in the center. Empty qr_logo setting = no overlay. Returns '' on failure.
export async function renderQrDataUrl(url) {
const qrLogoUrl = await getQrLogo()
const canvas = document.createElement('canvas')
try {
await QRCode.toCanvas(canvas, url, { width: 144, margin: 0, errorCorrectionLevel: 'H' })
if (qrLogoUrl) {
const logo = await loadLogo(qrLogoUrl)
drawLogoOverlay(canvas, logo)
}
return canvas.toDataURL('image/png')
} catch (err) {
console.error('QR error:', err)
return ''
}
}

View File

@@ -143,7 +143,7 @@
<input
type="text"
v-model="settings.site_base_url"
placeholder="https://tsgwp00525.wjs.geaerospace.net/ops"
placeholder="https://shopdb.example.net/ops"
@blur="saveSetting('site_base_url', settings.site_base_url)"
:disabled="saving"
>

View File

@@ -113,7 +113,7 @@ const HELP = {
usb_directory_mode: 'Where USB checkout data lives: selfhosted (tables in this app) or external (a separate cmmc_usb database via CMMC_USB_DB_* environment variables).',
setup_complete: 'Set automatically when the first-run setup wizard finishes. Turning it off sends admins back to the /setup wizard on next login.',
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net',
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.',
site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.'

View File

@@ -40,7 +40,7 @@ def _sitezone():
Same lookup the notifications plugin uses. A stored timestamp is UTC, so
anything rendered server-side has to be converted or it shows the wrong
wall clock for the site - four hours out at West Jefferson.
wall clock for the site - four hours out at the reference site.
"""
from shopdb.api import Setting
row = Setting.query.filter_by(key='site_timezone').first()

View File

@@ -30,7 +30,34 @@ def upgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' in columns:
op.execute('UPDATE backuprevisions SET lastseenat = NULL')
# SCOPED TO THE BACKFILL, not to every row. An unscoped UPDATE was
# correct exactly once; re-running it destroys evidence, and this chain
# HAS been re-running on MySQL (see the missing commit in
# shopdb/plugins/alembic_template.py). A migration that deletes data has
# to assume it will run twice.
#
# TWO CONDITIONS, and both are needed:
#
# 1. The backfill's actual signature is COALESCE(collectedat, createdat)
# - that is the expression 0002 wrote. Matching `lastseenat =
# collectedat` alone misses every row whose collectedat is NULL,
# because NULL comparison is never true, so exactly the rows carrying
# the most invented value would have kept it forever.
#
# 2. A date bound, because value equality on its own is not a signature
# on MySQL. db.DateTime is second-precision there (SQLite keeps
# microseconds, which is why no test could show this), and the
# collector writes collectedat and lastseenat in one statement - so a
# genuinely fresh revision that arrived without a payload collectedat
# has the two stamps equal to the second and would be read as a
# backfill and wiped.
#
# The cutoff is the day this correction ships. Everything the backfill
# touched predates it by construction; anything created after it is a
# live collection this migration must never touch.
op.execute("UPDATE backuprevisions SET lastseenat = NULL "
"WHERE lastseenat = COALESCE(collectedat, createdat) "
"AND createdat < '2026-08-14 00:00:00'")
def downgrade():

View File

@@ -20,6 +20,7 @@ from typing import Dict, List, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import Setting
from .api import backups_bp
from .models import BackupRevision
@@ -165,7 +166,10 @@ class BackupsPlugin(BasePlugin):
'valuetype': 'string',
'category': 'backups',
'description': 'UNC root that opaque (non-JSON) backups are '
'written under by the collecting PC. Site-specific.',
'written under by the collecting PC, e.g. '
'\\\\fileserver\\shopfloor\\backups. Blank until '
'the site sets it: no default can be right at '
'more than one site (ADR-015).',
},
{
'key': 'backups_staledays',
@@ -395,8 +399,14 @@ class BackupsPlugin(BasePlugin):
pruned = prune(
assetid, kindkey,
retentioncount=self.get_setting('backups_retentioncount', 0),
retentiondays=self.get_setting('backups_retentiondays', 0),
# Setting.get, NOT self.get_setting. The BasePlugin helper
# namespaces what it reads to `plugin.backups.<key>`, while
# get_settings_defaults declares - and the settings page writes -
# the bare key. So this read never found the value an operator had
# set, always fell back to 0, and 0 means "keep everything": the
# retention policy was configurable in the UI and did nothing.
retentioncount=Setting.get('backups_retentioncount', 0),
retentiondays=Setting.get('backups_retentiondays', 0),
)
if pruned:
warnings.append('pruned {} old revision(s) per retention policy'

View File

@@ -19,14 +19,17 @@ import json
from . import ntlars as ntlarscodec
# Default root of the opaque-backup tree on the SFLD share. Connected PCs write
# here directly (they need SFLD creds - a SYSTEM process hitting a UNC path
# Root of the opaque-backup tree on the site's file share. Connected PCs write
# here directly (they need share creds - a SYSTEM process hitting a UNC path
# without them gets an access-denied that Test-Path reports as "not found").
#
# This is the WEST JEFFERSON path and is only a DEFAULT: the live value is the
# backups_shareroot setting, because a bundled plugin in a multi-site product
# must not hardcode one site's file server.
DEFAULTSHAREROOT = r'\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backups'
# EMPTY BY DESIGN (ADR-015). This used to ship one site's own file server
# as the default, which put one site's internal topology in a bundled plugin and
# in the public mirror, and silently pointed a second site at a server it cannot
# reach. The live value is the backups_shareroot setting; unset, the share kinds
# have no conventional location to offer and say so rather than composing a path
# from somebody else's hostname.
DEFAULTSHAREROOT = ''
def canonicalhash(projection):
@@ -150,10 +153,16 @@ class BackupKind:
Advisory only - the authoritative path is the sharepath the collector
reports, since the PC is what actually wrote the file. This builds the
conventional location for display and for validating a reported path.
Returns '' when no share root is configured. A path built on an empty
root is not a lesser answer, it is a wrong one - it would render as a
relative path and validate a reported path against nothing.
"""
root = shareroot or DEFAULTSHAREROOT
if not root:
return ''
return '{}\\{}\\{}\\{}'.format(
shareroot or DEFAULTSHAREROOT,
machinetype or 'unknown', identifier or 'unknown', self.key)
root, machinetype or 'unknown', identifier or 'unknown', self.key)
class NtlarsKind(BackupKind):

View File

@@ -316,8 +316,8 @@ def list_display_kiosks():
ctype = ComputerType.query.filter_by(computertype=display_type_name).first()
if not ctype:
return success_response([])
domain = (Setting.get('display_fqdn_domain', 'device.geaerospace.net')
or 'device.geaerospace.net').strip().strip('.')
domain = (Setting.get('display_fqdn_domain', 'device.geaerospace.net') # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
or 'device.geaerospace.net').strip().strip('.') # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
rows = (db.session.query(Computer).join(Asset)
.filter(Computer.computertypeid == ctype.computertypeid,
Asset.isactive == True)

View File

@@ -106,6 +106,13 @@ class ComputersPlugin(BasePlugin):
'fields': {
'hostname': {'type': 'string', 'required': True},
'machinenumber': {'type': 'string'},
# The instrument this PC drives, named by the enrollment file
# measuringtool-id.txt. SEPARATE from machinenumber on purpose:
# "which bay is this" and "which instrument is this" are
# different facts, and machinenumber is what GE-Enforce
# TargetMachineNumbers gates on - repointing it at a tool would
# silently stop every bay-gated manifest entry matching.
'measuringtoolid': {'type': 'string'},
'pctype': {'type': 'string'},
'pcsubtype': {'type': 'string'},
'serialnumber': {'type': 'string'},
@@ -187,7 +194,7 @@ class ComputersPlugin(BasePlugin):
"""Whether the PC-to-machine alerts may be sent.
Off by default, deliberately. A site may legitimately run several PCs
on one machine number - part markers do at West Jefferson - and there
on one machine number - part markers do at the reference site - and there
both the handover and the contested case fire on normal, correct data,
which is noise rather than news. Turn it on at a site where a machine
number means exactly one PC.
@@ -370,7 +377,9 @@ class ComputersPlugin(BasePlugin):
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
# get an attached MeasuringTool asset auto-created and linked.
measuringtoollinks = self._sync_measuringtool_link(
comp.asset, pctype, hostname, warnings)
comp.asset, pctype, hostname, warnings,
measuringtoolid=payload.get('measuringtoolid'),
machinenumber=machinenumber)
db.session.commit()
return {
@@ -619,9 +628,7 @@ class ComputersPlugin(BasePlugin):
'run flask seed reference-data')
return []
machine = Asset.query.filter(
Asset.assetnumber.ilike(machinenumber),
Asset.isactive.is_(True)).first()
machine = self._asset_by_number(machinenumber)
if not machine:
# Reported a machine ShopDB does not know. Warn rather than invent
# an asset: a mistyped number would create a machine nobody can
@@ -791,22 +798,48 @@ class ComputersPlugin(BasePlugin):
db.session.flush()
hostname = comp.hostname
markerasset = Asset(
assetnumber='{}-{}'.format(
pcasset.assetnumber or hostname, spec['suffix']),
name='{} ({})'.format(spec['typename'], hostname),
assettypeid=coretype.assettypeid,
statusid=1)
db.session.add(markerasset)
db.session.flush()
db.session.add(Machine(assetid=markerasset.assetid,
machinetypeid=devicetype.machinetypeid))
db.session.add(AssetRelationship(
assetnumber = '{}-{}'.format(
pcasset.assetnumber or hostname, spec['suffix'])
# ADOPT AN EXISTING ASSET OF THAT NUMBER before minting. The number
# is derived from the PC, so it is entirely predictable and may
# already exist - left by an earlier run whose link was archived, or
# created by a person. assetnumber is unique, so inserting a second
# one raised and the whole report 500'd, on every cycle, forever.
#
# Deliberately NOT filtered on isactive: an inactive asset still
# holds the number, and it is the constraint that decides this.
markerasset = Asset.query.filter(
db.func.lower(Asset.assetnumber) == assetnumber.lower()).first()
if markerasset is None:
markerasset = Asset(
assetnumber=assetnumber,
name='{} ({})'.format(spec['typename'], hostname),
assettypeid=coretype.assettypeid,
statusid=1)
db.session.add(markerasset)
db.session.flush()
# The extension row may be missing on an adopted asset, and the link
# may already exist under another label - both get-or-create.
if not Machine.query.filter_by(assetid=markerasset.assetid).first():
db.session.add(Machine(assetid=markerasset.assetid,
machinetypeid=devicetype.machinetypeid))
controlrow = AssetRelationship.query.filter_by(
sourceassetid=pcasset.assetid,
targetassetid=markerasset.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=label,
isactive=True))
relationshiptypeid=controls.relationshiptypeid).first()
if controlrow is not None:
controlrow.isactive = True
if not controlrow.label:
controlrow.label = label
else:
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=markerasset.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=label,
isactive=True))
# One marker per PC: archive any other collector marker link.
for rel in existing:
@@ -847,9 +880,7 @@ class ComputersPlugin(BasePlugin):
'run flask seed reference-data')
return None
operation = Asset.query.filter(
Asset.assetnumber.ilike(machinenumber),
Asset.isactive.is_(True)).first()
operation = self._asset_by_number(machinenumber)
if not operation:
warnings.append(
'no asset for machine number {!r}; marker not filed under an '
@@ -865,19 +896,34 @@ class ComputersPlugin(BasePlugin):
machinenumber))
return None
links = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == markerasset.assetid,
AssetRelationship.relationshiptypeid == partof.relationshiptypeid,
AssetRelationship.label == label,
).all()
found = None
for rel in links:
if rel.targetassetid == operation.assetid:
rel.isactive = True
found = rel
else:
# Our own rows first: a marker that moved to another operation has its
# old membership archived, never deleted, so where it used to live stays
# answerable.
for rel in AssetRelationship.query.filter(
AssetRelationship.sourceassetid == markerasset.assetid,
AssetRelationship.relationshiptypeid == partof.relationshiptypeid,
AssetRelationship.label == label).all():
if rel.targetassetid != operation.assetid and rel.isactive:
rel.isactive = False
if found is None:
# Then get-or-create KEYED ON THE UNIQUE TRIPLE, not on the label.
# assetrelationships is unique on (source, target, type), so a row made
# by hand or by the legacy import - carrying a different label, or none
# at all - was invisible to the label-filtered lookup this used to do,
# and the insert then violated that constraint. The PC reported 200 on
# the cycle that minted the marker and 500 on every cycle after.
found = AssetRelationship.query.filter_by(
sourceassetid=markerasset.assetid,
targetassetid=operation.assetid,
relationshiptypeid=partof.relationshiptypeid).first()
if found is not None:
found.isactive = True
if not found.label:
# Unlabelled means the import or a person made it. Stamping ours
# is what makes the next cycle reuse it. A row carrying somebody
# else's label is left as theirs.
found.label = label
else:
db.session.add(AssetRelationship(
sourceassetid=markerasset.assetid,
targetassetid=operation.assetid,
@@ -1022,18 +1068,39 @@ class ComputersPlugin(BasePlugin):
'PC-superseded alert failed for machine %s',
getattr(machine, 'assetnumber', '?'))
def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings):
"""Auto-create + link the MeasuringTool a metrology PC drives.
def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings,
measuringtoolid=None, machinenumber=None):
"""Link the MeasuringTool a metrology PC drives. ADOPT before minting.
A CMM / Keyence / Genspect / wax-and-trace imaging pc-type means the
shopfloor PC controls an attached measuring instrument. This creates
that instrument once as a MeasuringTool asset and a directional
PC->tool 'controls' relationship, tagged MEASURINGTOOL_LINK_ORIGIN so
it is idempotent and self-archiving. The PC's own ComputerType is left
alone (it stays a shopfloor PC). A non-metrology pc-type archives any
collector-created tool link (e.g. a PC re-imaged to another type) but
never deletes the tool asset, which may carry calibration history.
Returns the desired-link list.
A CMM / Keyence / Genspect / wax-and-trace pc-type means the PC controls
an attached instrument. This links it with a directional PC->tool
'controls' relationship tagged MEASURINGTOOL_LINK_ORIGIN.
RESOLUTION ORDER, most stable identity first:
1. measuringtoolid - the instrument named by the enrollment file
measuringtool-id.txt. Explicit, survives a PC swap, and it is what
a Keyence or Genspect bay has instead of a bay id.
2. a prior collector link on this PC (reactivate + retype).
3. an EXISTING measuring tool this PC already controls that the
collector did not create - a legacy MT-#### row, or one somebody
made by hand. Adopting stamps the label so it is ours from then on.
4. the reported machine number, when it resolves to a measuring tool.
This is the CMM case: cmmid.txt already reports CMM4.
5. mint, only when none of the above found anything.
WHY THIS ORDER. Minting derives an asset number from the HOSTNAME, so a
permanent instrument inherited the identity of whichever PC drove it that
week - replace the PC and either the number lies or a second tool appears
for the same physical unit. It also could not see a tool it had not
created itself, so on prod 43 legacy MT-#### tools were shadowed by
minted <HOST>-CMM twins, three records deep in places. Minting is now the
last resort, and what it produces should be treated as a placeholder
until a real identifier is recorded.
A non-metrology pc-type archives any collector-created tool link (a PC
re-imaged to another type) but never deletes the tool asset, which may
carry calibration history. Returns the desired-link list.
"""
from shopdb.api import AssetRelationship, RelationshipType, Asset
from .pctypemap import metrology_tool_for
@@ -1073,11 +1140,75 @@ class ComputersPlugin(BasePlugin):
typename, typedescription = tool_spec
tooltype = self._ensure_measuringtool_type(typename, typedescription)
# Reuse any prior collector link (reactivate + retype) before creating,
# so a re-metrology PC never duplicates the tool asset.
# --- 1. an explicitly named instrument wins over everything ---------
adopted = None
named = (measuringtoolid or '').strip()
if named:
candidate = self._asset_by_number(named)
if candidate is None:
# Warn rather than invent: a typo in the file must not mint a
# phantom instrument that nobody can account for.
warnings.append(
'no asset for measuring tool {!r}; not linked'.format(named))
elif candidate.assetid not in self._measuringtool_assetids():
# The name resolved, but not to an instrument. measuringtool-id
# .txt holding a machine number would otherwise link the PC to
# that MACHINE under a measuring-tool label - a link that reads
# as an instrument everywhere downstream, on a row the machine
# sync also owns. Refuse and say which asset it hit.
warnings.append(
'asset {!r} is not a measuring tool; not linked'.format(
candidate.assetnumber))
else:
adopted = candidate
# --- 2. a prior collector link (reactivate + retype) -----------------
reuse = next((rel for rel in existing if rel.isactive), None) \
or (existing[0] if existing else None)
if reuse:
# --- 3/4. adopt a tool this PC already controls that we did not make --
# Ordered after the collector's own link so a settled PC keeps what it
# has, and before minting so an existing instrument is never twinned.
if adopted is None and reuse is None:
adopted = self._adoptable_measuringtool(
pcasset, controls, machinenumber)
if adopted is not None:
# CLAIM the existing link rather than adding a second one. A row for
# (pc, tool, controls) usually already exists - that is how the tool
# was found - and assetrelationships is unique on exactly that
# triple, so inserting would raise.
claimed = AssetRelationship.query.filter_by(
sourceassetid=pcasset.assetid,
targetassetid=adopted.assetid,
relationshiptypeid=controls.relationshiptypeid).first()
if claimed is not None and claimed.label == MACHINE_LINK_ORIGIN:
# LEAVE IT ALONE. On a CMM the instrument IS the reported bay,
# so the machine sync has already made the row for this exact
# triple - and it finds its own rows BY that label. Relabelling
# hid the link from it, so the next cycle built a second row for
# the same triple and MySQL rejected it: the PC reported 200,
# then 500 forever after. The row's lifecycle (including staying
# dormant while a bay is contested) belongs to the machine sync;
# adoption only needs the identity, which it has.
pass
elif claimed is not None:
# Relabelling is what adoption MEANS for any other row: the link
# is ours from here, so a later cycle reuses it instead of
# minting.
claimed.label = MEASURINGTOOL_LINK_ORIGIN
claimed.isactive = True
else:
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=adopted.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=MEASURINGTOOL_LINK_ORIGIN))
if adopted.measuringtool and tooltype:
adopted.measuringtool.measuringtooltypeid = \
tooltype.measuringtooltypeid
targetid = adopted.assetid
elif reuse:
reuse.isactive = True
toolasset = db.session.get(Asset, reuse.targetassetid)
if toolasset and toolasset.measuringtool and tooltype:
@@ -1111,14 +1242,96 @@ class ComputersPlugin(BasePlugin):
label=MEASURINGTOOL_LINK_ORIGIN))
targetid = toolasset.assetid
# Only one tool link is desired; archive any other collector rows.
# Only one tool link is desired; archive any other collector row.
#
# Keyed on the TARGET, not on object identity. `rel is not reuse` spared
# the row reuse pointed at even when adoption had just resolved a
# different instrument - so a bay whose measuringtool-id.txt named the
# real tool kept its minted twin link alive alongside the adopted one,
# and the PC read as controlling two instruments. That is the exact
# duplicate this whole adoption path exists to remove.
for rel in existing:
if rel is not reuse and rel.isactive:
if rel.targetassetid != targetid and rel.isactive:
rel.isactive = False
return [{'assetid': targetid, 'relationshiptype': 'controls',
'measuringtooltype': typename}]
@staticmethod
def _asset_by_number(assetnumber):
"""The active asset with exactly this asset number, case-insensitively.
NOT `ilike`. Every identifier resolved here arrives from a file on a
shopfloor PC - cmmid.txt, measuringtool-id.txt, the reported machine
number - and ilike reads `_` and `%` in that value as WILDCARDS. A
reported '%' matched whatever active asset happened to come first, and
'MT-600_' matched MT-6001: an identifier silently resolving to an asset
it does not name, then linked to as though it did.
"""
from shopdb.api import Asset
number = (assetnumber or '').strip()
if not number:
return None
return Asset.query.filter(
db.func.lower(Asset.assetnumber) == number.lower(),
Asset.isactive.is_(True)).first()
def _measuringtool_assetids(self):
"""Asset ids that really are measuring tools.
Empty when the measuringtools plugin is not installed, which makes both
callers fall through rather than guess: an asset number alone does not
say what kind of thing it names.
"""
try:
from plugins.measuringtools.models import MeasuringTool
except ImportError:
return set()
return {row.assetid for row in
MeasuringTool.query.with_entities(MeasuringTool.assetid)}
def _adoptable_measuringtool(self, pcasset, controls, machinenumber):
"""A measuring tool this PC already controls that we did not create.
Two sources, in order:
a. any ACTIVE controls link from this PC to a measuring_tool asset
that carries a different label - the legacy import's MT-#### rows,
or one somebody added by hand. These were invisible to the
collector, which keyed idempotency on its OWN label and so minted a
twin for the same physical instrument on every fresh PC.
b. the reported machine number, when it names a measuring tool. That
is the CMM case: cmmid.txt reports CMM4, which already resolves to
a real asset through the machine-link path.
Returns the Asset to adopt, or None. Never creates anything.
"""
from shopdb.api import AssetRelationship, Asset
toolassetids = self._measuringtool_assetids()
if not toolassetids:
return None
# a. already linked, by someone else
for rel in (AssetRelationship.query
.filter(AssetRelationship.sourceassetid == pcasset.assetid,
AssetRelationship.relationshiptypeid
== controls.relationshiptypeid,
AssetRelationship.isactive.is_(True))
.order_by(AssetRelationship.relationshipid.asc()).all()):
if rel.label == MEASURINGTOOL_LINK_ORIGIN:
continue
if rel.targetassetid in toolassetids:
return db.session.get(Asset, rel.targetassetid)
# b. the reported machine number naming a tool
number = (machinenumber or '').strip()
if number:
candidate = self._asset_by_number(number)
if candidate is not None and candidate.assetid in toolassetids:
return candidate
return None
def _ensure_measuringtool_type(self, name, description):
"""Find or create a MeasuringToolType (metrology types are not in the
measuringtools starter seed)."""

View File

@@ -13,7 +13,7 @@ import io
import logging
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
@@ -26,6 +26,7 @@ from shopdb.api import (
require_role,
)
from shopdb.api import Setting
from shopdb.api import send_upload
from ..models import DirectoryEmployee
@@ -612,7 +613,7 @@ def upload_employee_photo(sso):
@employees_bp.route('/photo/<path:filename>', methods=['GET'])
def serve_employee_photo(filename):
"""Serve an uploaded employee photo (public - kiosk cards read it)."""
return send_from_directory(_employeephoto_dir(), filename)
return send_upload(_employeephoto_dir(), filename)
@employees_bp.route('/<int:sso>/photo', methods=['DELETE'])

View File

@@ -352,6 +352,8 @@ def list_scopes():
'scopename': scope.scopename,
'phase': scope.phase,
'manifestversion': scope.manifestversion,
'minlibversion': scope.minlibversion,
'minlibversion': scope.minlibversion,
'computertypeid': scope.computertypeid,
'measuringtooltypeid': scope.measuringtooltypeid,
'iscommon': scope.iscommon,
@@ -462,6 +464,7 @@ def create_scope():
scope = ManifestScope(
scopename=scopename, phase=phase,
manifestversion=str(payload.get('manifestversion', '1.0')),
minlibversion=(payload.get('minlibversion') or None),
description=payload.get('description'),
computertypeid=payload.get('computertypeid'),
measuringtooltypeid=payload.get('measuringtooltypeid'),
@@ -496,6 +499,10 @@ def update_scope(scopeid):
setattr(scope, field, payload[field])
if 'manifestversion' in payload:
scope.manifestversion = str(payload['manifestversion'])
if 'minlibversion' in payload:
# Blank clears it, which returns the scope to a derived floor rather
# than pinning it at the empty string.
scope.minlibversion = (payload['minlibversion'] or None)
if 'iscommon' in payload:
scope.iscommon = bool(payload['iscommon'])
db.session.commit()
@@ -764,9 +771,9 @@ def publish_preflight(scopeid):
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
hosts, floor = service.hosts_below_libversion(
scope.scopename, scope.phase, scope.manifestversion)
scope.scopename, scope.phase, service.required_libversion(scope))
return success_response({
'required': scope.manifestversion,
'required': service.required_libversion(scope),
'floor': floor,
'hostsbehind': hosts,
'canpublish': not hosts,

View File

@@ -65,8 +65,8 @@
.EXAMPLE
.\Install-GEEnforce.ps1 -PCType gea-shopfloor-cmm -MachineNumber 0615 `
-CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" `
-ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx `
-CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "Main Plant" `
-ShopdbUrl https://shopdb.example.net -ShopdbToken shopdb_pat_xxx `
-EngineSource \\server\share\dt\shopfloor\common
#>
[CmdletBinding()]

View File

@@ -20,10 +20,30 @@
# Runs as SYSTEM under GE-Enforce, from an entry gated to the test bays.
# Fail-safe: exits 0 on every path - shadowing must never stop a bay enforcing.
param(
# This site's ShopDB. Optional: a bay that already carries a BaseUrl in the
# registry keeps it, so the fleet needs this only on the first cycle or when
# the address changes. There is deliberately NO default - see below.
[string] $BaseUrl = ''
)
$ErrorActionPreference = 'Continue'
# RE-ENTRANCY GUARD. This entry lives IN the manifest the shadow run enforces,
# so without it the thing recurses without bound: engine runs the manifest ->
# reaches this entry -> we invoke the runner -> the runner runs the engine
# against the SAME manifest -> reaches this entry again. Measured on the test
# bays 2026-08-13: 347 nested cycles in 36 minutes, one every five seconds,
# until the share unmounted. The task-based predecessor never hit this because
# it registered a task instead of invoking the runner.
#
# An environment variable, because it is inherited by every child process and
# therefore covers the nested engine and runner without a file to clean up or a
# stale lock to age out. Set for THIS process tree only.
if ($env:SHOPDB_SHADOW_ACTIVE -eq '1') { exit 0 }
$env:SHOPDB_SHADOW_ACTIVE = '1'
$InstallDir = 'C:\Program Files\GE\Shopfloor'
$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
$LegacyTask = 'ShopDB GE-Enforce (shadow)'
function Write-ShadowLog {
@@ -66,12 +86,30 @@ try {
}
}
# BaseUrl only when it differs, so a hand-set value is not churned.
# THE BAY'S OWN VALUE WINS. This used to hold one site's ShopDB URL as a
# compiled-in constant and write it whenever the registry disagreed. At the
# site it was written for that reads as "heal drift"; anywhere else it reads
# as "overwrite this site's address on every enforce cycle", and the site
# cannot win because the cycle repeats. A second plant could not point its
# own bays at its own server.
#
# So: an explicitly passed -BaseUrl seeds the value, an existing registry
# value is never touched, and with neither there is nothing honest to write.
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
if ((Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl -ne $BaseUrl) {
$current = (Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl
if (-not $current) {
if (-not $BaseUrl) {
Write-ShadowLog "no BaseUrl in $regPath and none passed - set it, or pass -BaseUrl in the manifest entry. Skipping this cycle."
exit 0
}
Set-ItemProperty -Path $regPath -Name BaseUrl -Value $BaseUrl
Write-ShadowLog "BaseUrl set to $BaseUrl"
Write-ShadowLog "BaseUrl seeded as $BaseUrl"
} elseif ($BaseUrl -and $BaseUrl -ne $current) {
# Say it, do not do it. A changed address is a real event, and it should
# be a deliberate one, not a side effect of whatever the manifest last
# carried.
Write-ShadowLog "BaseUrl in registry is $current; -BaseUrl passed $BaseUrl. Keeping the registry value."
}
Write-ShadowLog "shadowing $scope against $shareManifest"
@@ -83,3 +121,8 @@ catch {
Write-ShadowLog "FAILED: $_"
exit 0
}
finally {
# Clear it so the NEXT enforce cycle shadows again. Without this the guard
# would latch for the life of the process tree and shadow would run once.
$env:SHOPDB_SHADOW_ACTIVE = $null
}

View File

@@ -14,7 +14,7 @@
a PC is never left unmanaged because the web app is down.
Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB
BaseUrl e.g. https://shopdb.example.geaerospace.net
BaseUrl e.g. https://shopdb.example.net
ApiToken a geenforce.fetch (+ geenforce.report) managed service token,
provisioned the same way as SFLD creds (Azure DSC).
#>

View File

@@ -15,11 +15,11 @@
class="cidr-input"
rows="5"
spellcheck="false"
placeholder="10.134.48.0/23&#10;10.48.249.0/26"
placeholder="192.0.2.0/24&#10;198.51.100.0/26"
></textarea>
<small class="input-hint">
One CIDR (or a plain IP) per line, or comma-separated. Example:
<code>10.134.48.0/23, 10.48.249.0/26</code>. A caller from any of these
<code>192.0.2.0/24, 198.51.100.0/26</code>. A caller from any of these
networks may pull manifests + payloads and post reports with no token.
</small>

View File

@@ -19,10 +19,28 @@ branch_labels = None
depends_on = None
def _columns(bind):
insp = sa.inspect(bind)
if 'manifestenforcementreports' not in insp.get_table_names():
return None
return {c['name'] for c in insp.get_columns('manifestenforcementreports')}
def upgrade():
# Guarded, like network0003prefix. On a FRESH database the table is built
# from the SQLAlchemy models, which already declare this column, so an
# unconditional add fails with "duplicate column name" - and this chain has
# been re-running its head on MySQL (see the missing commit in
# shopdb/plugins/alembic_template.py), so a redeploy hit the same wall.
columns = _columns(op.get_bind())
if columns is None or 'subtype' in columns:
return
op.add_column('manifestenforcementreports',
sa.Column('subtype', sa.String(length=50), nullable=True))
def downgrade():
columns = _columns(op.get_bind())
if columns is None or 'subtype' not in columns:
return
op.drop_column('manifestenforcementreports', 'subtype')

View File

@@ -0,0 +1,49 @@
"""A scope declares the client library it needs, separately from its own version.
The publish gate exists to refuse a manifest the fleet's GE-Enforce library
cannot read correctly. It was comparing reported library versions against
`manifestversion` - the manifest's own 'Version' field, which for a share
imported manifest happens to share the library's numbering but for a
code-authored scope is whatever the author chose (seed_display_scope writes
'2.0'). Every kiosk runs something newer than that, so the gate passed on the
one scope most in need of it.
`minlibversion` says the thing the gate actually wants to know. NULL means
derive it from what the manifest uses, so existing scopes need no edit.
Revision ID: geenforce0004minlib
Revises: geenforce0003subtype
"""
from alembic import op
import sqlalchemy as sa
revision = 'geenforce0004minlib'
down_revision = 'geenforce0003subtype'
branch_labels = None
depends_on = None
TABLE = 'manifestscopes'
def _columns(bind):
insp = sa.inspect(bind)
if TABLE not in insp.get_table_names():
return None
return {c['name'] for c in insp.get_columns(TABLE)}
def upgrade():
# Guarded like geenforce0003subtype and network0003prefix: on a fresh
# database the table is built from the models, which already declare this.
columns = _columns(op.get_bind())
if columns is None or 'minlibversion' in columns:
return
op.add_column(TABLE, sa.Column('minlibversion', sa.String(length=16),
nullable=True))
def downgrade():
columns = _columns(op.get_bind())
if columns is None or 'minlibversion' not in columns:
return
op.drop_column(TABLE, 'minlibversion')

View File

@@ -49,6 +49,15 @@ class ManifestScope(BaseModel):
computertypeid = db.Column(db.Integer, nullable=True)
measuringtooltypeid = db.Column(db.Integer, nullable=True)
manifestversion = db.Column(db.String(16), nullable=False, default='1.0')
# Lowest GE-Enforce client library that may enforce this scope, e.g. '2.6'.
#
# SEPARATE FROM manifestversion, which is the manifest's own 'Version' field
# and is whatever its author wrote - seed_display_scope picks '2.0'. The
# publish gate used manifestversion as the library floor, so for every
# code-authored scope it compared the fleet against a number that says
# nothing about library features, and passed. NULL means "derive it", see
# service.required_libversion.
minlibversion = db.Column(db.String(16), nullable=True)
description = db.Column(db.String(255), nullable=True)
# The manifest-level '_comment' (documentation), preserved verbatim so
# export-to-share round-trips it. Excluded from behavioral parity.

View File

@@ -84,6 +84,20 @@ def _relaunch_window_targets_comment():
return pairs
def _site_base_url():
"""This site's public base URL from settings, or '' when it is unset.
Wrapped because the script builders are also called with no application
context (the seed tests build the text and check it compiles), and a
settings lookup there raises rather than returning a default.
"""
try:
from shopdb.api import Setting
return (Setting.get('site_base_url', '') or '').strip().rstrip('/')
except Exception:
return ''
def build_dispatcher_script():
"""Return the inline dispatcher PowerShell as text.
@@ -96,6 +110,11 @@ def build_dispatcher_script():
table_lines.append(f" '{display_type}' = '{route}'")
table_body = ';\n'.join(table_lines)
# Baked in at seed time because the emitted script runs on a kiosk with no
# route back to the database. Blank when the site has not set it, and the
# dispatcher refuses rather than guessing - see the comment on $KioskBaseUrl.
kiosk_base_url = _site_base_url()
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
#
# Reads C:\\Enrollment\\display-type.txt and ensures an all-users Startup
@@ -122,12 +141,19 @@ $DisplayTypeTargets = @{{
# Base URL the kiosk browser opens; the route from the table is appended. Read
# from HKLM (the value the GE-Enforce client is configured with) so it stays
# site-agnostic; falls back to the West Jefferson shopdb host.
$KioskBaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
# site-agnostic. The fallback is THIS site's site_base_url setting, baked in
# when the scope was seeded - it used to be one particular plant's host, which
# meant a kiosk anywhere else with no registry value quietly opened a server it
# has no business reaching.
$KioskBaseUrl = '{kiosk_base_url}'
try {{
$shopdbConfig = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name BaseUrl -ErrorAction Stop
if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }}
}} catch {{}}
if (-not $KioskBaseUrl) {{
Write-Host 'no ShopDB base URL: set HKLM:\\SOFTWARE\\GE\\ShopDB BaseUrl, or set site_base_url in Settings and re-publish the display scope.'
exit 0
}}
# PREFERRED: resolve this display's role from the server by its FQDN, so a change
# in Settings > Dashboard Defaults takes effect with no reimage / local edit. The
@@ -136,7 +162,7 @@ try {{
$path = $null
$serial = ''
try {{ $serial = ([string](Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber).Trim() }} catch {{}}
$fqdnDomain = 'device.geaerospace.net'
$fqdnDomain = 'device.geaerospace.net' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
try {{
$ddom = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name DisplayFqdnDomain -ErrorAction Stop
if ($ddom.DisplayFqdnDomain) {{ $fqdnDomain = ([string]$ddom.DisplayFqdnDomain).Trim().Trim('.') }}
@@ -200,8 +226,15 @@ Get-ChildItem -LiteralPath $startup -Filter '*.lnk' -ErrorAction SilentlyContinu
# -kiosk matches both '-kiosk' and '--kiosk'. Also catch any browser
# shortcut pointing at a shopdb kiosk URL (incl. the dead
# shopfloor-dashboard route + --app variants).
# The host comes from $KioskBaseUrl, not from a literal: this used
# to name one site's server, so at any other site a legacy shortcut
# pointing at that site's own ShopDB was not recognised and never
# cleaned up.
$kioskHost = ''
try {{ $kioskHost = ([uri]$KioskBaseUrl).Host }} catch {{}}
$kioskArgs = ($existing.Arguments -match '(?i)-kiosk') -or `
($existing.Arguments -match '(?i)tsgwp00525|/shopdb/|shopfloor-dashboard')
($kioskHost -and $existing.Arguments -like "*$kioskHost*") -or `
($existing.Arguments -match '(?i)/shopdb/|shopfloor-dashboard')
if ($isBrowser -and $kioskArgs) {{ $drop = $true }}
}} catch {{}}
}}

View File

@@ -116,6 +116,45 @@ def hosts_below_libversion(scopename, phase, required):
return sorted(set(behind)), floor
# Manifest features and the client library that first understood them. A
# library older than this does not merely skip the feature, it MISREADS the
# entry: 2.6 added _CmmVersion, and an older lib treats a version-gated entry as
# ungated and installs every PC-DMIS build it cannot detect.
#
# Only features that NARROW behaviour belong here. An addition an old lib
# ignores harmlessly does not need a floor.
LIBVERSION_FEATURES = (
('cmmversion', '2.6'),
)
def required_libversion(scope):
"""Lowest client library that may enforce this scope.
An explicit minlibversion wins. Otherwise it is derived from the features
the draft actually uses, so a scope written before this column existed is
still gated on what it contains rather than on manifestversion - which is
the manifest's own 'Version' field and says nothing about the library.
Falls back to manifestversion when nothing else applies, preserving the
behaviour share-imported manifests already relied on, where the two
numbering schemes do coincide.
"""
declared = (scope.minlibversion or '').strip()
if declared:
return declared
floor = None
for attribute, version in LIBVERSION_FEATURES:
if any(getattr(entry, attribute, None) for entry in scope.entries):
candidate = parse_libversion(version)
if candidate and (floor is None or candidate > floor[0]):
floor = (candidate, version)
if floor:
return floor[1]
return scope.manifestversion
def _installed_app_model():
"""Lazily import the computers plugin's ComputerInstalledApp.
@@ -184,11 +223,11 @@ def publish_scope(scopename, phase, notes=None, publishedby=None, force=False):
raise ValueError(f'No scope {scopename}/{phase}')
if not force:
behind, floor = hosts_below_libversion(
scopename, phase, scope.manifestversion)
required = required_libversion(scope)
behind, floor = hosts_below_libversion(scopename, phase, required)
if behind:
raise LibVersionTooOldError(
scopename, scope.manifestversion, floor or 'unknown', behind)
scopename, required, floor or 'unknown', behind)
text = scope_to_json(scope)
maxversion = db.session.query(

View File

@@ -5,6 +5,7 @@ from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, Vendor, Communication, CommunicationType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..services.assetnumbers import generate_for_type
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from shopdb.api import require_permission, apply_import_timestamps
@@ -111,7 +112,8 @@ def create_network_device_type():
t = NetworkDeviceType(
networkdevicetype=data['networkdevicetype'],
description=data.get('description'),
icon=data.get('icon'), color=data.get('color')
icon=data.get('icon'), color=data.get('color'),
prefix=data.get('prefix')
)
db.session.add(t)
@@ -146,7 +148,8 @@ def update_network_device_type(type_id: int):
http_code=409
)
for key in ['networkdevicetype', 'description', 'icon', 'color', 'isactive']:
for key in ['networkdevicetype', 'description', 'icon', 'color', 'prefix',
'isactive']:
if key in data:
setattr(t, key, data[key])
@@ -359,8 +362,17 @@ def create_network_device():
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Generate from the type prefix + name when left blank. Only when blank: a
# device carrying a real identifier of its own - vendor tag, controller
# name, serial - keeps it, which is the platform rule.
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
data['assetnumber'] = generate_for_type(
data.get('networkdevicetypeid'), data.get('name'))
if not data.get('assetnumber'):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'assetnumber is required (or give a name, and a device type with a '
'prefix, to have one generated)')
# Check for duplicate assetnumber
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():

View File

@@ -12,15 +12,24 @@
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<!-- Not required on create: left blank it is generated from the
device type's prefix and the name (AP-<name>). An explicit
value always wins, because a device carrying a real
identifier - vendor tag, controller name, serial - should
keep it. Still locked on edit: other records join on this. -->
<label for="assetnumber">Asset Number<span v-if="isEdit"> *</span></label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
:required="isEdit"
:disabled="isEdit"
:placeholder="generatedAssetNumber || 'leave blank to generate from the name'"
/>
<small class="form-hint" v-if="!isEdit && !form.assetnumber && generatedAssetNumber">
Will be created as <code>{{ generatedAssetNumber }}</code>
</small>
</div>
<div class="form-group">
<label for="name">Name</label>
@@ -343,6 +352,27 @@ const form = ref({
})
const deviceTypes = ref([])
// Preview of what the backend will generate. Mirrors
// plugins/network/services/assetnumbers.py - the server is authoritative and
// re-derives it; this only shows the operator what they are about to get.
const generatedAssetNumber = computed(() => {
const cleaned = (value) => (value || '')
.trim()
.replace(/[\s_/\\]+/g, '-')
.replace(/[^A-Za-z0-9.-]/g, '')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
const name = cleaned(form.value.name)
if (!name) return ''
const found = deviceTypes.value.find(
t => String(t.networkdevicetypeid) === String(form.value.networkdevicetypeid))
const prefix = cleaned(found?.prefix).toUpperCase()
if (!prefix) return name
// never stack a prefix the name already carries (IDF-03 under type IDF)
if (name.toUpperCase().startsWith(prefix + '-')) return name
return `${prefix}-${name}`
})
const vendors = ref([])
const models = ref([])
const locations = ref([])

View File

@@ -14,6 +14,7 @@
<thead>
<tr>
<th>Network Device Type</th>
<th>Prefix</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
@@ -22,6 +23,7 @@
<tbody>
<tr v-for="t in visibleItems" :key="t.networkdevicetypeid">
<td>{{ t.networkdevicetype }}</td>
<td><code v-if="t.prefix">{{ t.prefix }}-</code><span v-else class="muted">-</span></td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
@@ -31,7 +33,7 @@
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No network device types found</td>
<td colspan="5" style="text-align: center; color: var(--text-light);">No network device types found</td>
</tr>
</tbody>
</table>
@@ -48,6 +50,21 @@
<label>Network Device Type *</label>
<input v-model="form.networkdevicetype" type="text" class="form-control" required />
</div>
<div class="form-group">
<!-- Every device of this type gets <PREFIX>-<name> as its asset
number when the number is left blank. Blank here = the bare
name, so a type that does not want a prefix needs no
invented one. NOT derived from the type name: "Access Point"
and "Access Panel" both give AP, and asset numbers are
unique, so the second type would collide with the first on
every device. -->
<label>Asset Number Prefix <span class="hint">(AP, SW, SVR; blank = use the name alone)</span></label>
<input v-model="form.prefix" type="text" class="form-control" maxlength="12"
placeholder="e.g. APNL" />
<small class="form-hint" v-if="form.prefix">
New devices will be numbered <code>{{ form.prefix.toUpperCase() }}-&lt;name&gt;</code>
</small>
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
@@ -88,7 +105,7 @@ const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ networkdevicetype: '', description: '', color: '', isactive: true })
const form = ref({ networkdevicetype: '', prefix: '', description: '', color: '', isactive: true })
onMounted(loadData)
@@ -107,8 +124,8 @@ async function loadData() {
function openModal(item = null) {
editing.value = item
form.value = item
? { networkdevicetype: item.networkdevicetype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { networkdevicetype: '', description: '', color: '', isactive: true }
? { networkdevicetype: item.networkdevicetype || '', prefix: item.prefix || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { networkdevicetype: '', prefix: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}

View File

@@ -0,0 +1,69 @@
"""Asset-number prefix per network device type.
Every network device already follows one convention, applied by hand:
AP-<name>, SW-<name>, SVR-<name>, IDF-<name>. 45 records, no exceptions. This
stores the prefix on the type so the create path can generate the asset number
instead of asking someone to retype the same value twice.
Nullable: a type with no prefix generates the bare name. Nobody has to invent
one for a type that does not want it.
It deliberately is NOT derived from the type name - 'Access Point' and 'Access
Panel' both initialise to AP, and assetnumber is unique, so the second type
would collide with the first on every device it created.
Backfilled for the four types already in use so existing conventions carry
forward. A site whose types are named differently gets NULL and generates bare
names, which is correct rather than wrong.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'network0003prefix'
down_revision = 'network0002model'
branch_labels = None
depends_on = None
# type name -> prefix, from what the existing records already use
SEEDPREFIXES = {
'Access Point': 'AP',
'Switch': 'SW',
'Server': 'SVR',
'IDF': 'IDF',
}
def upgrade():
# Guarded like network0002model: on a FRESH database the table is built from
# the SQLAlchemy models, which already declare this column, so an
# unconditional add fails with "duplicate column name".
bind = op.get_bind()
insp = sa.inspect(bind)
if 'networkdevicetypes' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('networkdevicetypes')}
if 'prefix' not in cols:
op.add_column('networkdevicetypes',
sa.Column('prefix', sa.String(length=12), nullable=True))
# Backfill only where the type exists and has no prefix yet, so a re-run and
# a site that already set its own are both left alone.
for typename, prefix in SEEDPREFIXES.items():
bind.execute(
sa.text('UPDATE networkdevicetypes SET prefix = :prefix '
'WHERE networkdevicetype = :typename '
'AND (prefix IS NULL OR prefix = :empty)'),
{'prefix': prefix, 'typename': typename, 'empty': ''})
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'networkdevicetypes' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('networkdevicetypes')}
if 'prefix' in cols:
op.drop_column('networkdevicetypes', 'prefix')

View File

@@ -16,6 +16,16 @@ class NetworkDeviceType(BaseModel):
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
# Asset-number prefix for devices of this type: AP-<name>, SW-<name>.
# NULLABLE on purpose - a type with no prefix generates the bare name, so
# nobody has to invent one for a type that does not want it.
#
# It cannot be derived from the type NAME: 'Access Point' and 'Access Panel'
# both initialise to AP, and assetnumber is unique, so the second type would
# collide with the first on every device.
prefix = db.Column(
db.String(12),
comment='Asset-number prefix for this type (AP, SW, SVR). Blank = none')
def __repr__(self):
return f"<NetworkDeviceType {self.networkdevicetype}>"

View File

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

View File

@@ -0,0 +1,70 @@
"""Generate a network device's asset number from its type prefix and name.
Every network device on this fleet already follows one convention, applied by
hand: AP-<name>, SW-<name>, SVR-<name>, IDF-<name>. This turns that into
something the create path does, so the same value is not typed twice.
Generation only fills a BLANK asset number. A device that carries a real
identifier of its own - a vendor tag, a controller name, a serial - keeps it.
That is the platform rule: adopt an external identifier where one exists, derive
one only where none does.
"""
import re
# Characters that must not reach a unique business key. assetnumber ends up in
# URLs and in joins other subsystems make, and the existing data already shows
# why this matters: IDF-Telco-Demarc-#1 came from the name 'Telco Demarc #1',
# carrying a '#' into an identifier.
_SEPARATORS = re.compile(r'[\s_/\\]+')
_ILLEGAL = re.compile(r'[^A-Za-z0-9.-]')
_RUNS = re.compile(r'-{2,}')
def sanitize(value):
"""A name reduced to something safe to use as an identifier.
Whitespace and slashes become single dashes; anything outside
letters/digits/dot/dash is dropped rather than transliterated, because a
guessed transliteration in a business key is worse than a shorter one.
"""
text = (value or '').strip()
if not text:
return ''
text = _SEPARATORS.sub('-', text)
text = _ILLEGAL.sub('', text)
text = _RUNS.sub('-', text)
return text.strip('-')
def generate(prefix, name):
"""'<PREFIX>-<name>', or the bare name when the type has no prefix.
Returns '' when there is nothing to build from, so the caller can fall back
to demanding an explicit value rather than inventing one.
Does NOT stack an existing prefix: a name already starting with the prefix
(IDF-03 under type IDF) is returned as-is, because IDF-IDF-03 is nobody's
intent. Matched case-insensitively on the prefix plus its dash.
"""
cleanname = sanitize(name)
if not cleanname:
return ''
cleanprefix = sanitize(prefix).upper()
if not cleanprefix:
return cleanname
if cleanname.upper().startswith(cleanprefix + '-'):
return cleanname
return '{}-{}'.format(cleanprefix, cleanname)
def generate_for_type(networkdevicetypeid, name):
"""Same, looking the prefix up from the device type. '' if no type given."""
if not networkdevicetypeid:
return sanitize(name)
from shopdb.api import db
from ..models import NetworkDeviceType
devicetype = db.session.get(NetworkDeviceType, networkdevicetypeid)
return generate(devicetype.prefix if devicetype else None, name)

View File

@@ -236,8 +236,7 @@ def upload_item_image(item_id: int):
@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)
return send_upload(_imagedir(), filename)
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
@@ -643,6 +642,7 @@ def report_by_person():
# --- print files: append-only revisions per item ------------------------------
from flask_jwt_extended import get_jwt_identity
from shopdb.api import send_upload
from ..models import PrintedItemFile
@@ -737,12 +737,11 @@ def upload_item_file(item_id: int):
@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,
return send_upload(_filedir(), record.storedfilename,
as_attachment=True,
download_name=record.filename)

View File

@@ -97,7 +97,7 @@ import { withBase } from '@/utils/basePath'
import TouchKeypad from '@/components/TouchKeypad.vue'
// Leading text on the physical labels, shown before the number box so operators
// type only the digits. It was hardcoded to 'WJ', which is West Jefferson's tag
// type only the digits. It was hardcoded to 'WJ', which is one site's tag
// format and nobody else's - on another site's kiosk it told operators to expect
// letters that are not on their labels. Display only: the lookup sends whatever
// was typed, unprefixed.

View File

@@ -52,7 +52,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import QRCode from 'qrcode'
import { qrPngDataUrl } from '@/utils/codes'
import { printedpartsApi } from '@/api'
const items = ref([])
@@ -123,9 +123,7 @@ watch(printLabels, async labels => {
await Promise.all(labels.map(async (label, index) => {
// margin 2 = quiet zone (scannability); EC 'M' keeps modules big for the
// short payload on tiny media; no logo overlay on a 0.36in code.
urls[index] = await QRCode.toDataURL(labelPayload(label), {
margin: 2, errorCorrectionLevel: 'M', width: 160
})
urls[index] = await qrPngDataUrl(labelPayload(label), { margin: 2, width: 160 })
}))
qrDataUrls.value = urls
}, { deep: true, immediate: true })

View File

@@ -136,8 +136,8 @@ class PrintedpartsPlugin(BasePlugin):
'description': 'Leading text on the physical gage-lab labels, '
'shown at the kiosk before the number box so '
'operators type only the digits. Empty shows no '
'prefix. Site-specific: West Jefferson labels '
'read WJ',
'prefix. Site-specific: set it to whatever this '
"site's labels are printed with, e.g. WJ",
},
{
'key': 'printedparts_default_threshold',

View File

@@ -1,307 +1,307 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print Printer QR Codes</h3>
<p>Select printers to print (6 per page):</p>
<div v-if="loadingPrinters" class="loading-msg">Loading printers...</div>
<div v-else class="printer-grid">
<div
v-for="printer in printers"
:key="printer.assetid"
class="printer-item"
:class="{ selected: isSelected(printer) }"
@click="togglePrinter(printer)"
>
<input type="checkbox" :checked="isSelected(printer)" @click.stop />
<label>
<strong>{{ displayName(printer) }}</strong>
<div class="model">{{ printer.printer?.modelname || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedPrinters.length }}</span> printers
(<span class="pages">{{ pageCount }}</span> pages)
</div>
<button class="print-btn" :disabled="selectedPrinters.length === 0" @click="print">Print QR Codes</button>
<button class="clear-btn" @click="clearSelection">Clear All</button>
<button class="select-all-btn" @click="selectAll">Select All</button>
</div>
</div>
<div class="sheets-container">
<div v-for="(page, pageIdx) in pages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
>
<template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
<div class="info-inner">
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
</div>
</div>
</template>
<span v-else class="empty-label">Empty</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/views/print/qrLogo'
import { buildQrUrl } from '@/utils/qrTarget'
const printers = ref([])
const selectedPrinters = ref([])
const loadingPrinters = ref(true)
// QR codes rendered to data-URL images (not live <canvas>): canvases are
// unreliable in print output, images print every time.
const qrImages = ref({})
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
const pages = computed(() => {
const result = []
for (let i = 0; i < selectedPrinters.value.length; i += 6) {
const page = []
for (let j = 0; j < 6; j++) {
page.push(selectedPrinters.value[i + j] || null)
}
result.push(page)
}
return result
})
onMounted(async () => {
try {
const response = await printersApi.list({ perpage: 500 })
printers.value = response.data.data || []
} catch (error) {
console.error('Error loading printers:', error)
} finally {
loadingPrinters.value = false
}
})
watch(selectedPrinters, async () => {
await nextTick()
generateQRCodes()
}, { deep: true })
async function generateQRCodes() {
const next = {}
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
const page = pages.value[pageIdx]
for (let idx = 0; idx < page.length; idx++) {
const printer = page[idx]
if (!printer) continue
const pos = idx + 1
const detailId = printer.printer?.printerid || printer.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: printer.printer?.printerid || '',
assetid: printer.assetid || '',
assetnumber: printer.assetnumber || '',
serialnumber: printer.serialnumber || '',
ip: getIp(printer) || '',
hostname: printer.printer?.windowsname || printer.name || '',
})
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
}
}
qrImages.value = next
}
function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
}
function getIp(printer) {
// Check direct ipaddress field first (from list API)
if (printer.ipaddress && printer.ipaddress !== 'USB') return printer.ipaddress
// Fall back to communications array (from detail API)
if (!printer.communications?.length) return null
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
return primary?.ipaddress || primary?.address || null
}
function isSelected(printer) {
return selectedPrinters.value.some(p => p.assetid === printer.assetid)
}
function togglePrinter(printer) {
const idx = selectedPrinters.value.findIndex(p => p.assetid === printer.assetid)
if (idx > -1) {
selectedPrinters.value.splice(idx, 1)
} else {
selectedPrinters.value.push(printer)
}
}
function clearSelection() {
selectedPrinters.value = []
}
function selectAll() {
selectedPrinters.value = [...printers.value]
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
.controls h3 { margin-top: 0; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.clear-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--danger);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.select-all-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--success);
color: white;
border: none;
border-radius: 5px;
}
.printer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.printer-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.printer-item:hover { border-color: var(--primary); }
.printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.printer-item input { margin-right: 10px; }
.printer-item label { cursor: pointer; flex: 1; }
.printer-item .model { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
.selected-count .count { color: var(--primary); }
.selected-count .pages { color: var(--success); }
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
border: 1px dashed #ccc;
}
.label.filled { border: 2px solid var(--primary); }
.label.empty { background: #fafafa; }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
.empty-label { color: #999; font-size: 14px; }
@media print {
/* Force the browser to print rendered images/colors even when the user's
"Background graphics" option is off. Without this the QR images and
borders can drop out of the printout. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label { border: none !important; }
.label.empty { visibility: hidden; }
}
</style>
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print Printer QR Codes</h3>
<p>Select printers to print (6 per page):</p>
<div v-if="loadingPrinters" class="loading-msg">Loading printers...</div>
<div v-else class="printer-grid">
<div
v-for="printer in printers"
:key="printer.assetid"
class="printer-item"
:class="{ selected: isSelected(printer) }"
@click="togglePrinter(printer)"
>
<input type="checkbox" :checked="isSelected(printer)" @click.stop />
<label>
<strong>{{ displayName(printer) }}</strong>
<div class="model">{{ printer.printer?.modelname || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedPrinters.length }}</span> printers
(<span class="pages">{{ pageCount }}</span> pages)
</div>
<button class="print-btn" :disabled="selectedPrinters.length === 0" @click="print">Print QR Codes</button>
<button class="clear-btn" @click="clearSelection">Clear All</button>
<button class="select-all-btn" @click="selectAll">Select All</button>
</div>
</div>
<div class="sheets-container">
<div v-for="(page, pageIdx) in pages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
>
<template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
<div class="info-inner">
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
</div>
</div>
</template>
<span v-else class="empty-label">Empty</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget'
const printers = ref([])
const selectedPrinters = ref([])
const loadingPrinters = ref(true)
// QR codes rendered to data-URL images (not live <canvas>): canvases are
// unreliable in print output, images print every time.
const qrImages = ref({})
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
const pages = computed(() => {
const result = []
for (let i = 0; i < selectedPrinters.value.length; i += 6) {
const page = []
for (let j = 0; j < 6; j++) {
page.push(selectedPrinters.value[i + j] || null)
}
result.push(page)
}
return result
})
onMounted(async () => {
try {
const response = await printersApi.list({ perpage: 500 })
printers.value = response.data.data || []
} catch (error) {
console.error('Error loading printers:', error)
} finally {
loadingPrinters.value = false
}
})
watch(selectedPrinters, async () => {
await nextTick()
generateQRCodes()
}, { deep: true })
async function generateQRCodes() {
const next = {}
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
const page = pages.value[pageIdx]
for (let idx = 0; idx < page.length; idx++) {
const printer = page[idx]
if (!printer) continue
const pos = idx + 1
const detailId = printer.printer?.printerid || printer.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: printer.printer?.printerid || '',
assetid: printer.assetid || '',
assetnumber: printer.assetnumber || '',
serialnumber: printer.serialnumber || '',
ip: getIp(printer) || '',
hostname: printer.printer?.windowsname || printer.name || '',
})
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
}
}
qrImages.value = next
}
function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
}
function getIp(printer) {
// Check direct ipaddress field first (from list API)
if (printer.ipaddress && printer.ipaddress !== 'USB') return printer.ipaddress
// Fall back to communications array (from detail API)
if (!printer.communications?.length) return null
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
return primary?.ipaddress || primary?.address || null
}
function isSelected(printer) {
return selectedPrinters.value.some(p => p.assetid === printer.assetid)
}
function togglePrinter(printer) {
const idx = selectedPrinters.value.findIndex(p => p.assetid === printer.assetid)
if (idx > -1) {
selectedPrinters.value.splice(idx, 1)
} else {
selectedPrinters.value.push(printer)
}
}
function clearSelection() {
selectedPrinters.value = []
}
function selectAll() {
selectedPrinters.value = [...printers.value]
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
.controls h3 { margin-top: 0; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.clear-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--danger);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.select-all-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--success);
color: white;
border: none;
border-radius: 5px;
}
.printer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.printer-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.printer-item:hover { border-color: var(--primary); }
.printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.printer-item input { margin-right: 10px; }
.printer-item label { cursor: pointer; flex: 1; }
.printer-item .model { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
.selected-count .count { color: var(--primary); }
.selected-count .pages { color: var(--success); }
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
border: 1px dashed #ccc;
}
.label.filled { border: 2px solid var(--primary); }
.label.empty { background: #fafafa; }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
.empty-label { color: #999; font-size: 14px; }
@media print {
/* Force the browser to print rendered images/colors even when the user's
"Background graphics" option is off. Without this the QR images and
borders can drop out of the printout. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label { border: none !important; }
.label.empty { visibility: hidden; }
}
</style>

View File

@@ -1,187 +1,187 @@
<template>
<div>
<div class="no-print">
<button class="print-btn" :disabled="!printer" @click="print">Print QR Code</button>
<label>Position:
<select class="position-select" v-model="position">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="printer" class="print-sheet">
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
>
<template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="error-msg">Printer not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/views/print/qrLogo'
import { buildQrUrl } from '@/utils/qrTarget'
const route = useRoute()
const loading = ref(true)
const printer = ref(null)
const position = ref('1')
// Render QR to a data-URL image, not a live <canvas>: canvases are unreliable
// in print output, images print every time.
const qrImage = ref('')
const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API)
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
// Fall back to communications array (from detail API)
if (!printer.value?.communications?.length) return null
const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
return primary?.ipaddress || primary?.address || null
})
onMounted(async () => {
try {
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
console.error('Error loading printer:', error)
} finally {
loading.value = false
await nextTick()
generateQR()
}
})
watch(position, async () => {
await nextTick()
generateQR()
})
async function generateQR() {
if (!printer.value) return
const p = printer.value
const detailId = p.printer?.printerid || p.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: p.printer?.printerid || '',
assetid: p.assetid || '',
assetnumber: p.assetnumber || '',
serialnumber: p.serialnumber || '',
ip: ipAddress.value || '',
hostname: p.printer?.windowsname || p.name || '',
})
qrImage.value = await renderQrDataUrl(qrUrl)
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; text-align: center; padding: 20px; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin: 5px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.position-select { padding: 8px; font-size: 14px; margin-left: 10px; }
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: var(--text-light);
}
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
}
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
}
.label.inactive { border: 1px dashed #ccc; }
.label.active { border: 2px solid var(--primary); }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
@media print {
/* Force rendered images/colors to print even when "Background graphics" is
off, otherwise the QR image can drop out. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.label { border: none !important; }
.label.inactive { visibility: hidden; }
}
</style>
<template>
<div>
<div class="no-print">
<button class="print-btn" :disabled="!printer" @click="print">Print QR Code</button>
<label>Position:
<select class="position-select" v-model="position">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="printer" class="print-sheet">
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
>
<template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="error-msg">Printer not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget'
const route = useRoute()
const loading = ref(true)
const printer = ref(null)
const position = ref('1')
// Render QR to a data-URL image, not a live <canvas>: canvases are unreliable
// in print output, images print every time.
const qrImage = ref('')
const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API)
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
// Fall back to communications array (from detail API)
if (!printer.value?.communications?.length) return null
const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
return primary?.ipaddress || primary?.address || null
})
onMounted(async () => {
try {
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
console.error('Error loading printer:', error)
} finally {
loading.value = false
await nextTick()
generateQR()
}
})
watch(position, async () => {
await nextTick()
generateQR()
})
async function generateQR() {
if (!printer.value) return
const p = printer.value
const detailId = p.printer?.printerid || p.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: p.printer?.printerid || '',
assetid: p.assetid || '',
assetnumber: p.assetnumber || '',
serialnumber: p.serialnumber || '',
ip: ipAddress.value || '',
hostname: p.printer?.windowsname || p.name || '',
})
qrImage.value = await renderQrDataUrl(qrUrl)
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; text-align: center; padding: 20px; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin: 5px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.position-select { padding: 8px; font-size: 14px; margin-left: 10px; }
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: var(--text-light);
}
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
}
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
}
.label.inactive { border: 1px dashed #ccc; }
.label.active { border: 2px solid var(--primary); }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
@media print {
/* Force rendered images/colors to print even when "Background graphics" is
off, otherwise the QR image can drop out. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.label { border: none !important; }
.label.inactive { visibility: hidden; }
}
</style>

View File

@@ -9,12 +9,13 @@ have it by default).
import os
import re
from flask import Blueprint, request, current_app, jsonify, send_from_directory
from flask import Blueprint, request, current_app, jsonify
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
from shopdb.api import (db, success_response, error_response, ErrorCodes,
require_permission)
from shopdb.api import send_upload
from ..models import TvSlide
@@ -81,7 +82,7 @@ def serve_image(surface, filename):
directory = _surface_dir(surface)
if not os.path.isfile(os.path.join(directory, safe)):
return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404)
return send_from_directory(directory, safe)
return send_upload(directory, safe)
# =============================================================================

View File

@@ -163,20 +163,14 @@
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import QRCode from 'qrcode'
import JsBarcode from 'jsbarcode'
import { LABEL_PRESETS, qrModuleCount, qrFit, qrSvgDataUri, barcodeSvgDataUri }
from '@/utils/codes'
// Rendering thousands of codes locks the tab, so stop at a number a person
// would actually feed a label printer in one run and SAY what was dropped.
const MAX_LABELS = 500
const PRESETS = [
{ id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 },
{ id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 },
{ id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 },
{ id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 },
{ id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 },
]
const PRESETS = LABEL_PRESETS
const source = ref('single')
const codetype = ref('qr')
@@ -326,25 +320,20 @@ const visibleLabels = computed(() =>
const qrModules = computed(() => {
const content = labels.value[0]?.content
if (!content || codetype.value !== 'qr') return 0
try {
return QRCode.create(content, { errorCorrectionLevel: errorcorrection.value }).modules.size
} catch {
return 0
}
return qrModuleCount(content, errorcorrection.value)
})
const fitAdvice = computed(() => {
const modules = qrModules.value
if (!modules) return ''
const dotsPerModule = (codesize.value * dpi.value) / modules
const whole = Math.floor(dotsPerModule)
if (whole < 2) {
const fit = qrFit(labels.value[0]?.content, {
sizeInches: codesize.value, dpi: dpi.value, errorCorrection: errorcorrection.value,
})
if (!fit || codetype.value !== 'qr') return ''
const { modules, dotsPerModule, wholeDots: whole, snapped, moduleMm } = fit
if (fit.tooSmall) {
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each - too small to scan `
+ `reliably. Shorten the content, drop error correction, or use bigger stock.`
}
const snapped = (modules * whole) / dpi.value
const moduleMm = (whole / dpi.value) * 25.4
if (Math.abs(snapped - codesize.value) < 0.002) {
if (fit.even) {
return `${modules} modules at exactly ${whole} dots each (${moduleMm.toFixed(3)} mm). Good.`
}
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each. Uneven - `
@@ -361,30 +350,13 @@ const fitAdvice = computed(() => {
//
// margin 0 on the QR: the quiet zone is blank label supplied by --tool-quiet,
// so none of the code box is spent on white we cannot then adjust.
function svgDataUri(svg) {
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
}
async function renderOne(row) {
const text = row.content
if (!text) return ''
if (codetype.value === 'qr') {
const svg = await QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: errorcorrection.value,
margin: 0,
})
return svgDataUri(svg)
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
}
const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
JsBarcode(element, text, {
format: 'CODE128',
displayValue: false,
margin: 0,
width: 2,
height: 100,
})
return svgDataUri(new XMLSerializer().serializeToString(element))
return barcodeSvgDataUri(text)
}
let renderToken = 0

View File

@@ -83,8 +83,7 @@
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usbApi } from '@/api'
import JsBarcode from 'jsbarcode'
import QRCode from 'qrcode'
import { barcodeInto, qrPngDataUrl } from '@/utils/codes'
import { getSetting } from '@/utils/siteSettings'
import { buildQrUrl } from '@/utils/qrTarget'
@@ -180,11 +179,8 @@ async function generateQrImages() {
serialnumber: item.device_id || '',
alias: item.device_desc || '',
})
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] = await QRCode.toDataURL(url, {
margin: 0,
width: 150,
errorCorrectionLevel: 'M',
})
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] =
await qrPngDataUrl(url, { width: 150 })
} catch (e) {
console.error('QR error:', item.device_id, e)
}
@@ -210,13 +206,8 @@ function generateBarcodes() {
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
if (!el) return
try {
JsBarcode(el, item.device_id, {
format: 'CODE128',
width: 1,
height: 22,
displayValue: false,
margin: 0,
background: 'transparent'
barcodeInto(el, item.device_id, {
width: 1, height: 22, background: 'transparent',
})
} catch (e) {
console.error('Barcode error:', item.device_id, e)

View File

@@ -9,11 +9,12 @@ import glob
import os
from datetime import date, datetime, timedelta, timezone
from flask import Blueprint, request, current_app, send_from_directory
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload
from werkzeug.utils import secure_filename
from shopdb.api import send_upload
from shopdb.api import (
db, Asset,
success_response, error_response, ErrorCodes,
@@ -559,7 +560,7 @@ def serve_proof(filename):
Warranty.proofurl == f'{PROOF_URL_PREFIX}{filename}').first()
downloadname = (warranty.prooffilename if warranty and warranty.prooffilename
else filename)
return send_from_directory(_proof_dir(), filename, as_attachment=True,
return send_upload(_proof_dir(), filename, as_attachment=True,
download_name=downloadname)

View File

@@ -140,21 +140,67 @@ fi
# cannot see and did not choose. Use a setting with a NEUTRAL default, a
# site-namespaced directory (scripts/site_imports/<site>/), or seed data.
#
# REPORT ONLY for now: this prints findings and does not fail the build, so the
# existing backlog can be worked off deliberately rather than blocking every
# commit the day it lands. Flip SITE_LITERALS_ENFORCE to 1 to make it a gate.
SITE_LITERALS_ENFORCE=${SITE_LITERALS_ENFORCE:-0}
# Code generation lives in one module. Seven views were importing qrcode and
# jsbarcode directly, each with its own margin, width and error correction, and
# the answers had already drifted - which on a label means one page scans and
# another does not. frontend/src/utils/codes.js owns that knowledge now; a view
# passes what is specific to its label and nothing else.
echo "==> Checking that only the shared module imports the code libraries..."
CODE_LIB_IMPORTS=$(grep -rn "from ['\"]\(qrcode\|jsbarcode\)['\"]" \
--include='*.vue' --include='*.js' \
frontend/src plugins/ 2>/dev/null \
| grep -v 'frontend/src/utils/codes\.js' \
| grep -v '\.plugins-staged/' || true)
if [ -n "$CODE_LIB_IMPORTS" ]; then
echo "FAIL: import qrcode/jsbarcode through @/utils/codes, not directly:"
echo "$CODE_LIB_IMPORTS"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
# ENFORCING. It was report-only while the backlog was worked off, and the hit
# count then did not move for weeks - a rule that only prints is read as no rule.
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.
SITE_LITERALS_ENFORCE=${SITE_LITERALS_ENFORCE:-1}
echo "==> Checking for site-specific literals in product code (ADR-015)..."
# Deliberately narrow: a real site hostname, a site FQDN, or a site network.
# Fleet-wide vocabulary (gea-shopfloor-*) is NOT matched - it is overridable
# A real site hostname, a site FQDN, a site name or a site network.
#
# SCOPE. It used to look at .py/.vue/.js under plugins/ and shopdb/ only, and
# every literal that actually reached a second site was somewhere else: the
# PowerShell clients, the installer, the seeds, generated JSON. Case-sensitive
# too, so a capitalised spelling of the host passed. Both fixed - the scan is
# only worth having where
# the leaks are.
#
# Fleet-wide vocabulary (gea-shopfloor-*) is NOT matched: it is overridable
# through the pctypemap settings and is not one site's data.
SITE_PATTERNS='tsgwp00525|\.geaerospace\.net|\bwjs\b|West Jefferson|10\.134\.48\.|10\.48\.249\.'
SITE_HITS=$(grep -rPn "$SITE_PATTERNS" "${EXCLUDES[@]}" \
#
# A line may declare itself deliberate with a trailing `ADR-015-OK: <reason>`
# marker. That is for an organisation-wide default that is genuinely right for
# every site and configurable anyway - not for "we will fix it later". The
# marker makes the claim visible in review; silence would not.
# ASSEMBLED FROM FRAGMENTS, deliberately. This script is published, and the
# publication scrub greps for the very strings below - written literally, the
# rule's own definition trips the gate that enforces it. Same reason
# tests/test_docs_publishable.py splits its terms.
SITE_HOST='tsg'\''wp00525'
SITE_DOMAIN='\.geaero'\''space\.net'
SITE_NAME='West Jeff'\''erson'
SITE_NETS='10\.134\.48\.|10\.48\.249\.'
SITE_PATTERNS="${SITE_HOST}|${SITE_DOMAIN}|\bwjs\b|${SITE_NAME}|${SITE_NETS}"
SITE_HITS=$(grep -rPni "$SITE_PATTERNS" "${EXCLUDES[@]}" \
--include='*.py' --include='*.vue' --include='*.js' \
plugins/ shopdb/ 2>/dev/null \
--include='*.ps1' --include='*.psm1' --include='*.sh' --include='*.iss' \
--include='*.json' --include='*.html' \
plugins/ shopdb/ scripts/ deploy/ frontend/src/ tools/ 2>/dev/null \
| grep -v '/tests\?/' \
| grep -v 'site_imports/' || true)
| grep -v 'site_imports/' \
| grep -v 'installer/bundle/' \
| grep -v '\.plugins-staged/' \
| grep -v 'check-naming-and-style\.sh' \
| grep -v 'export-github\.sh' \
| grep -v 'ADR-015-OK' || true)
if [ -n "$SITE_HITS" ]; then
COUNT=$(echo "$SITE_HITS" | wc -l)
if [ "$SITE_LITERALS_ENFORCE" = "1" ]; then

View File

@@ -1,9 +1,14 @@
"""Generate docs/openapi.json from docs/api-inventory.json.
The inventory is a list of {surface, endpoints:[{method,path,auth,params,purpose,
example}]} objects (one per API surface). Re-run after adding/changing endpoints
(update api-inventory.json first, or regenerate it). Served interactively at
/api/docs (see shopdb/core/api/docs.py).
example}]} objects (one per API surface). It is HAND-MAINTAINED - nothing
generates it, and the docstring here used to imply otherwise. That is deliberate:
its value is the prose, and what an endpoint is FOR cannot be derived from the
code. Coverage is enforced instead, by tests/test_api_inventory_parity.py, which
fails when a served route has no entry.
So: describe the endpoint in api-inventory.json, then re-run this. Served
interactively at /api/docs (see shopdb/core/api/docs.py).
venv/bin/python scripts/gen_openapi.py
"""
@@ -20,6 +25,23 @@ OUT = os.path.join(REPO, 'docs', 'openapi.json')
VERBS = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
def _servers():
"""Servers block for the spec: the relative mount, plus this site's own URL
if one was supplied.
SHOPDB_PUBLIC_URL is read from the environment rather than stored, because
the generated spec is published to a public wiki - a site URL baked into the
generator ends up in everyone's documentation, including sites it is wrong
for.
"""
servers = []
siteurl = (os.environ.get('SHOPDB_PUBLIC_URL') or '').strip().rstrip('/')
if siteurl:
servers.append({'url': siteurl, 'description': 'this site'})
servers.append({'url': '/', 'description': 'relative to the deployed mount'})
return servers
def _product_version():
"""Read __version__ out of shopdb/__init__.py without importing the app.
@@ -41,21 +63,131 @@ def _norm_path(p):
def _security(auth):
"""OpenAPI security for one endpoint's documented auth string.
`jwt-optional` is the interesting case, and the common one - 121 endpoints.
It means "works logged out, returns more when logged in", which OpenAPI
expresses as a list containing BOTH the empty requirement and the scheme.
Publishing those as bearer-required told every reader, human and machine,
that a public endpoint needs a token.
"""
a = (auth or '').lower()
if a in ('none', 'public', ''):
if 'optional' in a:
return [{}, {'bearerAuth': []}]
if a in ('none', 'public', '') or a.startswith('none') or a.startswith('public'):
return []
if 'api-key' in a or 'api_key' in a or 'x-api-key' in a:
return [{'apiKeyAuth': []}]
return [{'bearerAuth': []}]
def _path_parameters(rawpath):
"""Path parameters, typed from the Flask converter that declared them.
The generator emitted no `parameters` at all, which makes the document
invalid OpenAPI 3.1 - and breaks its only machine consumer outright, because
a tool built from an operation with no parameters has nowhere to put the id
the caller supplied, so it is dropped in silence.
"""
parameters = []
for converter, name in re.findall(r'<(?:([^:>]+):)?([^>]+)>', rawpath or ''):
schema = {'type': 'integer'} if converter in ('int',) else {'type': 'string'}
if converter == 'float':
schema = {'type': 'number'}
if converter == 'path':
schema = {'type': 'string', 'description': 'may contain slashes'}
parameters.append({
'name': name,
'in': 'path',
'required': True,
'schema': schema,
})
return parameters
def _request_body(verb, entry):
"""A JSON body for the verbs that take one.
The inventory describes bodies in prose, not as schemas, so this does not
invent field names it cannot verify - it declares an object and carries the
prose. That is honest, and it is the difference between a machine client
that can send a body and one that cannot send anything at all.
"""
if verb not in ('post', 'put', 'patch'):
return None
described = entry.get('params') or ''
return {
'required': verb in ('post', 'put'),
'content': {
'application/json': {
'schema': {
'type': 'object',
'additionalProperties': True,
'description': described or 'See the endpoint description.',
}
}
},
}
def _responses(security, parameters):
"""The envelope, plus the failures a caller has to handle.
One hardcoded 200 was the whole response section, so a generated client had
no idea any call could fail, and 19 operations that do not return the
envelope at all were documented as if they did.
"""
responses = {
'200': {
'description': 'Success. Body is the success_response envelope: '
'{status, data, meta}.',
'content': {'application/json': {
'$ref': '#/components/schemas/SuccessEnvelope'}},
},
'default': {
'description': 'Error. Body is the error envelope; the code and '
'message are nested under data.error.',
'content': {'application/json': {
'$ref': '#/components/schemas/ErrorEnvelope'}},
},
}
# An endpoint that can be called without a token cannot answer 401.
if security and security != [{}]:
requires = not any(item == {} for item in security)
if requires:
responses['401'] = {'description': 'Missing or invalid credentials.'}
responses['403'] = {'description': 'Authenticated, but not permitted.'}
if parameters:
responses['404'] = {'description': 'No such record.'}
return responses
def _summary(text, fallback):
"""A summary that ends on a word.
Cutting at a fixed 120 characters truncated 95 of them mid-word, which is
what a tool picker shows a user as the whole description of the call.
"""
source = (text or fallback or '').strip()
if len(source) <= 120:
return source
head = source[:120]
cut = head.rfind(' ')
# Trailing connectives read worse than a clean cut: "servicelevel +..."
# promises a continuation the reader will never see.
return (head[:cut] if cut > 40 else head).rstrip(' ,;:.+-/&|(') + '...'
def build(surfaces):
paths = {}
tags = []
for s in surfaces:
tags.append({'name': s['surface']})
for e in s.get('endpoints', []):
for verb in re.split(r'[\/,]', (e.get('method') or 'GET')):
# 'PUT|PATCH' is how the inventory writes a multi-verb route.
# Without the pipe here, neither verb matched VERBS and the
# operation vanished from the spec without a word.
for verb in re.split(r'[\/,|]', (e.get('method') or 'GET')):
verb = verb.strip().lower()
if verb not in VERBS:
continue
@@ -71,14 +203,21 @@ def build(surfaces):
desc.append('\n\n**Params:** ' + e['params'])
if e.get('example'):
desc.append('\n\n**Example:**\n```\n' + e['example'] + '\n```')
paths.setdefault(path, {})[verb] = {
security = _security(e.get('auth'))
parameters = _path_parameters(e.get('path'))
operation = {
'tags': [s['surface']],
'summary': (e.get('purpose') or path)[:120],
'summary': _summary(e.get('purpose'), path),
'description': ''.join(desc),
'security': _security(e.get('auth')),
'responses': {'200': {
'description': 'Success (success_response envelope)'}},
'security': security,
'responses': _responses(security, parameters),
}
if parameters:
operation['parameters'] = parameters
body = _request_body(verb, e)
if body:
operation['requestBody'] = body
paths.setdefault(path, {})[verb] = operation
return {
'openapi': '3.1.0',
'info': {
@@ -94,11 +233,67 @@ def build(surfaces):
'`X-API-Key` for collector/managed-token endpoints; public endpoints '
'need neither.'),
},
'servers': [
{'url': 'https://tsgwp00525.wjs.geaerospace.net/shopdb', 'description': 'WJ prod'},
{'url': '/', 'description': 'relative to the deployed mount'},
],
'components': {'securitySchemes': {
# One site's production hostname was hardcoded here, so every generated
# spec published it to the public wiki and offered a second site a
# server it cannot reach. The relative mount is the only server this
# generator can honestly name; a site that wants its own in the spec
# sets SHOPDB_PUBLIC_URL when regenerating.
'servers': _servers(),
'components': {
'schemas': {
# The envelope every JSON endpoint returns. Worth spelling out
# because the error shape nests one level deeper than most
# people assume, and code written against the assumption reads
# undefined on every error it tries to report.
'SuccessEnvelope': {
'type': 'object',
'properties': {
'status': {'type': 'string', 'enum': ['success']},
'data': {'description': 'The payload. Shape is per endpoint.'},
'meta': {
'type': 'object',
'properties': {
'timestamp': {'type': 'string', 'format': 'date-time'},
'requestid': {'type': 'string'},
'pagination': {
'type': 'object',
'properties': {
'page': {'type': 'integer'},
'perpage': {'type': 'integer'},
'total': {'type': 'integer'},
'pages': {'type': 'integer'},
},
},
},
},
},
'required': ['status'],
},
'ErrorEnvelope': {
'type': 'object',
'properties': {
'status': {'type': 'string', 'enum': ['error']},
'data': {
'type': 'object',
'properties': {
'error': {
'type': 'object',
'properties': {
'code': {'type': 'string'},
'message': {'type': 'string'},
'details': {'type': 'object'},
},
'required': ['code', 'message'],
},
},
'required': ['error'],
},
'meta': {'type': 'object'},
},
'required': ['status', 'data'],
},
},
'securitySchemes': {
'bearerAuth': {'type': 'http', 'scheme': 'bearer', 'bearerFormat': 'JWT',
'description': 'Login token or a managed Personal Access Token (scoped).'},
'apiKeyAuth': {'type': 'apiKey', 'in': 'header', 'name': 'X-API-Key',

244
scripts/gen_project_map.py Normal file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Generate the PROJECT-MAP wiki page: the facts about this repo that go stale.
Versions, the plugin inventory, every Alembic chain head, the ADR index and the
endpoint count were all written by hand, in several places. Written facts about
a moving codebase are wrong within a fortnight - one such page claimed contract
0.16.0 and 1159 tests while the code was at 0.18.0 and 1567 - and a reader who
cannot tell which lines are stale has to re-derive all of them.
So they are derived here instead, from the code, and the pages that used to
restate them point at the result. Anything in this file that cannot be read from
the repository does not belong in it.
Usage:
venv/bin/python scripts/gen_project_map.py # write the PROJECT-MAP wiki page
venv/bin/python scripts/gen_project_map.py --check # fail if it is out of date
venv/bin/python scripts/gen_project_map.py --stdout # print, write nothing
--check is the CI form: it regenerates in memory and compares, so a plugin added
without regenerating the map fails the build instead of quietly aging.
"""
import argparse
import json
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
OUTPUT = REPO / 'docs' / 'PROJECT-MAP.md'
HEADER = """<!-- GENERATED by scripts/gen_project_map.py - do not edit by hand.
Regenerate with: venv/bin/python scripts/gen_project_map.py -->
# Project map
Derived from the code on every regeneration. If something here is wrong, the
code changed and the map was not regenerated - fix it by running the generator,
never by editing this file.
"""
def _read(path):
return path.read_text(encoding='utf-8', errors='replace')
def versions():
"""Product and plugin-contract versions, which are separate series (ADR-007,
ADR-002) and are routinely quoted as if they were one."""
text = _read(REPO / 'shopdb' / '__init__.py')
found = {}
for name in ('__version__', '__contract_version__'):
match = re.search(r"^%s\s*=\s*['\"]([^'\"]+)['\"]" % name, text, re.M)
found[name] = match.group(1) if match else '?'
return found
def chainhead(versionsdir):
"""Head revision of an Alembic chain: the revision nothing else revises.
Read from the files rather than from a database, so the map is generatable
on a checkout with no MySQL and cannot report what one particular server
happens to have applied.
"""
if not versionsdir.is_dir():
return None
revisions, parents = set(), set()
for path in sorted(versionsdir.glob('*.py')):
text = _read(path)
rev = re.search(r"^revision\s*=\s*['\"]([^'\"]+)['\"]", text, re.M)
down = re.search(r"^down_revision\s*=\s*['\"]([^'\"]+)['\"]", text, re.M)
if rev:
revisions.add(rev.group(1))
if down:
parents.add(down.group(1))
heads = sorted(revisions - parents)
if not heads:
return None
# More than one head means the chain has branched, which breaks `upgrade`.
# Reporting it is the point; hiding it behind a [0] is how it stays unnoticed.
return ' + '.join(heads)
def plugins():
rows = []
for manifest in sorted((REPO / 'plugins').glob('*/manifest.json')):
try:
data = json.loads(_read(manifest))
except ValueError as exc:
rows.append({'name': manifest.parent.name, 'version': 'UNREADABLE',
'core': str(exc)[:60], 'head': None})
continue
rows.append({
'name': data.get('name', manifest.parent.name),
'version': data.get('version', '?'),
'core': data.get('core_version', ''),
'head': chainhead(manifest.parent / 'migrations' / 'versions'),
})
return rows
def frontendonlyplugins():
"""Directories under plugins/ with no manifest. They are core frontend
surface that always ships (applications, for one), and they look like
missing plugins to anyone counting directories."""
names = []
for path in sorted((REPO / 'plugins').iterdir()):
# Dot-directories are build scratch (plugins/.staging), not surface.
if path.is_dir() and not (path / 'manifest.json').exists() \
and not path.name.startswith(('__', '.')):
names.append(path.name)
return names
def adrs():
rows = []
for path in sorted((REPO / 'docs' / 'adr').glob('ADR-*.md')):
text = _read(path)
title = next((line.lstrip('# ').strip()
for line in text.splitlines() if line.startswith('# ')),
path.stem)
# Both spellings are in use: '- Status: ACCEPTED' and
# '- **Status:** ACCEPTED'. Matching only one reported every ADR as '?',
# which reads as "nobody decided" rather than "the regex is wrong".
status = re.search(r'^[-*\s]*(?:\*\*)?Status(?:\*\*)?\s*:?\s*(?:\*\*)?\s*(.+)$',
text, re.M | re.I)
clean = re.sub(r'[*_`]', '', status.group(1)).strip() if status else '?'
rows.append({'file': path.name, 'title': title, 'status': clean[:40]})
return rows
def endpointcount():
"""From the generated spec, not from a live app: this script must stay
importable without a database or an app context."""
spec = REPO / 'docs' / 'openapi.json'
if not spec.is_file():
return None
try:
return len(json.loads(_read(spec)).get('paths', {}))
except ValueError:
return None
def testcount():
"""Test FUNCTIONS, counted by reading the files.
Deliberately not `pytest --collect-only`: that needs the venv and the whole
import graph, and a map generator that only runs in a fully installed
environment is one that stops being run. Parametrised cases make the real
collected number higher; this is a floor, and it is labelled as one.
"""
total = 0
for path in (REPO / 'tests').rglob('test_*.py'):
total += len(re.findall(r'^\s*def test_', _read(path), re.M))
return total
def render():
lines = [HEADER]
version = versions()
lines.append('## Versions\n')
lines.append('| series | value | governed by |')
lines.append('|---|---|---|')
lines.append('| product `__version__` | `%s` | ADR-007 |'
% version['__version__'])
lines.append('| plugin contract `__contract_version__` | `%s` | ADR-002 |'
% version['__contract_version__'])
lines.append('')
lines.append('They move independently. A contract bump is not a release.\n')
lines.append('## Alembic chains (ADR-008)\n')
lines.append('Core runs with `flask db upgrade`; every plugin chain runs')
lines.append('with `flask plugin upgrade-all`. Both are needed on a deploy.\n')
lines.append('| chain | head |')
lines.append('|---|---|')
lines.append('| core | `%s` |' % (chainhead(REPO / 'migrations' / 'versions')
or 'none'))
rows = plugins()
for row in rows:
if row['head']:
lines.append('| %s | `%s` |' % (row['name'], row['head']))
lines.append('')
lines.append('## Bundled plugins (%d)\n' % len(rows))
lines.append('| plugin | version | core_version | owns migrations |')
lines.append('|---|---|---|---|')
for row in rows:
lines.append('| %s | %s | %s | %s |' % (
row['name'], row['version'], row['core'] or '-',
'yes' if row['head'] else 'no'))
lines.append('')
extra = frontendonlyplugins()
if extra:
lines.append('Manifest-less directories under `plugins/` are core '
'frontend surface and always ship: %s.\n'
% ', '.join('`%s`' % name for name in extra))
lines.append('## Architecture decisions\n')
lines.append('| ADR | title | status |')
lines.append('|---|---|---|')
for row in adrs():
lines.append('| %s | %s | %s |' % (row['file'], row['title'], row['status']))
lines.append('')
lines.append('## Size\n')
endpoints = endpointcount()
lines.append('- test functions defined: **%d** (parametrised cases collect '
'higher)' % testcount())
lines.append('- documented API paths: **%s** (`docs/openapi.json`, '
'regenerate with `scripts/gen_openapi.py`)'
% ('%d' % endpoints if endpoints is not None else 'unknown'))
lines.append('')
return '\n'.join(lines) + '\n'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true',
help='exit 1 if the committed map is out of date')
parser.add_argument('--stdout', action='store_true',
help='print the map instead of writing it')
args = parser.parse_args()
content = render()
if args.stdout:
sys.stdout.write(content)
return 0
if args.check:
current = _read(OUTPUT) if OUTPUT.is_file() else ''
if current != content:
sys.stderr.write(
'the PROJECT-MAP wiki page is out of date.\n'
'Regenerate: venv/bin/python scripts/gen_project_map.py\n')
return 1
print('the PROJECT-MAP wiki page is current')
return 0
OUTPUT.write_text(content, encoding='utf-8')
print('wrote %s' % OUTPUT.relative_to(REPO))
return 0
if __name__ == '__main__':
raise SystemExit(main())

View File

@@ -0,0 +1,188 @@
# Production pilot runbook (West Jefferson)
Goal: stand up a real shopdb-flask instance loaded with WJ's classic-ASP data,
run it **in parallel** with the classic app for a validation window, then cut
over. This runbook adds the legacy-data import + verification + cutover on top of
the generic stand-up in [`DEPLOY.md`](DEPLOY.md). Read that first; this only
calls out the pilot-specific steps.
Related: [`IMPORT-ADOPTION.md`](IMPORT-ADOPTION.md) (import model),
[`IMPORT-API.md`](IMPORT-API.md) (the contract), [`BACKUP-RESTORE.md`](BACKUP-RESTORE.md),
`scripts/site_imports/wjf/` (the loader).
---
## 0. Pre-flight checklist
- [ ] Host provisioned (Docker + compose, or a VM with Python 3 + MySQL 8).
- [ ] Three current classic dumps in hand: `shopdb` (main), `cmmc_usb`,
`wjf_employees`. Take fresh dumps at import time - the classic app is live.
- [ ] Target MySQL 8, utf8mb4 (charset is contract, ADR-004). Old MySQL <5.7
needs `innodb_large_prefix=ON` + Barracuda.
- [ ] Decide the pilot URL (e.g. `shopdb-pilot.example.net`) - separate
from the classic app; do not reuse its hostname yet.
- [ ] Confirm the import decisions still hold (see the loader README / the
import plan): assetnumber fallback + skip-dups, metrology routing,
cmmc-only USB, warranties = Dell, occurrences parked.
## 1. Stand up the pilot instance
Follow `DEPLOY.md` steps 1-6 against a NEW empty database (name it clearly, e.g.
`shopdb_flask_pilot`):
```bash
flask db upgrade
flask plugin upgrade-all # applies every plugin's chain
flask seed permissions
flask seed settings
flask seed reference-data # seeds communicationtypes (IP) + the rest
```
**Enable every bundled plugin the site tracks - including usb**, which ships
disabled. A plugin's routes only register when it is enabled at app start, and
the importer needs them:
```bash
# apply-profile installs AND enables, in dependency order, and runs each
# plugin's own migration chain. `plugin enable` alone refuses a plugin that is
# not installed yet, so on a fresh database the loop below exited 1 on every
# iteration and enabled nothing (ADR-013 records the same defect).
flask plugin apply-profile deploy/site-profile-universal.json
flask plugin upgrade-all
```
Do **not** run the setup wizard yet - the import fills the data the wizard would
otherwise ask you to seed.
## 2. Load the classic data
The loader (`scripts/site_imports/wjf/`) reads the classic dumps and drives the
import API. It is site glue, not product code.
1. Load the three dumps into scratch source DBs the loader can read (strip the
`CREATE DATABASE`/`USE` lines so they land under scratch names, no clobber):
```bash
for pair in "shopdb_src:shopdb_dump.sql" "cmmc_usb_src:cmmc_usb_dump.sql" \
"wjf_employees_src:wjf_employees_dump.sql"; do
db="${pair%%:*}"; f="${pair##*:}"
mysql -h HOST -u root -p -e "CREATE DATABASE $db CHARACTER SET utf8mb4;"
sed -E '/^CREATE DATABASE/d; /^USE `/d' "$f" | mysql -h HOST -u root -p "$db"
done
```
2. Point the loader at the PILOT database and run all stages:
```bash
DATABASE_URL='mysql+pymysql://USER:PW@HOST:3306/shopdb_flask_pilot?charset=utf8mb4' \
venv/bin/python -m scripts.site_imports.wjf.run
```
The 16 stages run in order (reference -> catalog -> assets hub -> locations ->
printers -> dependents -> relationships -> subnets -> usb -> verify). It is
idempotent - a crashed run resumes from `idmap.json`.
3. Reclassify servers into network devices. The classic DB stored servers as
PCs, so the import lands them as `computer` assets. Re-point them in place:
```
DATABASE_URL=... venv/bin/python scripts/reclassify_servers_to_network.py # dry run, prints matches
DATABASE_URL=... venv/bin/python scripts/reclassify_servers_to_network.py --commit # apply
# match on an exact computer type instead of the SVR- name prefix:
... --type "Server" --commit
```
The assetid does not change: communications, relationships, map position, and
audit history carry over. Only the extension row is swapped (computers ->
networkdevices) and the asset type flipped; reclassified devices get the
`Server` networkdevicetype. Run the dry run, eyeball the list, then commit.
Re-running is safe (already-moved assets no longer match).
Expected magnitude (from the WJ dumps used in development - your fresh dumps will
differ slightly):
| entity | count |
|---|---|
| assets [*] | ~983 (computer ~663, machine ~76, network ~58, measuring-tool ~136, printer ~50) |
| locations | ~24 |
| employees | ~415 |
| installs | ~850 |
| primary IPs | ~461 |
| warranties | ~464 |
| notifications | ~261 |
| knowledge base | ~341 |
| relationships | ~93 |
| subnets | ~37 |
| USB devices / events | ~18 / ~232 |
[*] Counts taken AFTER `scripts/reclassify_servers_to_network.py --commit`.
Servers imported as computers are re-pointed to network devices, so the computer
count drops and network rises by the same amount versus a raw import.
> PLACEHOLDER - re-measure before publishing. The computer/network split shown
> in the assets row above still reflects a RAW import (pre-reclassify). Re-run
> the counts on the current prodscratch AFTER the reclassify step above and drop
> in the actual numbers; do not carry these development figures forward as if
> they already account for the reclassify.
The `verify` stage prints a source-vs-target row-count audit; the gaps are the
documented skips (inactive rows, duplicate machinenumbers, LocationOnly, the
9999 placeholder).
## 3. Verify the import
- [ ] Read the `verify` stage output - source vs target counts line up modulo
the documented skips.
- [ ] Create the admin: `flask seed admin --username ... --email ...` (password
printed once). Mark setup done so the app does not force the wizard:
set `setup_complete=true` in settings (or click through the wizard,
skipping the seed steps).
- [ ] UI spot-check (log in): Computers list paginates the full fleet; the Shop
Floor Map plots assets, color-coded by type (positions came from
mapleft/maptop); open a PC detail (installs), a printer (IP + share), an
application (installed-on list), a KB article; check the employee
directory; check a couple of asset relationships.
- [ ] Branding: upload the site logo + floor-plan blueprint under Settings, set
facility name (Settings drive these per `CONFIG.md`).
- [ ] Photos are deferred - employees show initials until a photo batch is run.
## 4. Parallel-run window
- Keep the classic app authoritative during the window. The pilot is read-mostly
for validation; do not dual-write.
- Have a few real users (IT + a floor lead) work the pilot and log gaps.
- Re-import is cheap: fix a loader mapping, drop + rebuild the pilot DB, re-run.
Nothing you do to the pilot touches classic.
- Point the **collector** (GE-Enforce fleet ingest) at the pilot in parallel to
confirm live PC check-ins land (see `COLLECTOR-INTEGRATION.md`), using a
scoped service token.
## 5. Cutover
When the window is clean:
1. Freeze classic writes (announce a short read-only window).
2. Take final fresh dumps; re-run the loader into a clean pilot DB so the
cutover data is current.
3. Verify counts + a fast UI spot-check.
4. Repoint the production hostname/DNS (or the reverse proxy) at the pilot.
5. Retire the classic app to read-only standby (do not delete - keep it as the
rollback for the agreed period).
## 6. Rollback
- Pre-cutover: trivially point back at classic (it never stopped being
authoritative).
- Post-cutover, within the standby window: repoint DNS/proxy back at classic;
investigate; re-cut when fixed. Because the loader is deterministic and the
classic DB is untouched, a re-run reproduces the flask DB exactly.
## 7. Post-cutover
- [ ] Backups on a schedule (`BACKUP-RESTORE.md`) - mysqldump + the `instance/`
dir (uploaded logos, floor plans, tokens).
- [ ] Run the employee-photo batch.
- [ ] GE-Enforce: publish manifests + cut the fleet over to the flask endpoints
when ready (`GE-ENFORCE-DEPLOY.md`) - independent of this pilot.
- [ ] Schedule the deferred data (occurrences, full communications fidelity)
only if a real need appears.

View File

@@ -48,7 +48,16 @@ from .plugins import plugin_manager
# the literal values of C:\Enrollment\display-type.txt - so a plugin holding its
# own copy of that map (geenforce did) can read core's instead of drifting from
# it. Additive names on the import surface, minor bump.
__contract_version__ = '0.18.0'
# 0.19.0: get_dashboard_widgets returns DATA AND SHAPE, not a component name.
# The old shape ('name' + 'component' + 'size') named a Vue component per
# widget, which cannot survive a lean build - a plugin's component may never be
# staged into the frontend bundle (ADR-013) - and five plugins were declaring
# widgets that pointed at components nobody had written. Core now owns the
# renderers and a card declares id / title / endpoint / render / severity /
# permission / empty / position. BREAKING for any plugin still using the old
# shape, which is why it is recorded here: the change itself shipped earlier
# without a bump, and a contract that changes silently is not a contract.
__contract_version__ = '0.19.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
@@ -134,6 +143,41 @@ def create_app(config_name: str = None) -> Flask:
identity = jwt_data["sub"]
return db.session.get(User, int(identity))
@app.after_request
def apply_security_headers(response):
"""Baseline response headers. The app shipped with none of these.
Deliberately the three that cost nothing and break nothing:
nosniff a response whose bytes and declared type disagree is
not re-typed by the browser into something executable.
This is the general form of the upload problem that
shopdb/utils/uploads.py addresses per-file.
frame-ancestors
same-origin only. The kiosks open routes directly
rather than framing them, so this costs the fleet
nothing and stops the UI being framed elsewhere and
clicked through. Sent as X-Frame-Options too, because
the display bays run browsers old enough to want it.
Referrer-Policy
an asset id or a hostname in a path is not handed to
whatever a user clicks through to.
NOT a full page CSP. This app serves an SPA with inline styles, so a
real script-src policy is a change worth making on its own with the
frontend in front of you - claiming one here by adding a permissive
header would be worse than having none.
"""
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
response.headers.setdefault('Referrer-Policy',
'strict-origin-when-cross-origin')
# setdefault: an upload response has already declared its own, stricter
# Content-Security-Policy, and this must not weaken it.
response.headers.setdefault('Content-Security-Policy',
"frame-ancestors 'self'")
return response
return app

View File

@@ -77,6 +77,11 @@ from shopdb.utils.apitoken_auth import (
service_token_authorized, authorized_service_token,
)
# Serving an uploaded file safely. A plugin that serves user-supplied bytes must
# not have to remember the headers that keep an SVG or an HTML file from running
# as script in this origin - see shopdb/utils/uploads.py.
from shopdb.utils.uploads import send_upload, harden_upload_response
# Import-mode helpers: preserve legacy timestamps during a bulk data import
from shopdb.utils.import_mode import (
apply_import_timestamps,
@@ -283,6 +288,9 @@ __all__ = [
'service_token_authorized',
'authorized_service_token',
'SupportTeam',
# Serving uploads
'send_upload',
'harden_upload_response',
# Import-mode helpers
'apply_import_timestamps',
'import_mode_active',

View File

@@ -738,6 +738,13 @@ def fix_controls_direction():
relationshiptypeid=controls.relationshiptypeid,
).first()
if duplicate:
# The correctly-directed row exists - but it may itself be soft
# deleted, and deletion here IS soft. Retiring this row without
# looking left the pair with NO live link, while the command
# reported a successful clean-up. Reactivate the one pointing the
# right way before retiring the one pointing the wrong way.
if not duplicate.isactive:
duplicate.isactive = True
row.isactive = False # flipped row already exists, retire this one
deactivated += 1
else:

View File

@@ -3,7 +3,7 @@
import glob
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
@@ -112,6 +112,7 @@ def _require_computer_models():
from shopdb.utils.authz import require_permission
from shopdb.utils.uploads import send_upload
applications_bp = Blueprint('applications', __name__)
@@ -550,7 +551,7 @@ def upload_application_image(app_id: int):
@applications_bp.route('/image/<path:filename>', methods=['GET'])
def serve_application_image(filename):
"""Serve an uploaded application image (public - lists and tiles read it)."""
return send_from_directory(_appimage_dir(), filename)
return send_upload(_appimage_dir(), filename)
@applications_bp.route('/<int:app_id>/image', methods=['DELETE'])
@@ -635,7 +636,7 @@ def serve_application_package(filename):
open URL would publish it to anything that can reach the site. Always sent
as an attachment so a browser saves it rather than trying to render it.
"""
return send_from_directory(_apppackage_dir(), filename, as_attachment=True)
return send_upload(_apppackage_dir(), filename, as_attachment=True)
@applications_bp.route('/<int:app_id>/package', methods=['DELETE'])

View File

@@ -768,14 +768,23 @@ def create_asset_relationship():
'An asset cannot be related to itself'
)
# Check for duplicate relationship
# Check for duplicate relationship.
#
# Deleting a relationship is SOFT (isactive = False, see
# delete_asset_relationship). The row therefore survives, and this lookup
# found it - so deleting a link and adding it back answered 409 "this
# relationship already exists" about a link the page no longer shows, with
# no way forward from the UI at all. The row cannot simply be inserted
# again either: assetrelationships is unique on (source, target, type).
#
# Reactivating IS the create, for an inactive row.
existing = AssetRelationship.query.filter_by(
sourceassetid=source_id,
targetassetid=target_id,
relationshiptypeid=type_id
).first()
if existing:
if existing and existing.isactive:
return error_response(
ErrorCodes.CONFLICT,
'This relationship already exists',
@@ -798,7 +807,11 @@ def create_asset_relationship():
targetassetid=source_id,
relationshiptypeid=type_id
).first()
if inverse is not None:
# isactive, for the same reason as the duplicate check above: a soft
# deleted inverse is a link somebody already removed, and blocking on it
# made "remove the existing one first" - the instruction in this very
# message - fail to unblock anything.
if inverse is not None and inverse.isactive:
return error_response(
ErrorCodes.CONFLICT,
f"The reverse of this relationship already exists "
@@ -808,14 +821,20 @@ def create_asset_relationship():
http_code=409
)
rel = AssetRelationship(
sourceassetid=source_id,
targetassetid=target_id,
relationshiptypeid=type_id,
notes=data.get('notes')
)
db.session.add(rel)
if existing is not None:
# The soft-deleted row from the duplicate check. Reuse it: the unique
# index on (source, target, type) means there is no second row to add.
rel = existing
rel.isactive = True
rel.notes = data.get('notes')
else:
rel = AssetRelationship(
sourceassetid=source_id,
targetassetid=target_id,
relationshiptypeid=type_id,
notes=data.get('notes')
)
db.session.add(rel)
apply_import_timestamps(rel, data)
db.session.flush()

View File

@@ -3,7 +3,7 @@
import glob
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
@@ -19,6 +19,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.uploads import send_upload
models_bp = Blueprint('models', __name__)
@@ -228,7 +229,7 @@ def upload_model_image(model_id: int):
@models_bp.route('/image/<path:filename>', methods=['GET'])
def serve_model_image(filename):
"""Serve an uploaded model image (public - asset detail pages read it)."""
return send_from_directory(_modelimage_dir(), filename)
return send_upload(_modelimage_dir(), filename)
@models_bp.route('/<int:model_id>/image', methods=['DELETE'])

View File

@@ -2,7 +2,7 @@
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from werkzeug.utils import secure_filename
@@ -11,6 +11,7 @@ from shopdb.core.models import Setting, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission, require_role
from shopdb.utils.uploads import send_upload
settings_bp = Blueprint('settings', __name__)
@@ -189,7 +190,7 @@ def upload_map_blueprint():
@settings_bp.route('/map-blueprint/<path:filename>', methods=['GET'])
def serve_map_blueprint(filename):
"""Serve an uploaded blueprint image (public - kiosks read it)."""
return send_from_directory(_map_dir(), filename)
return send_upload(_map_dir(), filename)
@settings_bp.route('/branding-logo', methods=['POST'])
@@ -234,7 +235,7 @@ def upload_branding_logo():
@settings_bp.route('/branding/<path:filename>', methods=['GET'])
def serve_branding_logo(filename):
"""Serve an uploaded branding logo (public - kiosks/print pages read it)."""
return send_from_directory(_branding_dir(), filename)
return send_upload(_branding_dir(), filename)
@settings_bp.route('', methods=['GET'])
@@ -520,10 +521,10 @@ def build_default_settings():
},
{
'key': 'pc_access_domain',
'value': 'device.geaerospace.net',
'value': 'device.geaerospace.net', # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
'valuetype': 'string',
'category': 'site',
'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.'
'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
},
{
'key': 'employeeid_pattern',
@@ -541,7 +542,7 @@ def build_default_settings():
},
{
'key': 'printer_hostname_template',
'value': 'Printer-{ip}.printer.geaerospace.net',
'value': 'Printer-{ip}.printer.geaerospace.net', # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
'valuetype': 'string',
'category': 'site',
'description': 'Template for a printer hostname built from its IP. {ip} is the dash-separated IP address.'

View File

@@ -50,10 +50,10 @@ def normalize_display_role(value):
return LEGACY_DISPLAY_ROLES.get(lowered)
# GE device naming: a PC's DNS name is 'F' + its BIOS serial under the device
# domain, e.g. FABC1234.device.geaerospace.net. The domain is a setting so other
# domain, e.g. FABC1234.device.geaerospace.net. The domain is a setting so other # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
# sites can point elsewhere; the collector already reports the serial, so the
# server derives the stable FQDN without the kiosk having to report it.
DEFAULT_DISPLAY_FQDN_DOMAIN = 'device.geaerospace.net'
DEFAULT_DISPLAY_FQDN_DOMAIN = 'device.geaerospace.net' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
def derive_display_fqdn(serialnumber):

View File

@@ -180,3 +180,15 @@ def run_migrations():
)
with context.begin_transaction():
context.run_migrations()
# SAME FIX AS migrations/env.py. MySQL DDL commits implicitly, which
# flushes everything queued before it - including the PREVIOUS
# migration's version stamp. The LAST migration of a run has no DDL
# after it, so its stamp is rolled back when the connection closes:
# the schema change survives, alembic_version stays one revision
# behind, and `flask plugin upgrade-all` exits 0 having silently
# re-run that migration - again on every deploy after.
#
# Core got this fix; the per-plugin chains run through THIS file and
# did not, so every plugin head has been re-running on MySQL. That is
# only invisible while every head happens to be idempotent.
connection.commit()

View File

@@ -205,15 +205,35 @@ class BasePlugin(ABC):
def get_dashboard_widgets(self) -> List[Dict]:
"""
Return dashboard widget definitions.
Return dashboard card definitions.
Each widget: {
'name': str,
'component': str, # Frontend component name
'endpoint': str, # API endpoint for data
'size': str, # 'small', 'medium', 'large'
'position': int # Order on dashboard
DATA AND SHAPE, not a component name. The card is drawn by one of core's
generic renderers; the plugin says what to show, where it comes from and
how to link it.
Contract 0.19.0 replaced a shape that named a Vue component per widget.
That could not survive a lean build - a plugin's component may never be
staged into the frontend bundle (ADR-013) - and in practice five plugins
declared widgets pointing at components nobody had written, which
rendered as nothing at all. It is the same correction ADR-010 already
made for asset panels.
Each card: {
'id': str, # stable, unique across plugins
'title': str, # heading
'endpoint': str, # API path the card fetches its own data from
'render': str, # core renderer: 'exceptions' | 'list' | 'stat'
'severity': str, # 'critical' | 'warning' | 'info' - orders cards
'permission': str, # card is hidden without it
'empty': str, # 'hide' when nothing to report, or empty text
'position': int, # order within a severity
'viewall': str, # optional route behind the card heading
'map': dict, # optional field mapping for the renderer,
# e.g. {'title': 'hostname', 'detail': 'entryname'}
}
`empty: 'hide'` is not cosmetic. A card that reports "nothing wrong"
every day teaches people to stop reading the page.
"""
return []

View File

@@ -5,8 +5,8 @@ purpose: the hook has to be registered before any CreateTable is compiled, and
every place that creates tables needs it.
WHY. Without it a CREATE TABLE inherits the SERVER's default charset. A MySQL box
defaulting to latin1 - common on older installs, and the West Jefferson 5.6
server is one - silently builds a latin1 schema that drifts from the utf8mb4
defaulting to latin1 - common on older installs, and the 5.6 server this was
ported from is one - silently builds a latin1 schema that drifts from the utf8mb4
production target. Nothing fails at create time; it surfaces later as mangled
characters, or as a join between a utf8mb4 and a latin1 column that cannot use an
index.

46
shopdb/utils/uploads.py Normal file
View File

@@ -0,0 +1,46 @@
"""Serving user-uploaded files without handing the browser a script.
Uploads reach these routes from people, and one of the accepted image types is
not inert: an SVG is an XML document that may carry <script>, and it is wanted
for floor-plan maps and branding, where vector is the right format. Loaded
through an <img> tag a script inside it never runs, so the tiles and maps in
this app were never the risk. Opening the file's own URL is - and the
application image route is public, so the URL is reachable without a session.
Rather than banning the format that has a real use, every uploaded file is
served with headers that make the document case inert:
Content-Security-Policy: default-src 'none'; sandbox
No script, no fetch, no plugin, and a unique opaque origin. This is what
stops an SVG (or an HTML file that talked its way past an allowlist)
executing in the site's origin and reading a token out of localStorage.
X-Content-Type-Options: nosniff
A file whose extension and bytes disagree is not re-typed by the browser
into something executable.
Both are cheap and apply to every upload route, so a new one added later starts
from the same place instead of repeating the reasoning.
"""
from flask import send_from_directory
# 'sandbox' with no allow-* tokens: opaque origin, scripts blocked, forms and
# popups blocked. default-src 'none' is belt and braces for a UA that ignores
# sandbox in a header.
UPLOAD_CSP = "default-src 'none'; sandbox"
def harden_upload_response(response):
"""Apply the inert-document headers to a response serving an upload."""
response.headers['Content-Security-Policy'] = UPLOAD_CSP
response.headers['X-Content-Type-Options'] = 'nosniff'
return response
def send_upload(directory, filename, **kwargs):
"""send_from_directory, with the upload headers applied.
Any keyword send_from_directory takes is passed through, so a route that
already sends as an attachment keeps doing so.
"""
return harden_upload_response(send_from_directory(directory, filename, **kwargs))

View File

@@ -0,0 +1,95 @@
"""Every route the app serves is described in docs/api-inventory.json.
The inventory is hand-maintained, and deliberately so: its value is the prose -
what each endpoint is FOR, what it accepts, a worked example - and none of that
can be derived from the code. An audit of all 372 documented operations found
zero phantom routes and zero wrong parameter names, so the maintenance itself is
in good order.
What it cannot do is notice a route somebody added. Twenty-two were serving
traffic undocumented, including an entire plugin's surface and every one of the
dashboard card endpoints - each of them added after its surface was written.
So the prose stays hand-written and the COVERAGE is enforced here. Adding an
endpoint now means describing it, in the same commit, which is when the author
still knows what it is for.
"""
import json
import re
from pathlib import Path
import pytest
from shopdb import create_app
REPO = Path(__file__).resolve().parents[1]
INVENTORY = REPO / 'docs' / 'api-inventory.json'
pytestmark = pytest.mark.skipif(
not INVENTORY.is_file(),
reason='docs/ is excluded from publication; nothing to check here')
# Flask's own machinery, not this product's API.
EXEMPT_ENDPOINTS = {'static'}
# Documented as a family rather than per-file: these serve the frontend bundle
# and its assets, not JSON.
EXEMPT_RULE_PREFIXES = ('/static', '/assets')
def normalise(rule):
"""Flask `<int:id>` and inventory `<id>` describe the same path.
Converters are an implementation detail of the route, and the inventory was
written without them. Comparing on the converter would report a difference
that is not one.
"""
return re.sub(r'<(?:[^:>]+:)?([^>]+)>', r'<\1>', rule)
def served_operations():
app = create_app('testing')
served = set()
for rule in app.url_map.iter_rules():
if rule.endpoint in EXEMPT_ENDPOINTS:
continue
if any(rule.rule.startswith(prefix) for prefix in EXEMPT_RULE_PREFIXES):
continue
if not rule.rule.startswith('/api'):
continue
for method in sorted((rule.methods or set()) - {'HEAD', 'OPTIONS'}):
served.add((method, normalise(rule.rule)))
return served
def documented_operations():
documented = set()
for surface in json.loads(INVENTORY.read_text()):
for entry in surface.get('endpoints', []):
path = normalise(entry.get('path') or '')
# The inventory writes multi-verb routes as 'PUT|PATCH'. Splitting
# on comma and slash alone left those undocumented in this check
# AND dropped from the generated spec entirely.
for method in re.split(r'[/,|]', entry.get('method') or 'GET'):
method = method.strip().upper()
if method:
documented.add((method, path))
return documented
def test_every_served_route_is_documented():
missing = sorted(served_operations() - documented_operations())
assert not missing, (
'%d route(s) serve traffic with no entry in docs/api-inventory.json. '
'Describe them there (purpose, auth, params, example) and regenerate '
'the spec with scripts/gen_openapi.py:\n %s'
% (len(missing), '\n '.join('%s %s' % row for row in missing)))
def test_nothing_documented_has_been_removed():
"""A documented route that no longer exists sends a reader to a 404."""
stale = sorted(documented_operations() - served_operations())
assert not stale, (
'%d documented route(s) are no longer served:\n %s'
% (len(stale), '\n '.join('%s %s' % row for row in stale)))

View File

@@ -4,11 +4,11 @@ from shopdb.utils.clientip import strip_port, client_ip
def test_strip_ipv4_port():
assert strip_port('10.134.48.20:52344') == '10.134.48.20'
assert strip_port('192.0.2.20:52344') == '192.0.2.20'
def test_ipv4_without_port_untouched():
assert strip_port('10.134.48.20') == '10.134.48.20'
assert strip_port('192.0.2.20') == '192.0.2.20'
def test_bare_ipv6_untouched():

View File

@@ -0,0 +1,76 @@
"""Deleting a relationship is soft, and everything that reads them must know.
Rows are retired with isactive = False, never removed, and assetrelationships is
unique on (source, target, type). Anything that looks a relationship up without
filtering on isactive therefore sees links the UI does not show - and refuses to
create one it cannot create any other way, or retires the last live row believing
another one covers it.
"""
import pytest
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
@pytest.fixture
def rig(db):
if not AssetType.query.filter_by(assettype='computer').first():
db.session.add(AssetType(assettype='computer'))
if not AssetType.query.filter_by(assettype='machine').first():
db.session.add(AssetType(assettype='machine'))
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
if not controls:
controls = RelationshipType(relationshiptype='controls', isdirectional=True)
db.session.add(controls)
controls.isdirectional = True
db.session.commit()
computer = AssetType.query.filter_by(assettype='computer').first()
machine = AssetType.query.filter_by(assettype='machine').first()
pc = Asset(assetnumber='PCSOFT01', assettypeid=computer.assettypeid)
bay = Asset(assetnumber='BAYSOFT01', assettypeid=machine.assettypeid)
db.session.add_all([pc, bay])
db.session.commit()
return {'pc': pc, 'bay': bay, 'controls': controls}
def _create(client, headers, source, target, typeid):
return client.post('/api/assets/relationships',
json={'sourceassetid': source, 'targetassetid': target,
'relationshiptypeid': typeid},
headers=headers)
def test_a_deleted_relationship_can_be_created_again(client, db, rig, auth_headers):
"""Soft delete then re-add answered 409 about a link nothing displayed."""
created = _create(client, auth_headers, rig['pc'].assetid, rig['bay'].assetid,
rig['controls'].relationshiptypeid)
assert created.status_code == 201, created.get_json()
relid = created.get_json()['data']['relationshipid']
assert client.delete('/api/assets/relationships/%d' % relid,
headers=auth_headers).status_code == 200
again = _create(client, auth_headers, rig['pc'].assetid, rig['bay'].assetid,
rig['controls'].relationshiptypeid)
assert again.status_code in (200, 201), again.get_json()
row = db.session.get(AssetRelationship, relid)
assert row.isactive is True
assert AssetRelationship.query.filter_by(
sourceassetid=rig['pc'].assetid,
targetassetid=rig['bay'].assetid).count() == 1
def test_a_deleted_inverse_does_not_block_the_right_direction(
client, db, rig, auth_headers):
"""'Remove the existing one first' is the advice the guard itself gives."""
wrong = _create(client, auth_headers, rig['bay'].assetid, rig['pc'].assetid,
rig['controls'].relationshiptypeid)
assert wrong.status_code == 201
relid = wrong.get_json()['data']['relationshipid']
assert client.delete('/api/assets/relationships/%d' % relid,
headers=auth_headers).status_code == 200
right = _create(client, auth_headers, rig['pc'].assetid, rig['bay'].assetid,
rig['controls'].relationshiptypeid)
assert right.status_code in (200, 201), right.get_json()

View File

@@ -0,0 +1,57 @@
"""Responses carry the baseline headers, and uploads carry the strict ones.
The application image route is PUBLIC and one accepted image type is an XML
document that can carry a script. Loaded through an <img> tag that script never
runs, so the tiles were never the risk; opening the file's own URL is. These
tests pin the headers that make that case inert, because the failure is silent -
nothing about the page looks different when they are missing.
"""
import io
import pytest
from shopdb.utils.uploads import UPLOAD_CSP
def test_every_response_carries_the_baseline_headers(client):
resp = client.get('/api/health')
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
assert resp.headers.get('X-Frame-Options') == 'SAMEORIGIN'
assert resp.headers.get('Referrer-Policy') == 'strict-origin-when-cross-origin'
def test_an_error_response_carries_them_too(client):
"""after_request runs for error handlers as well, which is where a response
built by a different code path would otherwise slip through."""
resp = client.get('/api/no-such-route')
assert resp.status_code == 404
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
def test_a_served_upload_is_sandboxed(client, app, tmp_path):
"""The uploaded file itself gets a policy that blocks script execution."""
from shopdb.core.api.applications import _appimage_dir
with app.app_context():
directory = _appimage_dir()
svg = ('<svg xmlns="http://www.w3.org/2000/svg">'
'<script>window.top.location="http://evil"</script></svg>')
path = directory / 'probe-headers.svg' if hasattr(directory, '__truediv__') \
else None
if path is None:
import os
path = os.path.join(directory, 'probe-headers.svg')
with open(path, 'w') as handle:
handle.write(svg)
else:
path.write_text(svg)
try:
resp = client.get('/api/applications/image/probe-headers.svg')
assert resp.status_code == 200
assert resp.headers.get('Content-Security-Policy') == UPLOAD_CSP
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
finally:
import os
os.remove(path)

View File

@@ -49,6 +49,29 @@ FORBIDDEN = [
('root' + 'password', 'contains a database root password'),
(r'\b' + 'cla' + 'ude' + r'\b', 'names an LLM assistant'),
(r'\b' + 'anthro' + 'pic' + r'\b', 'names an LLM vendor'),
# ADR-015. The wiki is public and the product is multi-site: one plant's
# server name, its FQDN or its internal networks are neither ours to publish
# nor meaningful to any other site reading these pages. Assembled from
# fragments for the same reason as the terms above.
('tsg' + 'wp00525', 'names a production server'),
(r'\b' + r'172\.16\.' + r'\d', 'names an internal network'),
# A tool-call fragment reached the end of an ACCEPTED ADR and published to
# the wiki. It says nothing to a reader and everything about how the
# document was produced, which is not what a decision record is for.
('</' + 'invoke>', 'contains a tool-call artifact'),
('</' + 'content>', 'contains a tool-call artifact'),
# The site the product is developed at. Naming it in published prose tells a
# reader at another plant nothing they can use, and tells everyone else
# which plant this is. Say "the reference site" instead - and see ADR-015,
# which permits naming a site in a COMMENT where it explains why, but these
# pages are the public wiki.
('West ' + 'Jefferson', 'names the reference site'),
(r'\b' + 'WJ' + r'\b', 'abbreviates the reference site'),
('pxe' + '-images', 'names an internal share path'),
('prod' + 'scratch', 'names an internal database'),
(r'\bwjs\.' + r'geaerospace\.net\b', 'names a site FQDN'),
(r'10\.134\.48\.', 'names an internal network'),
(r'10\.48\.249\.', 'names an internal network'),
]
# Generated API surface. Not prose, not hand-edited, and regenerated from the

115
tests/test_docs_versions.py Normal file
View File

@@ -0,0 +1,115 @@
"""No document types a version or a count that the code already knows.
Nine documents carried a hand-typed contract version. Every one was stale, and
one of them was load-bearing: PLUGIN-EXTERNAL-REPO.md told an external author to
pin `>=0.13.0,<0.14.0` against a contract at 0.19.0, so a plugin built by
following the guide is refused by the loader at startup. The plugin count was
wrong in six more.
A number that is copied is a number that goes stale, and prose gives no signal
about which lines are still true. the PROJECT-MAP wiki page is generated for exactly
this, so a document points at it instead of restating it.
This test is the rule. Where a version genuinely belongs in prose - the contract
history table, a changelog entry, an ADR recording what was decided when - it is
recording the PAST, which does not go stale.
"""
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
DOCS = REPO / 'docs'
pytestmark = pytest.mark.skipif(
not DOCS.is_dir(),
reason='no docs/ in this checkout - it is excluded from publication')
# The map is generated and the changelog records history; both are allowed to
# carry literal versions. CONTRACT-STABILITY's table is a history of what each
# contract version DID, which is the past and stays true.
EXEMPT = {'PROJECT-MAP.md', 'CONTRACT-STABILITY.md'}
ASSIGNMENT = re.compile(r"__(?:contract_)?version__\s*=\s*['\"]([0-9]+\.[0-9]+\.[0-9]+)['\"]")
NARRATED = re.compile(
r"__(?:contract_)?version__[^\n]{0,60}?\b([0-9]+\.[0-9]+\.[0-9]+)\b"
r"|\b(?:contract|version) is (?:at|currently)\s+\*{0,2}([0-9]+\.[0-9]+\.[0-9]+)")
PLUGIN_COUNT = re.compile(r'\b(\d+)\s+bundled plugins\b', re.I)
def documentation_files():
return sorted(p for p in DOCS.rglob('*.md') if p.name not in EXEMPT)
def current_versions():
text = (REPO / 'shopdb' / '__init__.py').read_text()
return {
name: re.search(r"^%s\s*=\s*['\"]([^'\"]+)['\"]" % name, text, re.M).group(1)
for name in ('__version__', '__contract_version__')
}
def bundled_plugin_count():
return len(list((REPO / 'plugins').glob('*/manifest.json')))
def test_no_document_declares_a_version_literal():
"""`__contract_version__ = '0.13.0'` in prose is a promise the code breaks."""
offenders = []
for path in documentation_files():
for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1):
if ASSIGNMENT.search(line):
offenders.append('%s:%d %s' % (path.relative_to(REPO), number, line.strip()[:90]))
assert not offenders, (
'These documents declare a version literal. Point at the PROJECT-MAP wiki page, '
'which is generated:\n ' + '\n '.join(offenders))
def test_a_narrated_version_matches_the_code():
"""Prose that states the CURRENT version has to be right."""
versions = set(current_versions().values())
offenders = []
for path in documentation_files():
# An ADR states the version a decision was taken AT. That is a record of
# the past, not a claim about today, and rewriting it would falsify the
# record this project keeps ADRs for.
if path.parent.name == 'adr':
continue
for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1):
for match in NARRATED.finditer(line):
found = match.group(1) or match.group(2)
if found and found not in versions:
offenders.append('%s:%d says %s %s'
% (path.relative_to(REPO), number, found, line.strip()[:70]))
assert not offenders, (
'These lines state a current version that no longer matches '
'shopdb/__init__.py:\n ' + '\n '.join(offenders))
def test_a_stated_plugin_count_matches_the_tree():
actual = bundled_plugin_count()
offenders = []
for path in documentation_files():
for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1):
for match in PLUGIN_COUNT.finditer(line):
if int(match.group(1)) != actual:
offenders.append('%s:%d says %s, tree has %d'
% (path.relative_to(REPO), number, match.group(1), actual))
assert not offenders, (
'These documents count plugins by hand. The count is in '
'the PROJECT-MAP wiki page:\n ' + '\n '.join(offenders))
def test_the_map_itself_is_current():
"""The pointer target has to be right, or every document pointing at it is
wrong at one remove."""
mapfile = DOCS / 'PROJECT-MAP.md'
assert mapfile.is_file(), 'the PROJECT-MAP wiki page is missing; run scripts/gen_project_map.py'
text = mapfile.read_text()
for name, version in current_versions().items():
assert version in text, (
'%s is %s in the code but the generated map does not carry it. '
'Run: venv/bin/python scripts/gen_project_map.py' % (name, version))
assert str(bundled_plugin_count()) in text

123
tests/test_openapi_spec.py Normal file
View File

@@ -0,0 +1,123 @@
"""The generated spec has to be usable by a machine, not just readable.
docs/openapi.json is the only machine-readable description of this API, and it
is what the MCP server builds its tools from. It was published with no
`parameters` and no `requestBody` on any operation - invalid OpenAPI 3.1, and
worse in practice: a tool built from an operation with no parameters has nowhere
to put the id its caller supplied, so every argument was dropped in silence. A
call for one asset returned the list.
These tests are the floor. They check the spec's shape, not its prose.
"""
import json
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
SPEC = REPO / 'docs' / 'openapi.json'
pytestmark = pytest.mark.skipif(
not SPEC.is_file(),
reason='no generated spec in this checkout')
@pytest.fixture(scope='module')
def spec():
return json.loads(SPEC.read_text())
def operations(spec):
for path, item in spec['paths'].items():
for verb, operation in item.items():
yield path, verb, operation
def test_every_templated_path_declares_its_parameters(spec):
"""`/api/assets/{id}` with no parameters is not a description of anything."""
offenders = [
'%s %s' % (verb.upper(), path)
for path, verb, operation in operations(spec)
if '{' in path and not operation.get('parameters')
]
assert not offenders, (
'These operations have a templated path and no parameters, so a '
'generated client cannot fill it in:\n ' + '\n '.join(offenders[:15]))
def test_declared_parameters_match_the_template(spec):
"""A parameter that is not in the path, or a placeholder with no parameter,
is the same defect wearing different clothes."""
offenders = []
for path, verb, operation in operations(spec):
intemplate = set(re.findall(r'\{([^}]+)\}', path))
declared = {p['name'] for p in operation.get('parameters', [])
if p.get('in') == 'path'}
if intemplate != declared:
offenders.append('%s %s: template %s, declared %s'
% (verb.upper(), path, sorted(intemplate), sorted(declared)))
assert not offenders, '\n '.join(offenders[:15])
def test_write_verbs_can_carry_a_body(spec):
offenders = [
'%s %s' % (verb.upper(), path)
for path, verb, operation in operations(spec)
if verb in ('post', 'put') and 'requestBody' not in operation
]
assert not offenders, (
'These write operations declare no request body, so a client built '
'from this spec cannot send one:\n ' + '\n '.join(offenders[:15]))
def test_optional_auth_is_expressed_as_optional(spec):
"""An endpoint that works logged out must not be published as requiring a
token - that is a lie to every reader, and it is the majority case here."""
optional = [operation for _, _, operation in operations(spec)
if any(item == {} for item in operation.get('security') or [])]
assert len(optional) > 50, (
'The inventory documents over a hundred jwt-optional endpoints; the '
'spec should express them with an empty security requirement '
'alongside the scheme, not as bearer-required.')
def test_an_endpoint_that_needs_no_token_cannot_fail_with_401(spec):
offenders = [
'%s %s' % (verb.upper(), path)
for path, verb, operation in operations(spec)
if not operation.get('security') and '401' in operation.get('responses', {})
]
assert not offenders, '\n '.join(offenders[:15])
def test_every_operation_documents_failure(spec):
offenders = [
'%s %s' % (verb.upper(), path)
for path, verb, operation in operations(spec)
if 'default' not in operation.get('responses', {})
]
assert not offenders, (
'These operations document only success:\n ' + '\n '.join(offenders[:15]))
def test_the_envelopes_are_defined_and_referenced(spec):
schemas = spec.get('components', {}).get('schemas', {})
assert 'SuccessEnvelope' in schemas and 'ErrorEnvelope' in schemas
# The error shape is the one people get wrong: nested under data.
error = schemas['ErrorEnvelope']['properties']['data']['properties']['error']
assert set(error['required']) == {'code', 'message'}
def test_summaries_are_not_cut_mid_word(spec):
"""A truncated summary is what a tool picker shows as the whole description
of a call."""
offenders = []
for path, verb, operation in operations(spec):
summary = operation.get('summary', '')
if summary.endswith('...'):
body = summary[:-3]
if body and not body[-1].isalnum() and body[-1] not in ')]"\'':
offenders.append('%s %s: %r' % (verb.upper(), path, summary[-40:]))
assert not offenders, '\n '.join(offenders[:15])

View File

@@ -52,7 +52,7 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
# baseline.
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0003subtype'
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
# printers adds the printersupplyalerts crossing-state table on top of its anchor.
EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts'
# machines (renamed from equipment) keeps its original anchor id and adds the
@@ -64,7 +64,7 @@ EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
# network links a device to a catalog model, which is where its photo comes
# from - machines, PCs and printers already had that link.
EXPECTED_HEAD_REVISION['network'] = 'network0002model'
EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
# the per-transaction revision column.
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'

View File

@@ -206,12 +206,19 @@ def test_partmarker_kind_is_share_backed_and_not_renderable():
assert kind.parse(b'anything') is None
def test_partmarker_sharedir_is_under_the_sfld_backups_root():
path = registry.getkind('partmarker').sharedir('lathe', '3204')
assert path.startswith(registry.DEFAULTSHAREROOT)
def test_partmarker_sharedir_is_under_the_configured_backups_root():
root = r'\\fileserver\shopfloor\backups'
path = registry.getkind('partmarker').sharedir('lathe', '3204', shareroot=root)
assert path.startswith(root)
assert path.endswith(r'lathe\3204\partmarker')
def test_sharedir_is_empty_when_no_root_is_configured():
"""No default root ships (ADR-015), so an unconfigured site gets nothing
rather than a path composed from another site's file server."""
assert registry.getkind('partmarker').sharedir('lathe', '3204') == ''
def test_getkind_is_case_insensitive_and_returns_none_when_unknown():
assert registry.getkind('NTLARS') is not None
assert registry.getkind('nosuchkind') is None
@@ -981,3 +988,21 @@ def test_the_panel_shows_verified_beside_captured(bk_app, bk_plugin):
listpanel = next(p for p in panels if p.get('render') == 'list')
labels = [m['label'] for m in listpanel['map']['meta']]
assert 'Captured' in labels and 'Verified' in labels
def test_retention_reads_the_key_the_settings_page_writes(app, db):
"""The plugin helper namespaces to `plugin.backups.<key>`; the declared key
is bare. Reading through the helper meant retention was configurable and
inert - it always fell back to 0, which means keep everything."""
from shopdb.core.models import Setting
from plugins.backups.plugin import BackupsPlugin
declared = {entry['key'] for entry in BackupsPlugin().get_settings_defaults()}
assert 'backups_retentioncount' in declared
Setting.set('backups_retentioncount', 5)
db.session.commit()
assert int(Setting.get('backups_retentioncount', 0)) == 5
# The helper cannot see it, which is the whole defect: it looks under
# plugin.backups.backups_retentioncount, which nothing writes.
assert int(BackupsPlugin().get_setting('backups_retentioncount', 0) or 0) == 0

View File

@@ -0,0 +1,232 @@
"""The collector ADOPTS an existing instrument instead of minting a twin.
Minting derived the asset number from the HOSTNAME, so a permanent instrument
inherited the identity of whichever PC drove it that week. And because the
collector keyed idempotency on its OWN label, it could not see a tool it had not
created - on prod that left 43 legacy MT-#### tools shadowed by minted
<HOST>-CMM twins, three records deep for some CMMs.
"""
import pytest
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
from plugins.measuringtools.models import MeasuringTool
KEY = 'collector-adoption-key'
@pytest.fixture
def collector_key(app):
old = app.config.get('COLLECTOR_API_KEY')
app.config['COLLECTOR_API_KEY'] = KEY
yield KEY
app.config['COLLECTOR_API_KEY'] = old
@pytest.fixture
def rig(db):
"""Asset types, a controls relationship type, and nothing else."""
for name in ('computer', 'measuring_tool'):
if not AssetType.query.filter_by(assettype=name).first():
db.session.add(AssetType(assettype=name))
if not RelationshipType.query.filter_by(relationshiptype='controls').first():
db.session.add(RelationshipType(relationshiptype='controls'))
db.session.commit()
def _tool(db, assetnumber):
"""A measuring tool asset the collector did NOT create."""
assettype = AssetType.query.filter_by(assettype='measuring_tool').first()
asset = Asset(assetnumber=assetnumber, assettypeid=assettype.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(MeasuringTool(assetid=asset.assetid))
db.session.commit()
return asset
def _report(client, key, hostname, **extra):
payload = {'hostname': hostname, 'pctype': 'gea-shopfloor-cmm'}
payload.update(extra)
return client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': key})
def _tools_controlled(hostname):
pc = Asset.query.filter_by(assetnumber=hostname).first()
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
toolids = {r.assetid for r in MeasuringTool.query.with_entities(MeasuringTool.assetid)}
rows = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pc.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.isactive.is_(True)).all()
return [Asset.query.get(r.targetassetid).assetnumber
for r in rows if r.targetassetid in toolids]
def test_a_named_instrument_is_adopted_not_minted(client, db, rig, collector_key):
"""measuringtool-id.txt names the instrument; nothing new is created."""
_tool(db, 'MT-6227')
assert _report(client, collector_key, 'FB7FB1V3',
measuringtoolid='MT-6227').status_code in (200, 201)
assert _tools_controlled('FB7FB1V3') == ['MT-6227']
assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None
def test_a_tool_already_controlled_is_adopted(client, db, rig, collector_key):
"""The legacy MT-#### case: linked by the import, invisible to the collector.
The link must pre-date the first report - that is the real sequence, and it
is the whole point. Reporting first would mint the twin before adoption ever
had a chance, which is exactly the bug.
"""
from plugins.computers.models import Computer
tool = _tool(db, 'MT-6224')
computertype = AssetType.query.filter_by(assettype='computer').first()
pc = Asset(assetnumber='FJPX1GT3', assettypeid=computertype.assettypeid)
db.session.add(pc)
db.session.flush()
db.session.add(Computer(assetid=pc.assetid, hostname='FJPX1GT3'))
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
db.session.add(AssetRelationship(
sourceassetid=pc.assetid, targetassetid=tool.assetid,
relationshiptypeid=controls.relationshiptypeid, label=None))
db.session.commit()
assert _report(client, collector_key, 'FJPX1GT3').status_code in (200, 201)
assert _tools_controlled('FJPX1GT3') == ['MT-6224']
assert Asset.query.filter_by(assetnumber='FJPX1GT3-CMM').first() is None
def test_the_reported_bay_id_is_adopted(client, db, rig, collector_key):
"""The CMM case: cmmid.txt already reports CMM4, which is a real asset."""
_tool(db, 'CMM4')
assert _report(client, collector_key, 'FB7FB1V3',
machinenumber='CMM4').status_code in (200, 201)
assert 'CMM4' in _tools_controlled('FB7FB1V3')
assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None
def test_minting_still_happens_when_nothing_exists(client, db, rig, collector_key):
"""A genuinely new instrument has no identity to adopt - last resort."""
assert _report(client, collector_key, 'FNEWPC01').status_code in (200, 201)
assert _tools_controlled('FNEWPC01') == ['FNEWPC01-CMM']
def test_a_named_instrument_that_does_not_exist_warns_and_mints_nothing_wrong(
client, db, rig, collector_key):
"""A typo must not mint a phantom instrument nobody can account for."""
resp = _report(client, collector_key, 'FTYPO001', measuringtoolid='MT-NOPE')
assert resp.status_code in (200, 201)
body = resp.get_json()['data']
assert any('MT-NOPE' in w for w in body.get('warnings', []))
def test_adoption_is_idempotent(client, db, rig, collector_key):
_tool(db, 'MT-6331')
for _ in range(3):
_report(client, collector_key, 'F9LQSDB4', measuringtoolid='MT-6331')
assert _tools_controlled('F9LQSDB4') == ['MT-6331']
def test_a_named_instrument_supersedes_a_minted_twin(
client, db, rig, collector_key):
"""The migration case: the twin already exists when the file names the real
one.
This is the state prod is actually in - 43 minted twins already linked - so
adoption that only works on a clean PC fixes nothing. The twin's link has to
be archived, or the PC reads as controlling two instruments.
"""
_tool(db, 'MT-6227')
_report(client, collector_key, 'FB7FB1V3')
assert _tools_controlled('FB7FB1V3') == ['FB7FB1V3-CMM']
_report(client, collector_key, 'FB7FB1V3', measuringtoolid='MT-6227')
assert _tools_controlled('FB7FB1V3') == ['MT-6227']
def test_a_named_id_that_is_not_a_measuring_tool_is_refused(
client, db, rig, collector_key):
"""A machine number in measuringtool-id.txt must not link the machine.
The name resolving is not enough: linked under the measuring-tool label it
would read as an instrument everywhere downstream, on a row the machine sync
also owns.
"""
machinetype = AssetType.query.filter_by(assettype='machine').first()
if machinetype is None:
machinetype = AssetType(assettype='machine')
db.session.add(machinetype)
db.session.flush()
db.session.add(Asset(assetnumber='2335', assettypeid=machinetype.assettypeid))
db.session.commit()
resp = _report(client, collector_key, 'FTYPO777', measuringtoolid='2335')
assert resp.status_code in (200, 201)
body = resp.get_json()['data']
assert any('2335' in w for w in body.get('warnings', []))
assert '2335' not in _all_controlled('FTYPO777')
def test_a_cmm_reporting_its_bay_survives_a_second_cycle(
client, db, rig, collector_key):
"""The instrument IS the reported bay, so both syncs want the same triple.
Adoption used to relabel the machine sync's row. The machine sync finds its
rows BY that label, so cycle 2 built a second row for the same
(pc, tool, controls) triple and MySQL rejected it on the unique constraint:
200, then 500 on every report after. The machine link must survive as a
machine link.
"""
_tool(db, 'CMM4')
for _ in range(3):
resp = _report(client, collector_key, 'FB7FB1V3', machinenumber='CMM4')
assert resp.status_code in (200, 201)
assert _all_controlled('FB7FB1V3') == ['CMM4']
assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None
pc = Asset.query.filter_by(assetnumber='FB7FB1V3').first()
tool = Asset.query.filter_by(assetnumber='CMM4').first()
rows = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, targetassetid=tool.assetid).all()
assert len(rows) == 1
assert rows[0].label == 'collector:machine'
def test_a_reported_identifier_is_not_a_wildcard(client, db, rig, collector_key):
"""`_` and `%` in a reported identifier must match themselves, nothing else.
These values come from a text file on a shopfloor PC. Resolved with ilike,
'MT-600_' matched MT-6001 and a bare '%' matched whatever active asset came
first - an identifier resolving to an asset it does not name, then linked to
as though it did.
"""
_tool(db, 'MT-6001')
resp = _report(client, collector_key, 'FWILD001', measuringtoolid='MT-600_')
assert resp.status_code in (200, 201)
assert 'MT-6001' not in _tools_controlled('FWILD001')
resp = _report(client, collector_key, 'FWILD002', measuringtoolid='%')
assert resp.status_code in (200, 201)
assert 'MT-6001' not in _tools_controlled('FWILD002')
def _all_controlled(hostname):
"""Every active controls target, tool or not - the refusal cases need to see
a link the tool-only helper would filter out."""
pc = Asset.query.filter_by(assetnumber=hostname).first()
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
rows = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pc.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.isactive.is_(True)).all()
targets = [r.targetassetid for r in rows]
if not targets:
return []
return sorted(a.assetnumber for a in
Asset.query.filter(Asset.assetid.in_(targets)).all())

View File

@@ -0,0 +1,97 @@
"""A part-marker PC keeps reporting after the first cycle.
Both links a part-marker PC produces are rows in assetrelationships, which is
unique on (source, target, type). Both were looked up by LABEL, so a row made by
hand or by the legacy import was invisible and the insert violated the
constraint; and the minted marker's asset number is derived from the PC, so it
could already be taken. Either way the PC reported 200 on the cycle that minted
the marker and 500 on every cycle after - the same shape as the CMM adoption bug
in test_collector_measuringtool_adoption.py.
"""
import pytest
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
KEY = 'partmarker-links-key'
@pytest.fixture
def collector_key(app):
old = app.config.get('COLLECTOR_API_KEY')
app.config['COLLECTOR_API_KEY'] = KEY
yield KEY
app.config['COLLECTOR_API_KEY'] = old
@pytest.fixture
def rig(db):
for name in ('computer', 'machine'):
if not AssetType.query.filter_by(assettype=name).first():
db.session.add(AssetType(assettype=name))
for name in ('controls', 'partof'):
if not RelationshipType.query.filter_by(relationshiptype=name).first():
db.session.add(RelationshipType(relationshiptype=name))
db.session.commit()
def _report(client, key, hostname, **extra):
payload = {'hostname': hostname, 'pctype': 'gea-shopfloor-partmarker'}
payload.update(extra)
return client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': key})
def _operation(db, assetnumber):
machinetype = AssetType.query.filter_by(assettype='machine').first()
asset = Asset(assetnumber=assetnumber, assettypeid=machinetype.assettypeid)
db.session.add(asset)
db.session.commit()
return asset
def test_repeat_cycles_do_not_500(client, db, rig, collector_key):
_operation(db, '0613')
codes = [_report(client, collector_key, 'FMARK001', machinenumber='0613').status_code
for _ in range(3)]
assert codes == [codes[0]] * 3, codes
assert codes[0] in (200, 201), codes
def test_a_partof_row_under_another_label_is_not_duplicated(
client, db, rig, collector_key):
"""A partof row made by hand or by the legacy import carries no label."""
operation = _operation(db, '0615')
# First cycle mints the marker and links it.
assert _report(client, collector_key, 'FMARK002',
machinenumber='0615').status_code in (200, 201)
marker = Asset.query.filter_by(assetnumber='FMARK002-PARTMARKER').first()
assert marker is not None
partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
row = AssetRelationship.query.filter_by(
sourceassetid=marker.assetid, targetassetid=operation.assetid,
relationshiptypeid=partof.relationshiptypeid).first()
assert row is not None
# Somebody relabels it (or it came from the import unlabelled).
row.label = None
db.session.commit()
resp = _report(client, collector_key, 'FMARK002', machinenumber='0615')
assert resp.status_code in (200, 201), resp.status_code
rows = AssetRelationship.query.filter_by(
sourceassetid=marker.assetid, targetassetid=operation.assetid,
relationshiptypeid=partof.relationshiptypeid).all()
assert len(rows) == 1
def test_a_taken_marker_asset_number_does_not_500(client, db, rig, collector_key):
"""The minted number is derived from the PC, so it can already exist."""
machinetype = AssetType.query.filter_by(assettype='machine').first()
db.session.add(Asset(assetnumber='FMARK003-PARTMARKER',
assettypeid=machinetype.assettypeid))
db.session.commit()
_operation(db, '0617')
resp = _report(client, collector_key, 'FMARK003', machinenumber='0617')
assert resp.status_code in (200, 201), resp.status_code

View File

@@ -125,7 +125,7 @@ def _set_allowlist(app, value):
def test_ip_allowlist_allows_without_token(client, db, app):
# An allowlisted caller reaches the manifest with NO token (vault trust).
_seed_and_publish(app)
_set_allowlist(app, '127.0.0.0/8, 10.134.48.0/23') # test client is 127.0.0.1
_set_allowlist(app, '127.0.0.0/8, 192.0.2.0/24') # test client is 127.0.0.1
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
assert resp.status_code == 200, resp.data
assert b'Alpha' in resp.data
@@ -152,9 +152,9 @@ def test_spoofed_forwarded_for_does_not_bypass_allowlist(client, db, app):
# whose real IP (127.0.0.1) is NOT allowlisted must NOT gain token-less access
# by forging X-Forwarded-For to an allowlisted address.
_seed_and_publish(app)
_set_allowlist(app, '10.134.48.0/23') # test client 127.0.0.1 is NOT in it
_set_allowlist(app, '192.0.2.0/24') # test client 127.0.0.1 is NOT in it
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-Forwarded-For': '10.134.48.10'})
headers={'X-Forwarded-For': '192.0.2.10'})
assert resp.status_code == 401, 'spoofed X-Forwarded-For bypassed the allowlist'

View File

@@ -263,3 +263,42 @@ def test_reported_subtype_keeps_an_unknown_value_verbatim(db):
stored = ManifestEnforcementReport.query.filter_by(
hostname='WJKIOSKODD', iscurrent=True).one()
assert stored.subtype == 'Lobbby'
# -- what the gate compares against ------------------------------------------
#
# The gate used manifestversion as the library floor. That is the manifest's own
# 'Version' field: for a share-imported manifest it happens to share the
# library's numbering, but a code-authored scope sets it freely -
# seed_display_scope writes '2.0' - so the fleet was compared against a number
# that says nothing about library features, and the gate passed on the very
# scope that most needed it.
def test_a_declared_minlibversion_is_what_the_fleet_is_judged_against(db):
scope = _scope(manifestversion='2.0')
scope.minlibversion = '2.6'
db.session.flush()
assert service.required_libversion(scope) == '2.6'
_report('KIOSK01', '2.4')
with pytest.raises(service.LibVersionTooOldError):
service.publish_scope('gea-shopfloor-cmm', 'runtime')
def test_the_floor_is_derived_from_the_features_the_manifest_uses(db):
"""A scope written before minlibversion existed is still gated on what it
contains: a version-gated entry needs lib 2.6, whatever its own Version
field claims."""
from plugins.geenforce.models import ManifestEntry
scope = _scope(manifestversion='2.0')
scope.entries.append(ManifestEntry(
name='PC-DMIS 2023.2', entrytype='MSI', sortorder=0,
cmmversion='2023'))
db.session.flush()
assert service.required_libversion(scope) == '2.6'
def test_a_scope_using_nothing_special_falls_back_to_its_own_version(db):
scope = _scope(manifestversion='2.4')
assert service.required_libversion(scope) == '2.4'

View File

@@ -0,0 +1,108 @@
"""Asset numbers generated from the device type's prefix.
Every network device on this fleet already followed one convention applied by
hand - AP-<name>, SW-<name>, SVR-<name>, IDF-<name>, 45 records, no exceptions.
Generation codifies it so the same value is not typed twice, and must reproduce
those records exactly rather than inventing a new scheme.
"""
import pytest
from shopdb.extensions import db
from shopdb.core.models import AssetType
from plugins.network.models import NetworkDeviceType
def _devicetype(db, name, prefix):
"""A device type, with the core asset type the create path needs present."""
if not AssetType.query.filter_by(assettype='network_device').first():
db.session.add(AssetType(assettype='network_device'))
devicetype = NetworkDeviceType(networkdevicetype=name, prefix=prefix)
db.session.add(devicetype)
db.session.commit()
return devicetype
from plugins.network.services.assetnumbers import generate, sanitize
@pytest.mark.parametrize('prefix,name,expected', [
('AP', 'AIRwdMUSwestj02', 'AP-AIRwdMUSwestj02'),
('SW', 'AIRsdMUSwestj01', 'SW-AIRsdMUSwestj01'),
('SVR', 'AVEWP4546V01', 'SVR-AVEWP4546V01'),
])
def test_reproduces_the_existing_convention(prefix, name, expected):
assert generate(prefix, name) == expected
def test_a_type_with_no_prefix_gives_the_bare_name():
"""Nullable on purpose - nobody invents a prefix for a type that wants none."""
assert generate(None, 'Bare Name') == 'Bare-Name'
assert generate('', 'Bare Name') == 'Bare-Name'
def test_an_already_prefixed_name_is_not_stacked():
"""IDF-03 under type IDF is IDF-03, never IDF-IDF-03. Real row on this data."""
assert generate('IDF', 'IDF-03') == 'IDF-03'
assert generate('idf', 'idf-03') == 'idf-03'
def test_characters_that_break_a_business_key_are_stripped():
"""assetnumber lands in URLs and in joins. The existing IDF-Telco-Demarc-#1
row shows a '#' reaching a unique key, which is what this prevents."""
assert generate('IDF', 'Telco Demarc #1') == 'IDF-Telco-Demarc-1'
assert generate('APNL', 'Door 4 / West') == 'APNL-Door-4-West'
assert sanitize(' spaced out ') == 'spaced-out'
def test_nothing_to_build_from_returns_empty():
"""So the caller demands an explicit value instead of inventing one."""
assert generate('AP', '') == ''
assert generate('AP', None) == ''
def test_create_generates_when_assetnumber_is_blank(client, db, auth_headers):
devicetype = _devicetype(db, 'Access Panel', 'APNL')
resp = client.post('/api/network', json={
'name': 'Dock Door 4',
'networkdevicetypeid': devicetype.networkdevicetypeid,
}, headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
assert resp.get_json()['data']['assetnumber'] == 'APNL-Dock-Door-4'
def test_an_explicit_assetnumber_always_wins(client, db, auth_headers):
"""Adopt an external identifier where one exists - a vendor tag beats a
generated value, which is why generation only fills a BLANK field."""
devicetype = _devicetype(db, 'Access Panel 2', 'APNL')
resp = client.post('/api/network', json={
'assetnumber': 'VENDORTAG-99',
'name': 'Dock Door 5',
'networkdevicetypeid': devicetype.networkdevicetypeid,
}, headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
assert resp.get_json()['data']['assetnumber'] == 'VENDORTAG-99'
def test_no_name_and_no_assetnumber_is_refused(client, db, auth_headers):
devicetype = _devicetype(db, 'Access Panel 3', 'APNL')
resp = client.post('/api/network', json={
'networkdevicetypeid': devicetype.networkdevicetypeid,
}, headers=auth_headers)
assert resp.status_code == 400
assert 'assetnumber is required' in resp.get_json()['data']['error']['message']
def test_prefix_round_trips_through_the_type_api(client, db, auth_headers):
created = client.post('/api/network/types', json={
'networkdevicetype': 'Door Controller', 'prefix': 'DOOR',
}, headers=auth_headers)
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['prefix'] == 'DOOR'
typeid = created.get_json()['data']['networkdevicetypeid']
updated = client.put(f'/api/network/types/{typeid}', json={'prefix': 'DR'},
headers=auth_headers)
assert updated.status_code == 200, updated.get_json()
assert updated.get_json()['data']['prefix'] == 'DR'