From fa88ef0afe8bfa040c0e551044e25ac48e797e38 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 20 Aug 2026 15:10:24 -0400 Subject: [PATCH] Device identity, collector enrollment file, and network device fixes --- .../computers/client/Report-AssetToShopDB.ps1 | 45 +++- plugins/computers/plugin.py | 173 ++++++++++++--- .../machines/frontend/views/MachinesList.vue | 14 +- .../frontend/views/NetworkDeviceDetail.vue | 28 ++- .../frontend/views/NetworkDeviceForm.vue | 11 +- tests/test_plugins/test_collector_deviceid.py | 207 ++++++++++++++++++ 6 files changed, 425 insertions(+), 53 deletions(-) create mode 100644 tests/test_plugins/test_collector_deviceid.py diff --git a/plugins/computers/client/Report-AssetToShopDB.ps1 b/plugins/computers/client/Report-AssetToShopDB.ps1 index d5f09d85..40b3079e 100644 --- a/plugins/computers/client/Report-AssetToShopDB.ps1 +++ b/plugins/computers/client/Report-AssetToShopDB.ps1 @@ -172,6 +172,7 @@ try { # 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3). # 3. C:\Enrollment\machine-number.txt - imaging value. # keyence / genspect / part-marker have no per-bay id -> no machineNo sent. +# Those bays name their DEVICE instead, in asset-id.txt below. $machineNo = '' foreach ($regPath in @( 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', @@ -236,6 +237,45 @@ try { Log "WARN could not read logged-in user: $($_.Exception.Message)" } +# The DEVICE hanging off this PC - the ONE enrollment file for every bay that +# has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and +# nothing else: it does not say what kind of device that is, because the pc-type +# already does, so the same file serves a part marker, a Keyence, a Genspect or +# anything added later without a new file or a client change. +# +# Deliberately NOT machine-number.txt. machinenumber answers "which bay is +# this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device +# there would silently stop every bay-gated manifest entry from matching. +# +# WHY A FILE AT ALL: every other identity the server can fall back to is derived +# from the PC - a prior link from this PC, or an asset number built from this +# hostname - so none of them survive a PC SWAP. The replacement box mints a +# SECOND record for the same physical device while the first keeps its history +# under a dead PC's name. This file is what survives. +# +# The server ADOPTS the named asset, refuses a value it cannot resolve, and +# refuses one that resolves to the wrong kind of thing - so a typo warns instead +# of inventing a phantom device. +$deviceId = '' +$idFile = 'C:\Enrollment\asset-id.txt' +if (Test-Path -LiteralPath $idFile) { + try { + $deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim() + if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" } + } catch { Log "WARN could not read ${idFile}: $($_.Exception.Message)" } +} +# 0.12.0 shipped measuringtool-id.txt before the file was generalised. Read it +# as a fallback so a bay already staged with one keeps reporting. +if (-not $deviceId) { + $mtFile = 'C:\Enrollment\measuringtool-id.txt' + if (Test-Path -LiteralPath $mtFile) { + try { + $deviceId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim() + if ($deviceId) { Log "deviceId from ${mtFile} (pre-0.13 name): $deviceId" } + } catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" } + } +} + # imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only # when present; absent -> server leaves existing pctype untouched (a bare report # never re-types a PC). Unmapped values return a warning, not an error. @@ -302,6 +342,7 @@ $body = @{ hostname = $hostname } if ($serialNumber) { $body['serialnumber'] = $serialNumber } if ($machineNo) { $body['machinenumber'] = $machineNo } if ($pcType) { $body['pctype'] = $pcType } +if ($deviceId) { $body['deviceid'] = $deviceId } if ($manufacturer) { $body['vendorname'] = $manufacturer } if ($model) { $body['modelnumber'] = $model } if ($osVersion) { $body['osname'] = $osVersion } @@ -312,8 +353,8 @@ $body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:s $json = $body | ConvertTo-Json -Compress -Depth 4 -Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f ` - $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp) +Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f ` + $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId) try { $resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json ` diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index f174b76c..4d55054a 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -106,12 +106,24 @@ class ComputersPlugin(BasePlugin): 'fields': { 'hostname': {'type': 'string', 'required': True}, 'machinenumber': {'type': 'string'}, - # The instrument this PC drives, named by the enrollment file - # measuringtool-id.txt. SEPARATE from machinenumber on purpose: - # "which bay is this" and "which instrument is this" are - # different facts, and machinenumber is what GE-Enforce - # TargetMachineNumbers gates on - repointing it at a tool would - # silently stop every bay-gated manifest entry matching. + # The DEVICE hanging off this PC, named by the enrollment file + # asset-id.txt. One file for every bay that has no NTLARS/eDNC + # MachineNo registry to identify it, and it does NOT say what + # kind of device it is: the pc-type already does, through + # SUBORDINATE_DEVICE_MAP. + # + # SEPARATE from machinenumber on purpose: "which bay is this" + # and "which device is this" are different facts, and + # machinenumber is what GE-Enforce TargetMachineNumbers gates + # on - repointing it at a device would silently stop every + # bay-gated manifest entry matching. + # + # Named deviceid rather than assetid because assetid is this + # contract's own RESPONSE field for a PC's integer primary key. + 'deviceid': {'type': 'string'}, + # Shipped in 0.12.0, before the file was generalised. Accepted + # as an alias so a bay staged with measuringtool-id.txt keeps + # reporting; deviceid wins when both arrive. 'measuringtoolid': {'type': 'string'}, 'pctype': {'type': 'string'}, 'pcsubtype': {'type': 'string'}, @@ -394,8 +406,14 @@ class ComputersPlugin(BasePlugin): # marker PC must not also claim the operation directly: several markers # serve one operation number, so direct claims would fight over it. The # marker is partof the operation and control propagates along that rail. + # asset-id.txt, falling back to the 0.12.0 field name. One value feeds + # BOTH device paths: the file names the device and nothing else, so + # which sync consumes it follows from the pc-type. + deviceid = (payload.get('deviceid') + or payload.get('measuringtoolid') or '').strip() or None + partmarkers = self._sync_partmarker(comp, pctype, machinenumber, - warnings) + warnings, deviceid=deviceid) # PC -> machine link from the reported machine number. if partmarkers: @@ -410,7 +428,7 @@ class ComputersPlugin(BasePlugin): # get an attached MeasuringTool asset auto-created and linked. measuringtoollinks = self._sync_measuringtool_link( comp.asset, pctype, hostname, warnings, - measuringtoolid=payload.get('measuringtoolid'), + measuringtoolid=deviceid, machinenumber=machinenumber) db.session.commit() @@ -750,7 +768,70 @@ class ComputersPlugin(BasePlugin): 'machinenumber': machine.assetnumber, 'superseded': len(held)}] - def _sync_partmarker(self, comp, pctype, machinenumber, warnings): + def _is_device_of_type(self, asset, spec): + """Is this asset the kind of device the pc-type's spec describes? + + asset-id.txt names a device and says nothing about its type, so the + type is what proves the file points somewhere sensible. A measuring + tool is checked against the measuring-tool set; anything else is + checked against its machine type, which is where a Part Marker lives. + """ + if asset is None: + return False + if spec.get('assettype') == 'measuring_tool': + return asset.assetid in self._measuringtool_assetids() + try: + from plugins.machines.models import Machine, MachineType + except ImportError: + return False + row = (db.session.query(MachineType.machinetype) + .join(Machine, Machine.machinetypeid == MachineType.machinetypeid) + .filter(Machine.assetid == asset.assetid).first()) + return bool(row) and row[0] == spec['typename'] + + def _ensure_device_rows(self, deviceasset, spec, pcasset, controls, label, + warnings): + """Get-or-create the extension row and the PC -> device control link. + + Shared by the named-device and minted paths: an adopted asset may have + no extension row, and the control link may already exist under another + label, so both are get-or-create rather than insert. + """ + from shopdb.api import AssetRelationship + try: + from plugins.machines.models import Machine, MachineType + except ImportError: + warnings.append('machines plugin unavailable; {} device skipped' + .format(spec['typename'])) + return + if not Machine.query.filter_by(assetid=deviceasset.assetid).first(): + devicetype = MachineType.query.filter_by( + machinetype=spec['typename']).first() + if not devicetype: + devicetype = MachineType(machinetype=spec['typename'], + description=spec.get('description')) + db.session.add(devicetype) + db.session.flush() + db.session.add(Machine(assetid=deviceasset.assetid, + machinetypeid=devicetype.machinetypeid)) + controlrow = AssetRelationship.query.filter_by( + sourceassetid=pcasset.assetid, + targetassetid=deviceasset.assetid, + relationshiptypeid=controls.relationshiptypeid).first() + if controlrow is not None: + controlrow.isactive = True + if not controlrow.label: + controlrow.label = label + else: + db.session.add(AssetRelationship( + sourceassetid=pcasset.assetid, + targetassetid=deviceasset.assetid, + relationshiptypeid=controls.relationshiptypeid, + label=label, + isactive=True)) + + def _sync_partmarker(self, comp, pctype, machinenumber, warnings, + deviceid=None): """Give a part-marker PC a marker asset of its own, under its operation. Several Telesis markers serve one operation number - 0613, 0615 and @@ -760,10 +841,19 @@ class ComputersPlugin(BasePlugin): question about an individual marker (how many are there, which port, which one failed) could be asked at all. - One marker per PC, so the PC identifies the marker and the collector can - mint it the same way it already mints a CMM or a Keyence unit for a - metrology PC. The marker is a machine asset of type Part Marker, the PC - `controls` it, and the marker is `partof` the operation it serves. + One marker per PC. The marker is a machine asset of type Part Marker, + the PC `controls` it, and the marker is `partof` the operation it + serves. + + IDENTITY COMES FROM asset-id.txt FIRST, because everything else here is + derived from the PC and a PC is not permanent. Reuse looks for a prior + link from THIS PC asset and adoption looks up `-PARTMARKER`, + so both survive a re-image and neither survives a PC SWAP: a new + hostname is a new PC asset with no prior link and a predicted number + that has never existed, so the same physical Telesis unit gets a second + record while the first keeps its config and backup history under a dead + PC's name. That is the failure the metrology path already learned from, + where minting from the hostname left 43 instruments shadowed by twins. That last rail is why the PC does not also claim the operation directly: `controls` propagates through `partof` (seeded in reference-data), so @@ -801,8 +891,26 @@ class ComputersPlugin(BasePlugin): label = spec['label'] - # Reuse this PC's existing device before minting one, so a re-image - # never leaves a second device behind for the same physical unit. + # --- 1. an explicitly named device wins over everything ------------- + named = (deviceid or '').strip() + namedasset = None + if named: + candidate = self._asset_by_number(named) + if candidate is None: + # Warn rather than invent: a typo must not mint a phantom + # device that nobody can account for. + warnings.append( + 'no asset for device {!r}; not linked'.format(named)) + elif not self._is_device_of_type(candidate, spec): + # The name resolved, but not to this pc-type's device. Refuse + # and say which asset it hit, rather than filing an unrelated + # asset under a device label the collector also owns. + warnings.append( + 'asset {!r} is not a {}; not linked'.format( + candidate.assetnumber, spec['typename'])) + else: + namedasset = candidate + existing = AssetRelationship.query.filter( AssetRelationship.sourceassetid == pcasset.assetid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid, @@ -811,7 +919,18 @@ class ComputersPlugin(BasePlugin): reuse = next((rel for rel in existing if rel.isactive), None) \ or (existing[0] if existing else None) - if reuse: + if namedasset is not None: + # The file is authoritative. A PC that previously minted its own + # marker and is now told the real one keeps only the named link; + # the stale link is archived by the one-marker-per-PC sweep below. + markerasset = namedasset + reuse = next((rel for rel in existing + if rel.targetassetid == namedasset.assetid), None) + if reuse is not None: + reuse.isactive = True + self._ensure_device_rows(markerasset, spec, pcasset, controls, + label, warnings) + elif reuse: reuse.isactive = True markerasset = db.session.get(Asset, reuse.targetassetid) else: @@ -852,26 +971,8 @@ class ComputersPlugin(BasePlugin): db.session.add(markerasset) db.session.flush() - # The extension row may be missing on an adopted asset, and the link - # may already exist under another label - both get-or-create. - if not Machine.query.filter_by(assetid=markerasset.assetid).first(): - db.session.add(Machine(assetid=markerasset.assetid, - machinetypeid=devicetype.machinetypeid)) - controlrow = AssetRelationship.query.filter_by( - sourceassetid=pcasset.assetid, - targetassetid=markerasset.assetid, - relationshiptypeid=controls.relationshiptypeid).first() - if controlrow is not None: - controlrow.isactive = True - if not controlrow.label: - controlrow.label = label - else: - db.session.add(AssetRelationship( - sourceassetid=pcasset.assetid, - targetassetid=markerasset.assetid, - relationshiptypeid=controls.relationshiptypeid, - label=label, - isactive=True)) + self._ensure_device_rows(markerasset, spec, pcasset, controls, + label, warnings) # One marker per PC: archive any other collector marker link. for rel in existing: diff --git a/plugins/machines/frontend/views/MachinesList.vue b/plugins/machines/frontend/views/MachinesList.vue index b02123e2..19732aab 100644 --- a/plugins/machines/frontend/views/MachinesList.vue +++ b/plugins/machines/frontend/views/MachinesList.vue @@ -36,7 +36,9 @@ - + {{ item.assetnumber }} @@ -58,12 +60,20 @@ {{ item.locationname || '-' }} + View + - diff --git a/plugins/network/frontend/views/NetworkDeviceDetail.vue b/plugins/network/frontend/views/NetworkDeviceDetail.vue index 3e984f10..6921b403 100644 --- a/plugins/network/frontend/views/NetworkDeviceDetail.vue +++ b/plugins/network/frontend/views/NetworkDeviceDetail.vue @@ -33,22 +33,26 @@ {{ heroWarranty.label }} +
-
- Asset # - {{ device.assetnumber }} +
+ Asset # + {{ device.assetnumber }}
-
- Serial - {{ device.serialnumber }} +
+ Serial + {{ device.serialnumber }}
-
- Location - {{ device.locationname }} +
+ Location + {{ device.locationname }}
-
- Business Unit - {{ device.businessunitname }} +
+ Business Unit + {{ device.businessunitname }}
diff --git a/plugins/network/frontend/views/NetworkDeviceForm.vue b/plugins/network/frontend/views/NetworkDeviceForm.vue index c53b7a9c..526e9d99 100644 --- a/plugins/network/frontend/views/NetworkDeviceForm.vue +++ b/plugins/network/frontend/views/NetworkDeviceForm.vue @@ -161,7 +161,7 @@ @@ -395,6 +395,15 @@ const generatedAssetNumber = computed(() => { }) const vendors = ref([]) const models = ref([]) + +// Models belong to a vendor, so picking one narrows the list - the same +// behaviour PCForm, MachineForm and PrinterForm have. With no vendor chosen the +// whole catalogue shows, rather than an empty dropdown that reads as "no models +// exist" when it means "pick a vendor first". +const filteredModels = computed(() => { + if (!form.value.vendorid) return models.value + return models.value.filter(m => m.vendorid === form.value.vendorid) +}) const locations = ref([]) const statuses = ref([]) const businessUnits = ref([]) diff --git a/tests/test_plugins/test_collector_deviceid.py b/tests/test_plugins/test_collector_deviceid.py new file mode 100644 index 00000000..5a721afd --- /dev/null +++ b/tests/test_plugins/test_collector_deviceid.py @@ -0,0 +1,207 @@ +"""asset-id.txt names the device, and that name survives a PC swap. + +Every other identity the collector has for a subordinate device is derived from +the PC: reuse looks for a prior link from THIS PC asset, and adoption looks up +`-`. Both survive a re-image and neither survives a swap - a +new hostname is a new PC asset with no prior link and a predicted number that +has never existed, so the same physical device gets a second record while the +first keeps its history under a dead PC's name. + +That is the failure that left 43 measuring tools shadowed by minted twins. These +tests pin the fix for BOTH device families, because the part-marker path was +modelled on the metrology path as it stood before it was fixed. +""" + +import pytest + +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.relationship import RelationshipType, AssetRelationship + +KEY = 'deviceid-key' + + +@pytest.fixture +def collector_key(app): + old = app.config.get('COLLECTOR_API_KEY') + app.config['COLLECTOR_API_KEY'] = KEY + yield KEY + app.config['COLLECTOR_API_KEY'] = old + + +@pytest.fixture +def rig(db): + for name in ('computer', 'machine', 'measuring_tool'): + if not AssetType.query.filter_by(assettype=name).first(): + db.session.add(AssetType(assettype=name)) + for name in ('controls', 'partof'): + if not RelationshipType.query.filter_by(relationshiptype=name).first(): + db.session.add(RelationshipType(relationshiptype=name)) + db.session.commit() + + +def _report(client, key, hostname, pctype, **extra): + payload = {'hostname': hostname, 'pctype': pctype} + payload.update(extra) + return client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': key}) + + +def _asset(db, assetnumber, assettype='machine'): + """A bare asset: an operation, or something that is NOT a device.""" + at = AssetType.query.filter_by(assettype=assettype).first() + asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid) + db.session.add(asset) + db.session.commit() + return asset + + +def _marker(db, assetnumber): + """A Part Marker the collector did NOT create - the real unit on the floor. + + The extension row and machine type are what make it a marker; a bare asset + of the right number is deliberately refused, which the wrong-type test pins. + """ + from plugins.machines.models import Machine, MachineType + at = AssetType.query.filter_by(assettype='machine').first() + mtype = MachineType.query.filter_by(machinetype='Part Marker').first() + if mtype is None: + mtype = MachineType(machinetype='Part Marker') + db.session.add(mtype) + db.session.flush() + asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid) + db.session.add(asset) + db.session.flush() + db.session.add(Machine(assetid=asset.assetid, + machinetypeid=mtype.machinetypeid)) + db.session.commit() + return asset + + +def _tool(db, assetnumber): + """A measuring tool the collector did NOT create.""" + from plugins.measuringtools.models import MeasuringTool + at = AssetType.query.filter_by(assettype='measuring_tool').first() + asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid) + db.session.add(asset) + db.session.flush() + db.session.add(MeasuringTool(assetid=asset.assetid)) + db.session.commit() + return asset + + +def _controlled(pcname, label): + """Asset numbers this PC controls under a collector label.""" + pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first() + if pc is None: + return [] + rels = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, label=label, isactive=True).all() + return sorted(Asset.query.filter_by(assetid=r.targetassetid).first().assetnumber + for r in rels) + + +# --------------------------------------------------------------- part markers + +def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key): + """THE case this exists for. Same physical marker, two different PCs.""" + _asset(db, '0613') + marker = _marker(db, 'PM-0613-A') + + first = _report(client, collector_key, 'FMARK100', + pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + assert first.status_code in (200, 201), first.get_data(as_text=True)[:300] + + # The bay's PC is replaced. New hostname, same marker named in asset-id.txt. + second = _report(client, collector_key, 'FMARK200', + pctype='gea-shopfloor-partmarker', + machinenumber='0613', deviceid='PM-0613-A') + assert second.status_code in (200, 201), second.get_data(as_text=True)[:300] + + assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None + assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None + assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A'] + assert Asset.query.filter_by(assetnumber='PM-0613-A').count() == 1 + assert marker.assetid == Asset.query.filter_by( + assetnumber='PM-0613-A').first().assetid + + +def test_without_the_file_a_swap_still_mints_the_old_way(client, db, rig, + collector_key): + """The unfixed behaviour, pinned so the file's value stays visible.""" + _asset(db, '0614') + assert _report(client, collector_key, 'FMARK300', + pctype='gea-shopfloor-partmarker', + machinenumber='0614').status_code in (200, 201) + assert _report(client, collector_key, 'FMARK400', + pctype='gea-shopfloor-partmarker', + machinenumber='0614').status_code in (200, 201) + assert Asset.query.filter_by(assetnumber='FMARK300-PARTMARKER').first() + assert Asset.query.filter_by(assetnumber='FMARK400-PARTMARKER').first() + + +def test_an_unknown_device_warns_and_links_nothing(client, db, rig, + collector_key): + _asset(db, '0616') + resp = _report(client, collector_key, 'FMARK500', + pctype='gea-shopfloor-partmarker', + machinenumber='0616', deviceid='PM-TYPO') + assert resp.status_code in (200, 201) + assert Asset.query.filter_by(assetnumber='PM-TYPO').first() is None + warnings = ' '.join(resp.get_json()['data'].get('warnings', [])) + assert 'PM-TYPO' in warnings + + +def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key): + """A machine number pasted into asset-id.txt must not become a marker.""" + _asset(db, '0617') + _asset(db, 'PLAIN-MACHINE') + resp = _report(client, collector_key, 'FMARK600', + pctype='gea-shopfloor-partmarker', + machinenumber='0617', deviceid='PLAIN-MACHINE') + assert resp.status_code in (200, 201) + assert _controlled('FMARK600', 'collector:partmarker') != ['PLAIN-MACHINE'] + warnings = ' '.join(resp.get_json()['data'].get('warnings', [])) + assert 'PLAIN-MACHINE' in warnings + + +def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key): + _asset(db, '0618') + _marker(db, 'PM-0618-A') + codes = [_report(client, collector_key, 'FMARK700', + pctype='gea-shopfloor-partmarker', + machinenumber='0618', deviceid='PM-0618-A').status_code + for _ in range(3)] + assert codes == [codes[0]] * 3, codes + assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A'] + + +# ------------------------------------------------------------ measuring tools + +def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key): + """One file, no device type in it: the pc-type decides which sync uses it.""" + _tool(db, 'MT-9001') + resp = _report(client, collector_key, 'KEYENCE100', + pctype='gea-shopfloor-keyence', deviceid='MT-9001') + assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300] + assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001'] + assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None + + +def test_the_0120_field_name_still_works(client, db, rig, collector_key): + """measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting.""" + _tool(db, 'MT-9002') + resp = _report(client, collector_key, 'KEYENCE200', + pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002') + assert resp.status_code in (200, 201) + assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002'] + + +def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key): + _tool(db, 'MT-9003') + _tool(db, 'MT-9004') + resp = _report(client, collector_key, 'KEYENCE300', + pctype='gea-shopfloor-keyence', + deviceid='MT-9003', measuringtoolid='MT-9004') + assert resp.status_code in (200, 201) + assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']