Part markers become their own assets under the operation they serve
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -83,6 +83,20 @@ ADR-007 and ADR-002.
|
||||
`computers_machinelink_alerts` setting that ships OFF: a site may legitimately
|
||||
run several PCs on one machine number, and there the alerts fire on correct
|
||||
data. The collector response warns either way.
|
||||
- A part marker is its own asset. Several Telesis markers can serve one
|
||||
operation number, so treating the operation as the marker collapsed separate
|
||||
devices into one record: their configurations, which differ by COM port,
|
||||
overwrote each other in the backup history, and no question about an
|
||||
individual marker - how many there are, which port one is on, which one
|
||||
failed - could be asked at all. There is one marker per PC, so the collector
|
||||
now mints a Part Marker machine asset for a marker PC the same way it already
|
||||
mints a CMM or a Keyence unit for a metrology PC: the PC `controls` the
|
||||
marker, and the marker is `partof` the operation it serves. Because `controls`
|
||||
propagates through `partof`, control of the operation still follows from
|
||||
controlling its marker, and two markers on one operation no longer contest a
|
||||
link only one of them can hold. Backups from a marker PC file against the
|
||||
marker. Moving a marker to another operation archives the old membership
|
||||
rather than deleting it.
|
||||
- Plugin contract 0.16.0: `get_settings_defaults()` lets a plugin declare the
|
||||
settings it owns (key, default, type, category, description, and whether an
|
||||
unauthenticated caller may read it). See `docs/PLUGIN-HOOKS.md`.
|
||||
|
||||
@@ -52,6 +52,52 @@ def byteshash(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def markerforsource(sourcehostname):
|
||||
"""Asset id of the part marker the reporting PC drives, or None.
|
||||
|
||||
A part-marker PC's config describes ITS marker, not the operation the
|
||||
marker serves. Several markers can serve one operation - 0613, 0615 and
|
||||
WJPRT each do - so filing by machine number put several devices' configs in
|
||||
one history where they overwrote each other. The computers collector gives
|
||||
each marker PC a marker asset (`collector:partmarker`), and that is what a
|
||||
backup from that PC belongs to.
|
||||
|
||||
Falls through to None, and so to the machine number, whenever anything is
|
||||
missing: no hostname, no computers or machines plugin on a lean build
|
||||
(ADR-014), or a marker PC that has not reported to the computers collector
|
||||
yet. Filing under the operation is the old behaviour and still better than
|
||||
rejecting the backup.
|
||||
"""
|
||||
from shopdb.api import db, AssetRelationship, RelationshipType
|
||||
|
||||
hostname = (sourcehostname or '').strip()
|
||||
if not hostname:
|
||||
return None
|
||||
try:
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.computers.plugin import PARTMARKER_LINK_ORIGIN
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
computer = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
||||
if computer is None or not computer.assetid:
|
||||
return None
|
||||
|
||||
controls = RelationshipType.query.filter_by(
|
||||
relationshiptype='controls').first()
|
||||
if controls is None:
|
||||
return None
|
||||
|
||||
link = (db.session.query(AssetRelationship)
|
||||
.filter(AssetRelationship.sourceassetid == computer.assetid,
|
||||
AssetRelationship.relationshiptypeid ==
|
||||
controls.relationshiptypeid,
|
||||
AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
|
||||
AssetRelationship.isactive.is_(True))
|
||||
.first())
|
||||
return link.targetassetid if link else None
|
||||
|
||||
|
||||
class BackupKind:
|
||||
"""Base class. Subclasses override what applies to them."""
|
||||
|
||||
@@ -198,6 +244,10 @@ class NtlarsKind(BackupKind):
|
||||
def resolveassetid(self, payload):
|
||||
from shopdb.api import db, Asset
|
||||
|
||||
marker = markerforsource(payload.get('sourcehostname'))
|
||||
if marker is not None:
|
||||
return marker, None
|
||||
|
||||
machinenumber = (payload.get('machinenumber') or '').strip()
|
||||
if not machinenumber:
|
||||
return None, 'no machinenumber in payload'
|
||||
@@ -229,6 +279,10 @@ class PartMarkerKind(BackupKind):
|
||||
def resolveassetid(self, payload):
|
||||
from shopdb.api import db, Asset
|
||||
|
||||
marker = markerforsource(payload.get('sourcehostname'))
|
||||
if marker is not None:
|
||||
return marker, None
|
||||
|
||||
identifier = (payload.get('machinenumber')
|
||||
or payload.get('assetnumber') or '').strip()
|
||||
if not identifier:
|
||||
|
||||
@@ -43,6 +43,22 @@ METROLOGY_TOOL_MAP = {
|
||||
}
|
||||
|
||||
|
||||
# Imaging pc-type for a PC that drives a Telesis part marker. The marker is its
|
||||
# own asset, not a field on the PC: several markers can serve one operation
|
||||
# number (0613, 0615, WJPRT all have more than one), so filing their configs
|
||||
# under the operation collapsed separate devices into one record. One marker per
|
||||
# PC, so the PC identifies the marker - the same shape as METROLOGY_TOOL_MAP.
|
||||
PARTMARKER_PCTYPE = 'gea-shopfloor-partmarker'
|
||||
|
||||
# MachineType a collector-created marker is given.
|
||||
PARTMARKER_TYPENAME = 'Part Marker'
|
||||
|
||||
|
||||
def drives_partmarker(pctype):
|
||||
"""True when this imaging pc-type means the PC drives a part marker."""
|
||||
return (pctype or '').strip() == PARTMARKER_PCTYPE
|
||||
|
||||
|
||||
def metrology_tool_for(pctype):
|
||||
"""Return (typename, typedescription) if this pc-type drives a measuring
|
||||
tool, else None."""
|
||||
|
||||
@@ -34,6 +34,10 @@ MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
# touched.
|
||||
MACHINE_LINK_ORIGIN = 'collector:machine'
|
||||
|
||||
# Marker stamped on both links a part-marker PC produces: PC controls marker,
|
||||
# and marker partof the operation it serves. Same archive discipline again.
|
||||
PARTMARKER_LINK_ORIGIN = 'collector:partmarker'
|
||||
|
||||
# How long a PC holding a machine may go without reporting before a second PC
|
||||
# claiming that machine is treated as its replacement. A swap resolves itself
|
||||
# within a day; a PC off overnight or behind a network outage keeps its bay.
|
||||
@@ -338,8 +342,19 @@ class ComputersPlugin(BasePlugin):
|
||||
# Remote-access protocol sync (only when the payload carried the key).
|
||||
accessprotocols = self._sync_access_protocols(comp, payload, warnings)
|
||||
|
||||
# Part-marker PCs get a marker asset of their own, which is what the
|
||||
# machine number then hangs off. Done BEFORE the machine link because a
|
||||
# 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.
|
||||
partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
|
||||
warnings)
|
||||
|
||||
# PC -> machine link from the reported machine number.
|
||||
machinelinks = self._sync_machine_link(comp, machinenumber, warnings)
|
||||
if partmarkers:
|
||||
machinelinks = []
|
||||
else:
|
||||
machinelinks = self._sync_machine_link(comp, machinenumber, warnings)
|
||||
|
||||
# Printer relationship sync (only when the payload carried printer data).
|
||||
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
||||
@@ -362,6 +377,7 @@ class ComputersPlugin(BasePlugin):
|
||||
'measuringtoollinkcount': len(measuringtoollinks),
|
||||
'accessprotocols': accessprotocols,
|
||||
'machinelinks': machinelinks,
|
||||
'partmarkers': partmarkers,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -687,6 +703,159 @@ class ComputersPlugin(BasePlugin):
|
||||
'machinenumber': machine.assetnumber,
|
||||
'superseded': len(held)}]
|
||||
|
||||
def _sync_partmarker(self, comp, pctype, machinenumber, warnings):
|
||||
"""Give a part-marker PC a marker asset of its own, under its operation.
|
||||
|
||||
Several Telesis markers serve one operation number - 0613, 0615 and
|
||||
WJPRT each have more than one - so treating the operation as the marker
|
||||
collapsed separate devices into one record. Their configs, which differ
|
||||
by COM port, then overwrote each other in the backup history, and no
|
||||
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.
|
||||
|
||||
That last rail is why the PC does not also claim the operation directly:
|
||||
`controls` propagates through `partof` (seeded in reference-data), so
|
||||
control of the operation follows from controlling its marker, and two
|
||||
markers on one operation no longer contest a link that can only have one
|
||||
holder.
|
||||
|
||||
Returns [] for any PC that does not drive a marker, which leaves the
|
||||
ordinary machine link to run.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
from .pctypemap import drives_partmarker, PARTMARKER_TYPENAME
|
||||
|
||||
if not drives_partmarker(pctype) or not comp or not comp.asset:
|
||||
return []
|
||||
|
||||
pcasset = comp.asset
|
||||
controls = RelationshipType.query.filter_by(
|
||||
relationshiptype='controls').first()
|
||||
if not controls:
|
||||
warnings.append("'controls' relationship type missing; "
|
||||
'run flask seed reference-data')
|
||||
return []
|
||||
|
||||
try:
|
||||
from plugins.machines.models import Machine, MachineType
|
||||
except ImportError:
|
||||
warnings.append('machines plugin unavailable; part marker skipped')
|
||||
return []
|
||||
|
||||
# Reuse this PC's existing marker before minting one, so a re-image
|
||||
# never leaves a second marker behind for the same physical device.
|
||||
existing = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
|
||||
).all()
|
||||
reuse = next((rel for rel in existing if rel.isactive), None) \
|
||||
or (existing[0] if existing else None)
|
||||
|
||||
if reuse:
|
||||
reuse.isactive = True
|
||||
markerasset = db.session.get(Asset, reuse.targetassetid)
|
||||
else:
|
||||
machinetype = AssetType.query.filter_by(assettype='machine').first()
|
||||
if not machinetype:
|
||||
warnings.append('machine asset type missing; part marker '
|
||||
'skipped')
|
||||
return []
|
||||
markertype = MachineType.query.filter_by(
|
||||
machinetype=PARTMARKER_TYPENAME).first()
|
||||
if not markertype:
|
||||
markertype = MachineType(machinetype=PARTMARKER_TYPENAME,
|
||||
description='Telesis part marker')
|
||||
db.session.add(markertype)
|
||||
db.session.flush()
|
||||
|
||||
hostname = comp.hostname
|
||||
markerasset = Asset(
|
||||
assetnumber='{}-PARTMARKER'.format(
|
||||
pcasset.assetnumber or hostname),
|
||||
name='Part Marker ({})'.format(hostname),
|
||||
assettypeid=machinetype.assettypeid,
|
||||
statusid=1)
|
||||
db.session.add(markerasset)
|
||||
db.session.flush()
|
||||
db.session.add(Machine(assetid=markerasset.assetid,
|
||||
machinetypeid=markertype.machinetypeid))
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=markerasset.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=PARTMARKER_LINK_ORIGIN,
|
||||
isactive=True))
|
||||
|
||||
# One marker per PC: archive any other collector marker link.
|
||||
for rel in existing:
|
||||
if rel is not reuse and rel.isactive:
|
||||
rel.isactive = False
|
||||
|
||||
operation = self._link_marker_to_operation(
|
||||
markerasset, machinenumber, warnings)
|
||||
|
||||
return [{'assetid': markerasset.assetid,
|
||||
'assetnumber': markerasset.assetnumber,
|
||||
'operationassetid': operation}]
|
||||
|
||||
def _link_marker_to_operation(self, markerasset, machinenumber, warnings):
|
||||
"""Make a marker `partof` the operation whose number its PC reports.
|
||||
|
||||
Unlike the PC-to-machine link this does NOT contest: an operation can
|
||||
hold any number of markers, which is the whole point. Moving a marker to
|
||||
another operation archives the old membership rather than deleting it,
|
||||
so where a marker used to live stays answerable.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
|
||||
if not machinenumber:
|
||||
return None
|
||||
|
||||
partof = RelationshipType.query.filter_by(
|
||||
relationshiptype='partof').first()
|
||||
if not partof:
|
||||
warnings.append("'partof' relationship type missing; "
|
||||
'run flask seed reference-data')
|
||||
return None
|
||||
|
||||
operation = Asset.query.filter(
|
||||
Asset.assetnumber.ilike(machinenumber),
|
||||
Asset.isactive.is_(True)).first()
|
||||
if not operation:
|
||||
warnings.append(
|
||||
'no asset for machine number {!r}; marker not filed under an '
|
||||
'operation'.format(machinenumber))
|
||||
return None
|
||||
if operation.assetid == markerasset.assetid:
|
||||
return None
|
||||
|
||||
links = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == markerasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == partof.relationshiptypeid,
|
||||
AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
|
||||
).all()
|
||||
found = None
|
||||
for rel in links:
|
||||
if rel.targetassetid == operation.assetid:
|
||||
rel.isactive = True
|
||||
found = rel
|
||||
else:
|
||||
rel.isactive = False
|
||||
if found is None:
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=markerasset.assetid,
|
||||
targetassetid=operation.assetid,
|
||||
relationshiptypeid=partof.relationshiptypeid,
|
||||
label=PARTMARKER_LINK_ORIGIN,
|
||||
isactive=True))
|
||||
return operation.assetid
|
||||
|
||||
def _assetname(self, assetid):
|
||||
"""Readable name for an asset id, for warnings and alerts."""
|
||||
from shopdb.api import Asset
|
||||
|
||||
@@ -891,3 +891,120 @@ def test_machinelink_alerts_are_off_by_default(client, db, collector_key,
|
||||
assert alerts == []
|
||||
assert len(_links(machine_3015.assetid)) == 1
|
||||
assert any('taken over' in w for w in r.get_json()['data']['warnings'])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Part markers: a marker is its own asset, under the operation it serves
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def operation_0615(db, machine_3015):
|
||||
"""An operation number that more than one part marker serves.
|
||||
|
||||
machine_3015 is depended on for the 'machine' asset type, the 'controls'
|
||||
relationship type and 'partof', which the marker links need.
|
||||
"""
|
||||
from shopdb.core.models import AssetType, Asset, RelationshipType
|
||||
|
||||
db.session.add(RelationshipType(relationshiptype='partof',
|
||||
isdirectional=True))
|
||||
mt = AssetType.query.filter_by(assettype='machine').first()
|
||||
asset = Asset(assetnumber='0615', name='Operation 0615',
|
||||
assettypeid=mt.assettypeid, statusid=1)
|
||||
db.session.add(asset)
|
||||
db.session.commit()
|
||||
return asset
|
||||
|
||||
|
||||
def _marker(hostname):
|
||||
return {'hostname': hostname, 'machinenumber': '0615',
|
||||
'pctype': 'gea-shopfloor-partmarker'}
|
||||
|
||||
|
||||
def test_a_marker_pc_gets_a_marker_asset_of_its_own(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
from shopdb.core.models import Asset
|
||||
client.post('/api/collector/computers', json=_marker('MARKERPC1'),
|
||||
headers={'X-API-Key': collector_key})
|
||||
|
||||
marker = Asset.query.filter_by(assetnumber='MARKERPC1-PARTMARKER').first()
|
||||
assert marker is not None
|
||||
assert marker.machine is not None
|
||||
assert marker.machine.machinetype.machinetype == 'Part Marker'
|
||||
|
||||
|
||||
def test_two_markers_share_an_operation_without_contesting_it(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
"""The reason markers became assets. Both markers serve 0615, and an
|
||||
operation holds any number of them, so neither takes the other's place."""
|
||||
from shopdb.core.models import Asset, AssetRelationship, RelationshipType
|
||||
for host in ('MARKERPC1', 'MARKERPC2'):
|
||||
client.post('/api/collector/computers', json=_marker(host),
|
||||
headers={'X-API-Key': collector_key})
|
||||
|
||||
partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
|
||||
members = AssetRelationship.query.filter_by(
|
||||
targetassetid=operation_0615.assetid,
|
||||
relationshiptypeid=partof.relationshiptypeid,
|
||||
isactive=True).all()
|
||||
assert len(members) == 2
|
||||
|
||||
names = sorted(db.session.get(Asset, rel.sourceassetid).assetnumber
|
||||
for rel in members)
|
||||
assert names == ['MARKERPC1-PARTMARKER', 'MARKERPC2-PARTMARKER']
|
||||
|
||||
|
||||
def test_a_marker_pc_does_not_claim_the_operation_directly(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
"""Control of the operation follows the partof rail from the marker. A
|
||||
direct PC claim would be the link two markers cannot both hold."""
|
||||
from shopdb.core.models import AssetRelationship
|
||||
client.post('/api/collector/computers', json=_marker('MARKERPC1'),
|
||||
headers={'X-API-Key': collector_key})
|
||||
|
||||
direct = AssetRelationship.query.filter_by(
|
||||
targetassetid=operation_0615.assetid,
|
||||
label='collector:machine').all()
|
||||
assert direct == []
|
||||
|
||||
|
||||
def test_reporting_again_reuses_the_marker(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
"""GE-Enforce posts every cycle; a marker must not be minted each time."""
|
||||
from shopdb.core.models import Asset
|
||||
for _ in range(3):
|
||||
client.post('/api/collector/computers', json=_marker('MARKERPC1'),
|
||||
headers={'X-API-Key': collector_key})
|
||||
markers = Asset.query.filter(
|
||||
Asset.assetnumber.like('%-PARTMARKER')).all()
|
||||
assert len(markers) == 1
|
||||
|
||||
|
||||
def test_moving_a_marker_archives_the_old_operation(
|
||||
client, db, collector_key, computer_assettype, operation_0615,
|
||||
machine_3015):
|
||||
"""Where a marker used to live stays answerable."""
|
||||
from shopdb.core.models import AssetRelationship, RelationshipType
|
||||
client.post('/api/collector/computers', json=_marker('MARKERPC1'),
|
||||
headers={'X-API-Key': collector_key})
|
||||
moved = dict(_marker('MARKERPC1'), machinenumber='3015')
|
||||
client.post('/api/collector/computers', json=moved,
|
||||
headers={'X-API-Key': collector_key})
|
||||
|
||||
partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
|
||||
rows = AssetRelationship.query.filter_by(
|
||||
relationshiptypeid=partof.relationshiptypeid).all()
|
||||
active = [r for r in rows if r.isactive]
|
||||
assert len(rows) == 2, 'the old membership must be kept, not deleted'
|
||||
assert len(active) == 1
|
||||
assert active[0].targetassetid == machine_3015.assetid
|
||||
|
||||
|
||||
def test_an_ordinary_pc_gets_no_marker(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
from shopdb.core.models import Asset
|
||||
client.post('/api/collector/computers',
|
||||
json={'hostname': 'PLAINPC', 'machinenumber': '0615',
|
||||
'pctype': 'gea-shopfloor-collections'},
|
||||
headers={'X-API-Key': collector_key})
|
||||
assert Asset.query.filter(Asset.assetnumber.like('%-PARTMARKER')).all() == []
|
||||
|
||||
Reference in New Issue
Block a user