Collector: machine number identifies the machine, not the PC; ntlars empty-value fix
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -12,6 +12,19 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- A shop-floor PC that reported the machine number of a machine ShopDB already
|
||||||
|
knew got a 500 from the collector, on every report, forever. The reported
|
||||||
|
machine number was being written to the PC's own `assets.assetnumber`, which
|
||||||
|
is uniquely indexed and already held by the machine, so the insert failed with
|
||||||
|
a duplicate-key error and the whole report was lost: operating system, boot
|
||||||
|
time, applications, printers and access protocols never landed. The machine
|
||||||
|
number now identifies the machine only. A new PC takes its hostname as its
|
||||||
|
asset number, which is what the existing data already does, and an existing
|
||||||
|
PC's asset number is never overwritten.
|
||||||
|
- A backup export containing a value with an empty right-hand side (`Name=`)
|
||||||
|
failed to parse, and with it the whole file, so that machine could never be
|
||||||
|
backed up. The form is not strictly legal but occurs in real exports. It is
|
||||||
|
now read as an empty string, preserving the value name.
|
||||||
- The 3D parts kiosk label prefix never appeared on the kiosk. The kiosk runs
|
- The 3D parts kiosk label prefix never appeared on the kiosk. The kiosk runs
|
||||||
logged out, and an unauthenticated read of a setting is limited to an
|
logged out, and an unauthenticated read of a setting is limited to an
|
||||||
allowlist the key was not on, so the kiosk got a 404 and fell back to no
|
allowlist the key was not on, so the kiosk got a 404 and fell back to no
|
||||||
@@ -35,6 +48,17 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- A PC reporting a machine number is now linked to that machine. Until now the
|
||||||
|
number was collected and then discarded, so a replaced PC never took over its
|
||||||
|
bay: the retired PC kept the link and everything that walks PC to machine, the
|
||||||
|
warranty machine column and the DNC info card among them, pointed at hardware
|
||||||
|
that had been pulled out. The link is a `controls` relationship carrying a
|
||||||
|
`collector:machine` origin label, so a link made by hand is never touched.
|
||||||
|
Superseding a PC archives the old link rather than deleting it, keeping the
|
||||||
|
history of which PC ran a machine and when, and raises an alert by email and
|
||||||
|
webhook. The retired PC's status is deliberately left alone: the collector
|
||||||
|
cannot tell whether it was shelved, sent for repair or re-imaged for another
|
||||||
|
bay, so a person decides.
|
||||||
- Plugin contract 0.16.0: `get_settings_defaults()` lets a plugin declare the
|
- Plugin contract 0.16.0: `get_settings_defaults()` lets a plugin declare the
|
||||||
settings it owns (key, default, type, category, description, and whether an
|
settings it owns (key, default, type, category, description, and whether an
|
||||||
unauthenticated caller may read it). See `docs/PLUGIN-HOOKS.md`.
|
unauthenticated caller may read it). See `docs/PLUGIN-HOOKS.md`.
|
||||||
|
|||||||
@@ -149,6 +149,13 @@ def _parsevalue(body):
|
|||||||
return _parsehexvalue(body)
|
return _parsehexvalue(body)
|
||||||
if body == '-':
|
if body == '-':
|
||||||
return 'DELETE', None
|
return 'DELETE', None
|
||||||
|
if body == '':
|
||||||
|
# "Name=" with nothing after the sign. Not strictly legal, but it
|
||||||
|
# occurs in real exports (the part marker's WJPRT.reg has KRelay1 like
|
||||||
|
# this), and failing the whole file over it would mean that machine can
|
||||||
|
# never be backed up. Treat it as an empty string so the value NAME is
|
||||||
|
# still preserved.
|
||||||
|
return 'REG_SZ', ''
|
||||||
raise NtlarsParseError('unrecognised value form: {!r}'.format(body))
|
raise NtlarsParseError('unrecognised value form: {!r}'.format(body))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ PRINTER_LINK_ORIGIN = 'collector:printers'
|
|||||||
# by a collector push, so hand-made tool links survive.
|
# by a collector push, so hand-made tool links survive.
|
||||||
MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
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):
|
class ComputersPlugin(BasePlugin):
|
||||||
"""
|
"""
|
||||||
@@ -153,8 +159,15 @@ class ComputersPlugin(BasePlugin):
|
|||||||
if not hostname:
|
if not hostname:
|
||||||
raise ValueError('hostname is required')
|
raise ValueError('hostname is required')
|
||||||
|
|
||||||
# Machine number is the business identifier (Asset.assetnumber). Skip
|
# The machine number identifies the MACHINE, not this PC. It is
|
||||||
# the imaging-time placeholder '9999' and fall back to hostname.
|
# 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()
|
machinenumber = (payload.get('machinenumber') or '').strip()
|
||||||
if machinenumber in ('', '9999'):
|
if machinenumber in ('', '9999'):
|
||||||
machinenumber = None
|
machinenumber = None
|
||||||
@@ -169,7 +182,7 @@ class ComputersPlugin(BasePlugin):
|
|||||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||||
# statusid=1 is the first seeded asset status ("In Use"); a
|
# statusid=1 is the first seeded asset status ("In Use"); a
|
||||||
# collector-discovered PC is by definition in use.
|
# collector-discovered PC is by definition in use.
|
||||||
asset = Asset(assetnumber=machinenumber or hostname,
|
asset = Asset(assetnumber=hostname,
|
||||||
assettypeid=atype.assettypeid, statusid=1)
|
assettypeid=atype.assettypeid, statusid=1)
|
||||||
db.session.add(asset)
|
db.session.add(asset)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
@@ -177,8 +190,10 @@ class ComputersPlugin(BasePlugin):
|
|||||||
db.session.add(comp)
|
db.session.add(comp)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
action = 'created'
|
action = 'created'
|
||||||
elif machinenumber and comp.asset:
|
# NOTE: an existing PC's assetnumber is left alone. Overwriting it with
|
||||||
comp.asset.assetnumber = machinenumber
|
# 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)
|
comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
if payload.get('lastboottime'):
|
if payload.get('lastboottime'):
|
||||||
@@ -277,6 +292,9 @@ class ComputersPlugin(BasePlugin):
|
|||||||
# Remote-access protocol sync (only when the payload carried the key).
|
# Remote-access protocol sync (only when the payload carried the key).
|
||||||
accessprotocols = self._sync_access_protocols(comp, payload, warnings)
|
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).
|
# Printer relationship sync (only when the payload carried printer data).
|
||||||
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
||||||
|
|
||||||
@@ -297,6 +315,7 @@ class ComputersPlugin(BasePlugin):
|
|||||||
'measuringtoollinks': measuringtoollinks,
|
'measuringtoollinks': measuringtoollinks,
|
||||||
'measuringtoollinkcount': len(measuringtoollinks),
|
'measuringtoollinkcount': len(measuringtoollinks),
|
||||||
'accessprotocols': accessprotocols,
|
'accessprotocols': accessprotocols,
|
||||||
|
'machinelinks': machinelinks,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +513,140 @@ class ComputersPlugin(BasePlugin):
|
|||||||
|
|
||||||
# -- measuring-tool sync -----------------------------------------------
|
# -- 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):
|
def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings):
|
||||||
"""Auto-create + link the MeasuringTool a metrology PC drives.
|
"""Auto-create + link the MeasuringTool a metrology PC drives.
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,12 @@ def test_complete_asset_payload_maps_enrollment_data(client, db, collector_key,
|
|||||||
with client.application.app_context():
|
with client.application.app_context():
|
||||||
comp = Computer.query.filter(Computer.hostname.ilike('WJSF1234')).first()
|
comp = Computer.query.filter(Computer.hostname.ilike('WJSF1234')).first()
|
||||||
assert comp is not None
|
assert comp is not None
|
||||||
assert comp.asset.assetnumber == '0615' # machinenumber -> assetnumber
|
# The PC keeps its OWN assetnumber (hostname). machinenumber names the
|
||||||
|
# MACHINE, and machines are assets too (ADR-001), so assigning it here
|
||||||
|
# hit the unique index on assets.assetnumber and returned 500 to the bay
|
||||||
|
# on every retry. The machine number now builds a controls link instead,
|
||||||
|
# which is what the reported number was actually for.
|
||||||
|
assert comp.asset.assetnumber == 'WJSF1234'
|
||||||
assert comp.asset.serialnumber == 'SN-ENROLL'
|
assert comp.asset.serialnumber == 'SN-ENROLL'
|
||||||
assert comp.computertype.computertype == 'Shopfloor PC' # pctype mapped
|
assert comp.computertype.computertype == 'Shopfloor PC' # pctype mapped
|
||||||
assert comp.vendor.vendor == 'Dell' # created
|
assert comp.vendor.vendor == 'Dell' # created
|
||||||
@@ -528,3 +533,192 @@ def test_unknown_protocol_warns_and_does_not_create_one(client, db, collector_ke
|
|||||||
assert any('TeamViewer' in w for w in body['warnings'])
|
assert any('TeamViewer' in w for w in body['warnings'])
|
||||||
assert AccessProtocol.query.filter(
|
assert AccessProtocol.query.filter(
|
||||||
AccessProtocol.name.ilike('TeamViewer')).first() is None
|
AccessProtocol.name.ilike('TeamViewer')).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PC -> machine link, and the PC-swap case
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def machine_3015(db):
|
||||||
|
"""A machine asset that already owns the number a PC will report."""
|
||||||
|
from shopdb.core.models import AssetType, Asset, RelationshipType
|
||||||
|
|
||||||
|
mt = AssetType(assettype='machine', pluginname='machines',
|
||||||
|
tablename='machines', description='Machines')
|
||||||
|
db.session.add(mt)
|
||||||
|
db.session.add(RelationshipType(relationshiptype='controls',
|
||||||
|
isdirectional=True))
|
||||||
|
db.session.flush()
|
||||||
|
asset = Asset(assetnumber='3015', name='Machine 3015',
|
||||||
|
assettypeid=mt.assettypeid, statusid=1)
|
||||||
|
db.session.add(asset)
|
||||||
|
db.session.commit()
|
||||||
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
def _links(machineassetid, activeonly=True):
|
||||||
|
from shopdb.core.models import AssetRelationship
|
||||||
|
q = AssetRelationship.query.filter_by(targetassetid=machineassetid)
|
||||||
|
if activeonly:
|
||||||
|
q = q.filter_by(isactive=True)
|
||||||
|
return q.all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pc_reporting_an_existing_machine_number_does_not_500(
|
||||||
|
client, db, collector_key, computer_assettype, machine_3015):
|
||||||
|
"""The machine owns assetnumber 3015. Naming the PC 3015 too hit the unique
|
||||||
|
index and returned 500 to the bay on every retry, forever."""
|
||||||
|
r = client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'FFBWTH63', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pc_gets_its_own_assetnumber_not_the_machines(
|
||||||
|
client, db, collector_key, computer_assettype, machine_3015):
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'FFBWTH63', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
comp = Computer.query.filter(Computer.hostname.ilike('FFBWTH63')).first()
|
||||||
|
assert comp.asset.assetnumber == 'FFBWTH63'
|
||||||
|
|
||||||
|
|
||||||
|
def test_reported_machine_number_creates_the_link(
|
||||||
|
client, db, collector_key, computer_assettype, machine_3015):
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'FFBWTH63', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
assert len(_links(machine_3015.assetid)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_replacing_a_pc_hands_the_machine_over(
|
||||||
|
client, db, collector_key, computer_assettype, machine_3015):
|
||||||
|
"""The point of the whole thing: the new PC takes the bay and the retired
|
||||||
|
one stops being shown as its controller."""
|
||||||
|
from shopdb.core.models import Asset
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'OLDPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
r = client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'NEWPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
|
||||||
|
active = _links(machine_3015.assetid)
|
||||||
|
assert len(active) == 1
|
||||||
|
owner = db.session.get(Asset, active[0].sourceassetid)
|
||||||
|
assert owner.assetnumber == 'NEWPC'
|
||||||
|
|
||||||
|
# and it says so, so a person can go and look at the old one
|
||||||
|
assert any('taken over' in w for w in r.get_json()['data']['warnings'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_old_link_is_archived_not_deleted(
|
||||||
|
client, db, collector_key, computer_assettype, machine_3015):
|
||||||
|
"""History has to survive: 'which PC ran 3015 in June' stays answerable."""
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'OLDPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'NEWPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
assert len(_links(machine_3015.assetid, activeonly=False)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_old_pc_status_is_left_alone(
|
||||||
|
client, db, collector_key, computer_assettype, machine_3015):
|
||||||
|
"""The collector cannot tell if it was shelved, broken or re-imaged, so it
|
||||||
|
must not guess - and must not overwrite what a person set."""
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'OLDPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
old = Computer.query.filter(Computer.hostname.ilike('OLDPC')).first()
|
||||||
|
before = old.asset.statusid
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'NEWPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
db.session.refresh(old.asset)
|
||||||
|
assert old.asset.statusid == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_machine_number_warns_and_invents_nothing(
|
||||||
|
client, db, collector_key, computer_assettype, machine_3015):
|
||||||
|
from shopdb.core.models import Asset
|
||||||
|
r = client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'FFBWTH63', 'machinenumber': '9911'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
assert any('9911' in w for w in r.get_json()['data']['warnings'])
|
||||||
|
assert Asset.query.filter(Asset.assetnumber == '9911').first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_placeholder_machine_number_links_nothing(
|
||||||
|
client, db, collector_key, computer_assettype, machine_3015):
|
||||||
|
"""9999 is the imaging-time placeholder, not a real bay."""
|
||||||
|
r = client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'FFBWTH63', 'machinenumber': '9999'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert _links(machine_3015.assetid) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_supersede_alerts_email_and_webhook(client, db, collector_key,
|
||||||
|
computer_assettype, machine_3015,
|
||||||
|
monkeypatch):
|
||||||
|
"""A person has to find out. Email goes to the site alert_recipients and the
|
||||||
|
webhook fires, the same path the toner alerts use."""
|
||||||
|
from shopdb.core.models import Setting
|
||||||
|
import shopdb.api as shopdbapi
|
||||||
|
|
||||||
|
# Patch on shopdb.api, which is where the plugin imports them from - the
|
||||||
|
# names are bound there at import time, so patching shopdb.utils.mailer
|
||||||
|
# would silently have no effect and the real (disabled) mailer would run.
|
||||||
|
sent = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
shopdbapi, 'send_email',
|
||||||
|
lambda to, subj, html, **k: sent.update(email=(to, subj, html)))
|
||||||
|
monkeypatch.setattr(shopdbapi, 'send_webhook',
|
||||||
|
lambda subj, text, **k: sent.update(webhook=subj))
|
||||||
|
db.session.add(Setting(key='alert_recipients', value='it@example.com'))
|
||||||
|
db.session.add(Setting(key='site_base_url', value='https://shopdb/shopdb'))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'OLDPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'NEWPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
|
||||||
|
assert 'webhook' in sent, 'webhook not fired on supersede'
|
||||||
|
assert 'email' in sent, 'no email sent on supersede'
|
||||||
|
assert sent['email'][0] == ['it@example.com']
|
||||||
|
assert '3015' in sent['email'][1] and 'OLDPC' in sent['email'][1]
|
||||||
|
|
||||||
|
# The link must be keyed on computerid: /pcs/:id resolves through
|
||||||
|
# GET /api/computers/<computerid>, so an assetid opens the wrong PC.
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
newpc = Computer.query.filter_by(hostname='NEWPC').first()
|
||||||
|
assert '/pcs/{}"'.format(newpc.computerid) in sent['email'][2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_failure_never_breaks_the_collector(client, db, collector_key,
|
||||||
|
computer_assettype,
|
||||||
|
machine_3015, monkeypatch):
|
||||||
|
"""A broken mail server must not stop a bay reporting its inventory."""
|
||||||
|
import shopdb.api as shopdbapi
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError('smtp down')
|
||||||
|
monkeypatch.setattr(shopdbapi, 'send_email', boom)
|
||||||
|
monkeypatch.setattr(shopdbapi, 'send_webhook', boom)
|
||||||
|
|
||||||
|
client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'OLDPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
r = client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'NEWPC', 'machinenumber': '3015'},
|
||||||
|
headers={'X-API-Key': collector_key})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(_links(machine_3015.assetid)) == 1
|
||||||
|
|||||||
@@ -710,3 +710,50 @@ def test_panel_label_is_rendered_in_the_site_zone(bk_app, bk_plugin):
|
|||||||
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
||||||
# 16:30 UTC on 2026-08-07 is 12:30 EDT.
|
# 16:30 UTC on 2026-08-07 is 12:30 EDT.
|
||||||
assert '12:30' in _label(revision)
|
assert '12:30' in _label(revision)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REGEDIT4 / part marker exports
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
REGEDIT4REG = (
|
||||||
|
'REGEDIT4\r\n\r\n'
|
||||||
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\General]' '\r\n'
|
||||||
|
'"MachineNo"="WJPRT"\r\n'
|
||||||
|
'"Cnc"="MARKER"\r\n\r\n'
|
||||||
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Mark]' '\r\n'
|
||||||
|
'"Port Id"="COM4"\r\n'
|
||||||
|
'"Baud"="9600"\r\n'
|
||||||
|
'"KRelay1"=\r\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_regedit4_header_is_accepted():
|
||||||
|
"""The part marker's per-machine file is REGEDIT4, not 'Windows Registry
|
||||||
|
Editor Version 5.00'."""
|
||||||
|
projection = ntlars.parse(REGEDIT4REG.encode('utf-8'))
|
||||||
|
assert registry.NtlarsKind.embeddedmachineno(projection) == 'WJPRT'
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_with_an_empty_right_hand_side_does_not_fail_the_file():
|
||||||
|
"""A real export contains "KRelay1=" with nothing after the sign. Raising on
|
||||||
|
it would mean that machine could never be backed up at all."""
|
||||||
|
projection = ntlars.parse(REGEDIT4REG.encode('utf-8'))
|
||||||
|
mark = next(k for k in projection['keys'] if k['path'] == 'Mark')
|
||||||
|
assert mark['values']['KRelay1'] == {'type': 'REG_SZ', 'data': ''}
|
||||||
|
|
||||||
|
|
||||||
|
def test_part_marker_serial_port_survives_the_roundtrip():
|
||||||
|
"""The COM port the marker hangs off is the point of keeping this config."""
|
||||||
|
first = ntlars.parse(REGEDIT4REG.encode('utf-8'))
|
||||||
|
second = ntlars.parse(ntlars.render(first, dialect='wow6432node'))
|
||||||
|
mark = next(k for k in second['keys'] if k['path'] == 'Mark')
|
||||||
|
assert mark['values']['Port Id']['data'] == 'COM4'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_marker_export_shows_the_mark_tab():
|
||||||
|
card = dncinfo.build(ntlars.parse(REGEDIT4REG.encode('utf-8')), assetid=0)
|
||||||
|
labels = [s['label'] for s in card['sections']]
|
||||||
|
assert any('MARK' in label for label in labels)
|
||||||
|
mark = next(s for s in card['sections'] if 'MARK' in s['label'])
|
||||||
|
assert any(f['label'] == 'Port Id' and f['value'] == 'COM4' for f in mark['fields'])
|
||||||
|
|||||||
Reference in New Issue
Block a user