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

@@ -145,7 +145,12 @@ def test_complete_asset_payload_maps_enrollment_data(client, db, collector_key,
with client.application.app_context():
comp = Computer.query.filter(Computer.hostname.ilike('WJSF1234')).first()
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.computertype.computertype == 'Shopfloor PC' # pctype mapped
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 AccessProtocol.query.filter(
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