Part markers become their own assets under the operation they serve

This commit is contained in:
2026-08-10 15:55:36 -04:00
parent 28b1dfb89e
commit 2924a53346
5 changed files with 371 additions and 1 deletions

View File

@@ -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