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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -236,8 +236,7 @@ def upload_item_image(item_id: int):
@printedparts_bp.route('/image/<path:filename>', methods=['GET'])
def serve_item_image(filename):
"""Serve an uploaded item image (public - kiosk and list read it)."""
from flask import send_from_directory
return send_from_directory(_imagedir(), filename)
return send_upload(_imagedir(), filename)
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
@@ -643,6 +642,7 @@ def report_by_person():
# --- print files: append-only revisions per item ------------------------------
from flask_jwt_extended import get_jwt_identity
from shopdb.api import send_upload
from ..models import PrintedItemFile
@@ -737,12 +737,11 @@ def upload_item_file(item_id: int):
@jwt_required(optional=True)
def download_item_file(file_id: int):
"""Download a revision under its original filename."""
from flask import send_from_directory
record = db.session.get(PrintedItemFile, file_id)
if not record:
return error_response(ErrorCodes.NOT_FOUND, 'File not found',
http_code=404)
return send_from_directory(_filedir(), record.storedfilename,
return send_upload(_filedir(), record.storedfilename,
as_attachment=True,
download_name=record.filename)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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