Device identity, collector enrollment file, and network device fixes
This commit is contained in:
@@ -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 `
|
||||
|
||||
@@ -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 `<PC number>-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:
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
|
||||
<tr v-for="item in machines" :key="item.assetid"
|
||||
:class="{ 'clickable-row': item.machine?.machineid }"
|
||||
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
|
||||
<td>
|
||||
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
|
||||
</td>
|
||||
@@ -58,12 +60,20 @@
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<!-- /machines/:id keys on machineid, the plugin extension id, NOT
|
||||
the assetid. Falling back to the assetid lands on whichever
|
||||
machine happens to carry that number: a wrong page that looks
|
||||
right, which is worse than a 404 (see the same fix in
|
||||
EnforcementReports and the warning in BackupHistory). With no
|
||||
machineid there is no page to link to, so show nothing. -->
|
||||
<router-link
|
||||
:to="`/machines/${item.machine?.machineid || item.assetid}`"
|
||||
v-if="item.machine?.machineid"
|
||||
:to="`/machines/${item.machine.machineid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="machines.length === 0">
|
||||
|
||||
@@ -33,22 +33,26 @@
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<!-- hero-detail / -label / -value are the styled classes every other
|
||||
detail page uses. This page had its own detail-item / label / value
|
||||
names, which match nothing in the stylesheet, so the label and the
|
||||
value rendered as one run-together string. -->
|
||||
<div class="hero-details">
|
||||
<div class="detail-item" v-if="device.assetnumber">
|
||||
<span class="label">Asset #</span>
|
||||
<span class="value">{{ device.assetnumber }}</span>
|
||||
<div class="hero-detail" v-if="device.assetnumber">
|
||||
<span class="hero-detail-label">Asset #</span>
|
||||
<span class="hero-detail-value">{{ device.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="device.serialnumber">
|
||||
<span class="label">Serial</span>
|
||||
<span class="value mono">{{ device.serialnumber }}</span>
|
||||
<div class="hero-detail" v-if="device.serialnumber">
|
||||
<span class="hero-detail-label">Serial</span>
|
||||
<span class="hero-detail-value mono">{{ device.serialnumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="device.locationname">
|
||||
<span class="label">Location</span>
|
||||
<span class="value">{{ device.locationname }}</span>
|
||||
<div class="hero-detail" v-if="device.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ device.locationname }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="device.businessunitname">
|
||||
<span class="label">Business Unit</span>
|
||||
<span class="value">{{ device.businessunitname }}</span>
|
||||
<div class="hero-detail" v-if="device.businessunitname">
|
||||
<span class="hero-detail-label">Business Unit</span>
|
||||
<span class="hero-detail-value">{{ device.businessunitname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-features" v-if="device.networkdevice">
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<label for="modelnumberid">Model</label>
|
||||
<select id="modelnumberid" v-model="form.modelnumberid" class="form-control">
|
||||
<option value="">Select Model</option>
|
||||
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
|
||||
<option v-for="m in filteredModels" :key="m.modelnumberid" :value="m.modelnumberid">
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -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([])
|
||||
|
||||
207
tests/test_plugins/test_collector_deviceid.py
Normal file
207
tests/test_plugins/test_collector_deviceid.py
Normal file
@@ -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
|
||||
`<PC number>-<SUFFIX>`. 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']
|
||||
Reference in New Issue
Block a user