Security hardening, multi-site portability, and a documentation and API-surface sweep
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user