Machine claim vs handover; alerts gated off; ntlars empty-value fix
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