Security hardening, multi-site portability, and a documentation and API-surface sweep
This commit is contained in:
126
CHANGELOG.md
126
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
202
frontend/src/utils/codes.js
Normal 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 ''
|
||||
}
|
||||
}
|
||||
93
frontend/src/utils/codes.spec.js
Normal file
93
frontend/src/utils/codes.spec.js
Normal 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('"')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
#>
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
class="cidr-input"
|
||||
rows="5"
|
||||
spellcheck="false"
|
||||
placeholder="10.134.48.0/23 10.48.249.0/26"
|
||||
placeholder="192.0.2.0/24 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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {{}}
|
||||
}}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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() }}-<name></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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -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}>"
|
||||
|
||||
1
plugins/network/services/__init__.py
Normal file
1
plugins/network/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Network plugin services."""
|
||||
70
plugins/network/services/assetnumbers.py
Normal file
70
plugins/network/services/assetnumbers.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
244
scripts/gen_project_map.py
Normal 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())
|
||||
188
scripts/site_imports/wjf/PILOT-DEPLOY.md
Normal file
188
scripts/site_imports/wjf/PILOT-DEPLOY.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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
46
shopdb/utils/uploads.py
Normal 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))
|
||||
95
tests/test_api_inventory_parity.py
Normal file
95
tests/test_api_inventory_parity.py
Normal 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)))
|
||||
@@ -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():
|
||||
|
||||
76
tests/test_core/test_relationship_soft_delete.py
Normal file
76
tests/test_core/test_relationship_soft_delete.py
Normal 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()
|
||||
57
tests/test_core/test_security_headers.py
Normal file
57
tests/test_core/test_security_headers.py
Normal 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)
|
||||
@@ -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
115
tests/test_docs_versions.py
Normal 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
123
tests/test_openapi_spec.py
Normal 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])
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
232
tests/test_plugins/test_collector_measuringtool_adoption.py
Normal file
232
tests/test_plugins/test_collector_measuringtool_adoption.py
Normal 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())
|
||||
97
tests/test_plugins/test_collector_partmarker_links.py
Normal file
97
tests/test_plugins/test_collector_partmarker_links.py
Normal 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
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
108
tests/test_plugins/test_network_assetnumber_generation.py
Normal file
108
tests/test_plugins/test_network_assetnumber_generation.py
Normal 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'
|
||||
Reference in New Issue
Block a user