computers: the machine number identifies the machine, not the PC

A bay reporting machinenumber 3015 got a 500 from the collector every five
minutes since it was imaged, and would have forever: the reported number was
written to the PC's own assets.assetnumber, which is uniquely indexed and
already held by machine 3015, so the insert failed with "Duplicate entry '3015'
for key 'ix_assets_assetnumber'" and the entire report was discarded. Operating
system, boot time, applications, printers and access protocols never landed.
Every retry did the same thing, so there was no path out of it.

A new PC now takes its hostname as its asset number, which is what the data
already shows: of 289 computers none has a numeric asset number and 214 use
their hostname. An existing PC's asset number is left alone; overwriting it
renamed the PC onto the machine's identifier, changing how that PC is
identified everywhere else.

The machine number instead does what it was collected for. It resolves the
machine and links the PC to it with a 'controls' relationship carrying a
collector:machine origin label, the same discipline the printer and
measuring-tool links use, so a link made by hand is never archived by a
collector push. Reporting a different machine archives this PC's previous link;
a machine ShopDB does not know is reported as a warning rather than invented.

When another PC was already linked to that machine it has been replaced. The
old link is archived rather than deleted, so which PC ran a machine in a given
month remains answerable, and an alert goes out by email and webhook. The
retired PC's status is deliberately not changed: the collector cannot tell
whether it was shelved, sent for repair or re-imaged for another bay, and
guessing would overwrite what a person set.
This commit is contained in:
cproudlock
2026-08-10 14:24:55 -04:00
parent b8398a36eb
commit 9512b0bdb3
3 changed files with 377 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.