Collector: machine number identifies the machine, not the PC; ntlars empty-value fix

This commit is contained in:
2026-08-10 14:26:19 -04:00
parent 41a4a96f1e
commit 502728c9f6
5 changed files with 431 additions and 6 deletions

View File

@@ -28,6 +28,12 @@ PRINTER_LINK_ORIGIN = 'collector:printers'
# by a collector push, so hand-made tool links survive.
MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
# Marker stamped on the PC->machine "controls" link built from the reported
# machine number. Same discipline as the two above: only rows carrying this
# label are archived by a collector push, so a link made by hand is never
# touched.
MACHINE_LINK_ORIGIN = 'collector:machine'
class ComputersPlugin(BasePlugin):
"""
@@ -153,8 +159,15 @@ class ComputersPlugin(BasePlugin):
if not hostname:
raise ValueError('hostname is required')
# Machine number is the business identifier (Asset.assetnumber). Skip
# the imaging-time placeholder '9999' and fall back to hostname.
# The machine number identifies the MACHINE, not this PC. It is
# reported so the PC can be related to its machine; it is deliberately
# NOT used as the PC's assetnumber. assets.assetnumber is uniquely
# indexed and the machine already owns that value, so assigning it here
# raised "Duplicate entry '3015' for key 'ix_assets_assetnumber'" and
# returned 500 to the bay - forever, since every retry did the same
# thing. The convention this restores is what the data already shows:
# of 289 computers, none has a numeric assetnumber and 214 use their
# hostname.
machinenumber = (payload.get('machinenumber') or '').strip()
if machinenumber in ('', '9999'):
machinenumber = None
@@ -169,7 +182,7 @@ class ComputersPlugin(BasePlugin):
atype = AssetType.query.filter_by(assettype='computer').first()
# statusid=1 is the first seeded asset status ("In Use"); a
# collector-discovered PC is by definition in use.
asset = Asset(assetnumber=machinenumber or hostname,
asset = Asset(assetnumber=hostname,
assettypeid=atype.assettypeid, statusid=1)
db.session.add(asset)
db.session.flush()
@@ -177,8 +190,10 @@ class ComputersPlugin(BasePlugin):
db.session.add(comp)
db.session.flush()
action = 'created'
elif machinenumber and comp.asset:
comp.asset.assetnumber = machinenumber
# NOTE: an existing PC's assetnumber is left alone. Overwriting it with
# the machine number renamed the PC onto the machine's identifier, which
# either collided with the unique index or silently changed how that PC
# is identified everywhere else.
comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)
if payload.get('lastboottime'):
@@ -277,6 +292,9 @@ class ComputersPlugin(BasePlugin):
# Remote-access protocol sync (only when the payload carried the key).
accessprotocols = self._sync_access_protocols(comp, payload, warnings)
# PC -> machine link from the reported machine number.
machinelinks = self._sync_machine_link(comp, machinenumber, warnings)
# Printer relationship sync (only when the payload carried printer data).
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
@@ -297,6 +315,7 @@ class ComputersPlugin(BasePlugin):
'measuringtoollinks': measuringtoollinks,
'measuringtoollinkcount': len(measuringtoollinks),
'accessprotocols': accessprotocols,
'machinelinks': machinelinks,
},
}
@@ -494,6 +513,140 @@ class ComputersPlugin(BasePlugin):
# -- measuring-tool sync -----------------------------------------------
def _sync_machine_link(self, comp, machinenumber, warnings):
"""Link a PC to the machine it drives, from the reported machine number.
Until this existed the machine number was collected and then discarded,
so a replaced PC never took over its bay: the retired PC kept the link
and the new one got none. Everything that walks PC->machine (the
warranty machine column, the DNC info card) therefore pointed at
hardware that had been pulled out.
ARCHIVES, never deletes. A superseded link stays with isactive=False so
"which PC ran 3015 in June" is still answerable. Status on the old PC is
deliberately NOT changed: the collector cannot tell whether it was
shelved, broken or re-imaged for another bay, and guessing would
overwrite whatever a person deliberately set.
"""
from shopdb.api import AssetRelationship, RelationshipType, Asset
pcasset = comp.asset if comp else None
if not machinenumber or not pcasset:
return []
controls = RelationshipType.query.filter_by(
relationshiptype='controls').first()
if not controls:
warnings.append("'controls' relationship type missing; "
'run flask seed reference-data')
return []
machine = Asset.query.filter(
Asset.assetnumber.ilike(machinenumber),
Asset.isactive.is_(True)).first()
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
# account for.
warnings.append(
'no asset for machine number {!r}; PC not linked'.format(
machinenumber))
return []
if machine.assetid == pcasset.assetid:
return []
# This PC's own collector links: keep the one for the reported machine,
# archive any other (the PC moved bays).
mine = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pcasset.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.label == MACHINE_LINK_ORIGIN,
).all()
link = None
for rel in mine:
if rel.targetassetid == machine.assetid:
rel.isactive = True
link = rel
else:
rel.isactive = False
if link is None:
link = AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=machine.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=MACHINE_LINK_ORIGIN,
isactive=True)
db.session.add(link)
# Any OTHER pc still collector-linked to this machine has been replaced.
superseded = AssetRelationship.query.filter(
AssetRelationship.targetassetid == machine.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.label == MACHINE_LINK_ORIGIN,
AssetRelationship.sourceassetid != pcasset.assetid,
AssetRelationship.isactive.is_(True),
).all()
for rel in superseded:
rel.isactive = False
old = db.session.get(Asset, rel.sourceassetid)
oldname = old.assetnumber if old else str(rel.sourceassetid)
warnings.append(
'machine {} was taken over from {}; check that PC'.format(
machine.assetnumber, oldname))
self._alert_pc_superseded(oldname, comp, machine)
return [{'assetid': machine.assetid,
'machinenumber': machine.assetnumber,
'superseded': len(superseded)}]
def _alert_pc_superseded(self, oldname, newcomp, machine):
"""Tell a human a PC was replaced on a machine. Best effort, never raises.
Deliberately an ALERT and not a status change: the collector cannot tell
whether the old PC was shelved, sent for repair or re-imaged for another
bay, so it says what happened and lets a person decide. Uses the same
email + webhook path as the toner alerts, falling back to the site-wide
alert_recipients when nothing more specific is set.
NOT a shopfloor notification: that board is for operators (General,
Recertification, Recognition), and an IT asset message does not belong
in front of the floor.
"""
import logging as _logging
try:
from shopdb.api import send_email, send_webhook, Setting
newname = newcomp.hostname if newcomp else 'a new PC'
machinename = machine.assetnumber or machine.name
subject = 'PC replaced on machine {}: {} to {}'.format(
machinename, oldname, newname)
# /pcs/:id is keyed on computerid, not assetid - an assetid here
# opens someone else's PC or a 404.
base = (Setting.get('site_base_url') or '').rstrip('/')
link = '{}/pcs/{}'.format(base, newcomp.computerid) if base else ''
linkhtml = ('<p><a href="{0}">View {1}</a></p>'.format(link, newname)
if link else '')
html = (
'<p><strong>{0}</strong> is now reporting machine '
'<strong>{1}</strong>, which was previously driven by '
'<strong>{2}</strong>.</p>'
'<p>The old link has been archived. {2} has NOT had its status '
'changed - set it to Inventory, In Repair or Retired as '
'appropriate.</p>{3}'.format(newname, machinename, oldname,
linkhtml))
send_webhook(subject, subject)
raw = Setting.get('alert_recipients') or ''
recipients = [a.strip() for a in raw.replace(';', ',').split(',')
if a.strip()]
if recipients:
send_email(recipients, subject, html)
except Exception:
_logging.getLogger(__name__).exception(
'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.