Collector auto-links measuring tools for metrology PCs; settings rail cleanup
All checks were successful
CI / backend (push) Successful in 1m25s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

Metrology PCs (CMM, Keyence, Genspect, wax-and-trace imaging pc-types) drive
an attached measuring instrument. The PC itself stays a shopfloor PC, but the
collector now models the instrument:

- New METROLOGY_TOOL_MAP (pctypemap.py) maps those pc-types to a
  MeasuringToolType (CMM, Vision System, Genspect, Form Tracer).
- ComputersPlugin._sync_measuringtool_link creates the MeasuringTool asset
  once and a directional PC->tool "controls" relationship, tagged
  collector:measuringtool. Idempotent (re-push reuses, no duplicate asset) and
  self-archiving (a PC re-imaged to a non-metrology type deactivates the link
  but keeps the asset and any calibration history). Mirrors the printer-link
  pattern. The MeasuringToolType is created on demand if not seeded.
- 4 tests: create+link, idempotent re-push, non-metrology skip, repurpose
  archives. Non-metrology PCs never warn about a missing controls type.

Settings rail cleanup:
- Collapsible groups so the 13-group rail fits without scrolling (1511px ->
  488px). The group containing the current page expands; the rest collapse.
  CSS-drawn caret (ASCII source, no Unicode). Empty groups never render, in
  both the rail and the landing page.
- Measuring Tools group placed with the other asset groups (right after
  Machines) instead of appended last; empty placeholder positions the
  plugin-contributed cards.
- Operating Systems moved from PCs to General Reference: OS is cross-asset
  (PCs, machines, measuring tools, network devices all run one).

Plus docs/proposals/ge-enforce-plugin.md: a planning doc for refactoring
GE-Enforce/DSC into a shopdb plugin (manifest as shopdb data, payloads on
SMB/HTTP/inline), grounded in the real manifest schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 15:29:08 -04:00
parent f6dcaef4c0
commit 1672e349e5
7 changed files with 669 additions and 22 deletions

View File

@@ -22,6 +22,12 @@ logger = logging.getLogger(__name__)
# assetrelationships.label column (no origin column exists; see BUILD notes).
PRINTER_LINK_ORIGIN = 'collector:printers'
# Marker stamped on PC->measuringtool "controls" links this collector creates
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link
# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived
# by a collector push, so hand-made tool links survive.
MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
class ComputersPlugin(BasePlugin):
"""
@@ -259,6 +265,11 @@ class ComputersPlugin(BasePlugin):
# Printer relationship sync (only when the payload carried printer data).
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
# get an attached MeasuringTool asset auto-created and linked.
measuringtoollinks = self._sync_measuringtool_link(
comp.asset, pctype, hostname, warnings)
db.session.commit()
return {
'action': action,
@@ -268,6 +279,8 @@ class ComputersPlugin(BasePlugin):
'extra': {
'printerlinks': printerlinks,
'printerlinkcount': len(printerlinks),
'measuringtoollinks': measuringtoollinks,
'measuringtoollinkcount': len(measuringtoollinks),
},
}
@@ -400,6 +413,116 @@ class ComputersPlugin(BasePlugin):
db.session.add(rel)
return rel
# -- measuring-tool sync -----------------------------------------------
def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings):
"""Auto-create + link the MeasuringTool a metrology PC drives.
A CMM / Keyence / Genspect / wax-and-trace imaging pc-type means the
shopfloor PC controls an attached measuring instrument. This creates
that instrument once as a MeasuringTool asset and a directional
PC->tool 'controls' relationship, tagged MEASURINGTOOL_LINK_ORIGIN so
it is idempotent and self-archiving. The PC's own ComputerType is left
alone (it stays a shopfloor PC). A non-metrology pc-type archives any
collector-created tool link (e.g. a PC re-imaged to another type) but
never deletes the tool asset, which may carry calibration history.
Returns the desired-link list.
"""
from shopdb.api import AssetRelationship, RelationshipType, Asset
from .pctypemap import metrology_tool_for
tool_spec = metrology_tool_for(pctype)
controls = RelationshipType.query.filter_by(
relationshiptype='controls').first()
if not controls:
# No 'controls' type => no tool links can exist. Only a problem for a
# metrology PC that needs one; stay quiet for ordinary PCs.
if tool_spec:
warnings.append("'controls' relationship type missing; "
'run flask seed reference-data')
return []
# Collector-created tool links already on this PC (active or archived).
existing = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pcasset.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.label == MEASURINGTOOL_LINK_ORIGIN,
).all()
if not tool_spec:
# Not a metrology PC: archive any collector-created tool link.
for rel in existing:
if rel.isactive:
rel.isactive = False
return []
try:
from plugins.measuringtools.models import MeasuringTool
except ImportError:
warnings.append('measuringtools plugin unavailable; '
'tool link skipped')
return []
typename, typedescription = tool_spec
tooltype = self._ensure_measuringtool_type(typename, typedescription)
# Reuse any prior collector link (reactivate + retype) before creating,
# so a re-metrology PC never duplicates the tool asset.
reuse = next((rel for rel in existing if rel.isactive), None) \
or (existing[0] if existing else None)
if reuse:
reuse.isactive = True
toolasset = db.session.get(Asset, reuse.targetassetid)
if toolasset and toolasset.measuringtool and tooltype:
toolasset.measuringtool.measuringtooltypeid = \
tooltype.measuringtooltypeid
targetid = reuse.targetassetid
else:
mt_assettype = AssetType.query.filter_by(
assettype='measuring_tool').first()
if not mt_assettype:
warnings.append('measuring_tool asset type missing; '
'tool link skipped')
return []
suffix = (pctype or '').split('-')[-1].upper()
baseasset = pcasset.assetnumber or hostname
toolasset = Asset(
assetnumber=f'{baseasset}-{suffix}',
name=f'{typename} ({hostname})',
assettypeid=mt_assettype.assettypeid,
statusid=1)
db.session.add(toolasset)
db.session.flush()
db.session.add(MeasuringTool(
assetid=toolasset.assetid,
measuringtooltypeid=tooltype.measuringtooltypeid
if tooltype else None))
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=toolasset.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=MEASURINGTOOL_LINK_ORIGIN))
targetid = toolasset.assetid
# Only one tool link is desired; archive any other collector rows.
for rel in existing:
if rel is not reuse and rel.isactive:
rel.isactive = False
return [{'assetid': targetid, 'relationshiptype': 'controls',
'measuringtooltype': typename}]
def _ensure_measuringtool_type(self, name, description):
"""Find or create a MeasuringToolType (metrology types are not in the
measuringtools starter seed)."""
from plugins.measuringtools.models import MeasuringToolType
tooltype = MeasuringToolType.query.filter_by(name=name).first()
if not tooltype:
tooltype = MeasuringToolType(name=name, description=description)
db.session.add(tooltype)
db.session.flush()
return tooltype
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():