computers: a second PC claiming a machine is a claim, not a handover
Treating "another PC is linked to this machine" as proof of replacement was wrong. A PC imaged for machine 3010 carries that number from the bench, before it has replaced anything, and several PCs sharing one machine number is a normal state at this site: the part markers do it. Both PCs then reported on their own schedules, each report moved the link and raised an alert, and the pair traded the machine back and forth for as long as both were alive. The PC holding a machine now keeps it while it is still alive. Alive means it has reported within MACHINE_CLAIM_QUIET_HOURS and its asset is still In Use. A challenger is recorded as a dormant link instead, which doubles as the marker saying the claim has already been announced, so a PC sitting on a bench does not alert on every collector cycle. The handover still happens on its own once the old PC has been quiet for a day, which is what a PC pulled off a machine does. Moving the old PC off In Use - Retired, Inventory, In Repair - hands the machine over on the next report, which gives IT a one-step way to force a swap the moment it happens rather than waiting out the window. A day is long enough that a PC switched off overnight, or one behind a network outage, never loses its bay to a spare. Alerts for both cases are gated on a new computers_machinelink_alerts setting and ship OFF. Several part markers legitimately share a machine number here, so the alerts would fire on correct data. Links, warnings in the collector response, and archived history are unaffected; only the sending is gated. Also: the alert goes through send_alert rather than resolving recipients by hand, which had missed the SMTP_ALERT_RECIPIENTS environment fallback, so a site configuring SMTP by environment would have got the webhook and no email.
This commit is contained in:
@@ -34,6 +34,12 @@ MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
# touched.
|
||||
MACHINE_LINK_ORIGIN = 'collector:machine'
|
||||
|
||||
# How long a PC holding a machine may go without reporting before a second PC
|
||||
# claiming that machine is treated as its replacement. A swap resolves itself
|
||||
# within a day; a PC off overnight or behind a network outage keeps its bay.
|
||||
# Shorter than this and a spare imaged on the bench could steal a live machine.
|
||||
MACHINE_CLAIM_QUIET_HOURS = 24
|
||||
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
"""
|
||||
@@ -145,6 +151,46 @@ class ComputersPlugin(BasePlugin):
|
||||
},
|
||||
}
|
||||
|
||||
def get_settings_defaults(self) -> List[dict]:
|
||||
"""Settings this plugin owns.
|
||||
|
||||
The framework seeds these at install, at enable, and on every
|
||||
`flask plugin upgrade-all`, so a key added in a later version reaches a
|
||||
site that installed an earlier one.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'key': 'computers_machinelink_alerts',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'computers',
|
||||
'description': 'Email and webhook alerts when a PC takes over '
|
||||
'a machine or claims one another PC still runs. '
|
||||
'Off while machine numbers are shared between '
|
||||
'PCs; the collector response still warns.',
|
||||
},
|
||||
]
|
||||
|
||||
def _machinelink_alerts_enabled(self):
|
||||
"""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
|
||||
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.
|
||||
|
||||
The links, the warnings in the collector response, and the archived
|
||||
history all continue regardless. Only the sending is gated.
|
||||
"""
|
||||
from shopdb.api import Setting
|
||||
|
||||
setting = Setting.query.filter_by(
|
||||
key='computers_machinelink_alerts').first()
|
||||
if not setting:
|
||||
return False
|
||||
return (setting.value or '').strip().lower() in ('true', '1', 'yes')
|
||||
|
||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
|
||||
from datetime import datetime, timezone
|
||||
@@ -527,6 +573,14 @@ class ComputersPlugin(BasePlugin):
|
||||
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.
|
||||
|
||||
A second PC reporting a machine another PC already holds is a CLAIM, not
|
||||
proof of replacement. A PC imaged on the bench for machine 3010 carries
|
||||
that number before it ever reaches the floor, and treating the claim as
|
||||
a handover made the two PCs trade the link back and forth at collector
|
||||
cadence, alerting on every pass. The incumbent therefore keeps the
|
||||
machine while it is still alive, and the challenger is recorded as a
|
||||
dormant link. See _incumbent_has_yielded for what alive means.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
|
||||
@@ -555,8 +609,8 @@ class ComputersPlugin(BasePlugin):
|
||||
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).
|
||||
# This PC's own collector links: the one for the reported machine is
|
||||
# settled below, any other is archived (the PC moved bays).
|
||||
mine = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
@@ -566,10 +620,48 @@ class ComputersPlugin(BasePlugin):
|
||||
link = None
|
||||
for rel in mine:
|
||||
if rel.targetassetid == machine.assetid:
|
||||
rel.isactive = True
|
||||
link = rel
|
||||
else:
|
||||
rel.isactive = False
|
||||
|
||||
# Whoever actively holds this machine now, if it is not this PC.
|
||||
held = 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()
|
||||
blocking = [rel for rel in held
|
||||
if not self._incumbent_has_yielded(rel.sourceassetid)]
|
||||
|
||||
if blocking:
|
||||
# Contested. The incumbent is still reporting and still In Use, so
|
||||
# this is a claim on a bay it has not taken over yet. Record the
|
||||
# claim dormant and leave the live link where it is; the dormant row
|
||||
# is also the marker that says this was already announced, which is
|
||||
# what stops an alert on every report.
|
||||
names = ', '.join(self._assetname(rel.sourceassetid)
|
||||
for rel in blocking)
|
||||
warnings.append(
|
||||
'machine {} is still held by {}; {} claim recorded but not '
|
||||
'linked'.format(machine.assetnumber, names, comp.hostname))
|
||||
if link is None:
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=machine.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MACHINE_LINK_ORIGIN,
|
||||
isactive=False))
|
||||
self._alert_machine_contested(names, comp, machine)
|
||||
else:
|
||||
link.isactive = False
|
||||
return [{'assetid': machine.assetid,
|
||||
'machinenumber': machine.assetnumber,
|
||||
'contestedby': names,
|
||||
'superseded': 0}]
|
||||
|
||||
# Uncontested: take the machine.
|
||||
if link is None:
|
||||
link = AssetRelationship(
|
||||
sourceassetid=pcasset.assetid,
|
||||
@@ -578,19 +670,14 @@ class ComputersPlugin(BasePlugin):
|
||||
label=MACHINE_LINK_ORIGIN,
|
||||
isactive=True)
|
||||
db.session.add(link)
|
||||
else:
|
||||
link.isactive = True
|
||||
|
||||
# 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:
|
||||
# Anything still holding it has yielded: gone quiet, or taken off In
|
||||
# Use by a person. That is a replacement, so archive and say so.
|
||||
for rel in held:
|
||||
rel.isactive = False
|
||||
old = db.session.get(Asset, rel.sourceassetid)
|
||||
oldname = old.assetnumber if old else str(rel.sourceassetid)
|
||||
oldname = self._assetname(rel.sourceassetid)
|
||||
warnings.append(
|
||||
'machine {} was taken over from {}; check that PC'.format(
|
||||
machine.assetnumber, oldname))
|
||||
@@ -598,24 +685,119 @@ class ComputersPlugin(BasePlugin):
|
||||
|
||||
return [{'assetid': machine.assetid,
|
||||
'machinenumber': machine.assetnumber,
|
||||
'superseded': len(superseded)}]
|
||||
'superseded': len(held)}]
|
||||
|
||||
def _assetname(self, assetid):
|
||||
"""Readable name for an asset id, for warnings and alerts."""
|
||||
from shopdb.api import Asset
|
||||
|
||||
asset = db.session.get(Asset, assetid)
|
||||
return asset.assetnumber if asset else str(assetid)
|
||||
|
||||
def _incumbent_has_yielded(self, assetid):
|
||||
"""True when the PC currently holding a machine has given it up.
|
||||
|
||||
Two ways to yield, and both are evidence rather than a guess:
|
||||
|
||||
It went quiet. A PC pulled off a machine stops reporting, so silence
|
||||
past MACHINE_CLAIM_QUIET_HOURS is the handover signal. The window is
|
||||
long enough that a PC switched off overnight, or one behind a network
|
||||
outage, never loses its bay to a spare sitting on the bench.
|
||||
|
||||
Or a person moved it off In Use. Setting the old PC to Retired,
|
||||
Inventory or In Repair is a deliberate statement that it no longer runs
|
||||
the machine, and it is the one-step way to force a handover the moment
|
||||
the swap happens instead of waiting out the window.
|
||||
|
||||
An asset with no computer extension row cannot report at all, so it
|
||||
cannot be alive; it yields.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from shopdb.api import Asset
|
||||
|
||||
computer = Computer.query.filter_by(assetid=assetid).first()
|
||||
if not computer:
|
||||
return True
|
||||
|
||||
asset = db.session.get(Asset, assetid)
|
||||
status = asset.status.status if asset and asset.status else None
|
||||
if status and status != 'In Use':
|
||||
return True
|
||||
|
||||
reported = computer.lastreporteddate
|
||||
if not reported:
|
||||
return True
|
||||
quiet = datetime.now(timezone.utc).replace(tzinfo=None) - reported
|
||||
return quiet > timedelta(hours=MACHINE_CLAIM_QUIET_HOURS)
|
||||
|
||||
def _alert_machine_contested(self, holdername, newcomp, machine):
|
||||
"""Say that a PC claims a machine another PC is still running.
|
||||
|
||||
Fires ONCE, on the report that first records the dormant claim. The
|
||||
dormant relationship row is the marker: while it exists the claim is
|
||||
already known, so a PC sitting on the bench for a fortnight does not
|
||||
alert every collector cycle.
|
||||
|
||||
Nothing is changed in the data by this. It exists because the two
|
||||
legitimate readings - a swap in progress, or a machine number typed
|
||||
onto the wrong PC at imaging - look identical to the collector, and
|
||||
only a person can tell them apart.
|
||||
"""
|
||||
import logging as _logging
|
||||
if not self._machinelink_alerts_enabled():
|
||||
return
|
||||
try:
|
||||
from shopdb.api import send_alert, Setting
|
||||
|
||||
newname = newcomp.hostname if newcomp else 'a new PC'
|
||||
machinename = machine.assetnumber or machine.name
|
||||
subject = 'Machine {} claimed by {}, still run by {}'.format(
|
||||
machinename, newname, holdername)
|
||||
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> reports that it runs machine '
|
||||
'<strong>{1}</strong>, but <strong>{2}</strong> is still '
|
||||
'reporting on that machine and is still In Use, so the link '
|
||||
'has been left where it is.</p>'
|
||||
'<p>If this is a swap in progress, nothing needs doing: {0} '
|
||||
'takes the machine once {2} stops reporting for a day, or '
|
||||
'straight away if you set {2} to Retired, Inventory or In '
|
||||
'Repair. If instead {0} was imaged with the wrong machine '
|
||||
'number, correct it on {0}.</p>{3}'.format(
|
||||
newname, machinename, holdername, linkhtml))
|
||||
send_alert(subject, html)
|
||||
except Exception:
|
||||
_logging.getLogger(__name__).exception(
|
||||
'machine-contested alert failed for machine %s',
|
||||
getattr(machine, 'assetnumber', '?'))
|
||||
|
||||
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.
|
||||
bay, so it says what happened and lets a person decide.
|
||||
|
||||
Goes through send_alert, which is the site's configured alert fan-out:
|
||||
email to the SMTP settings' alert recipients plus the alert webhook.
|
||||
Resolving those recipients by hand would have missed the
|
||||
SMTP_ALERT_RECIPIENTS environment fallback, so a site that configures
|
||||
SMTP by environment rather than in the UI would have got the webhook
|
||||
and no email.
|
||||
|
||||
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
|
||||
if not self._machinelink_alerts_enabled():
|
||||
return
|
||||
try:
|
||||
from shopdb.api import send_email, send_webhook, Setting
|
||||
from shopdb.api import send_alert, Setting
|
||||
|
||||
newname = newcomp.hostname if newcomp else 'a new PC'
|
||||
machinename = machine.assetnumber or machine.name
|
||||
@@ -635,13 +817,7 @@ class ComputersPlugin(BasePlugin):
|
||||
'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)
|
||||
send_alert(subject, html)
|
||||
except Exception:
|
||||
_logging.getLogger(__name__).exception(
|
||||
'PC-superseded alert failed for machine %s',
|
||||
|
||||
Reference in New Issue
Block a user