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

@@ -329,3 +329,106 @@ def test_env_key_still_works_alongside_tokens(client, db, collector_key,
headers={'X-API-Key': KEY})
assert resp.status_code == 200
assert resp.get_json()['data']['action'] == 'created'
# -- metrology measuring-tool auto-link ------------------------------------
# A CMM/Keyence/Genspect/wax-trace PC stays a shopfloor PC, but the collector
# creates the attached measuring instrument as a MeasuringTool asset and links
# the PC to it with a directional 'controls' relationship.
@pytest.fixture
def measuringtool_env(db):
"""Seed the measuring_tool asset type + 'controls' relationship type the
metrology collector path links through."""
from shopdb.core.models import AssetType, RelationshipType
db.session.add(AssetType(assettype='measuring_tool',
pluginname='measuringtools',
tablename='measuringtools',
description='Metrology instruments'))
db.session.add(RelationshipType(relationshiptype='controls',
description='Operational authority',
isdirectional=True))
db.session.commit()
def test_metrology_pctype_creates_and_links_measuringtool(
client, db, collector_key, computer_assettype, measuringtool_env):
"""A CMM PC gets a MeasuringTool asset + controls link; the CMM tool type
is auto-created (it is not in the measuringtools starter seed)."""
from plugins.computers.models import Computer
from plugins.measuringtools.models import MeasuringTool, MeasuringToolType
from shopdb.core.models import AssetRelationship
payload = {'hostname': 'WJCMM01', 'pctype': 'gea-shopfloor-cmm'}
resp = client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': KEY})
assert resp.status_code == 200, resp.get_json()
data = resp.get_json()['data']
assert data['measuringtoollinkcount'] == 1
link = data['measuringtoollinks'][0]
assert link['relationshiptype'] == 'controls'
assert link['measuringtooltype'] == 'CMM'
with client.application.app_context():
tools = MeasuringTool.query.all()
assert len(tools) == 1
assert MeasuringToolType.query.filter_by(name='CMM').first() is not None
pc = Computer.query.filter(Computer.hostname.ilike('WJCMM01')).first()
rels = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, label='collector:measuringtool').all()
assert len(rels) == 1
assert rels[0].targetassetid == tools[0].assetid
assert rels[0].isactive
def test_metrology_link_idempotent(client, db, collector_key,
computer_assettype, measuringtool_env):
"""Re-pushing the same metrology PC does not duplicate the tool asset."""
from plugins.measuringtools.models import MeasuringTool
payload = {'hostname': 'WJCMM02', 'pctype': 'gea-shopfloor-cmm'}
for _ in range(2):
resp = client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': KEY})
assert resp.status_code == 200
with client.application.app_context():
assert MeasuringTool.query.count() == 1
def test_nonmetrology_pctype_makes_no_tool(client, db, collector_key,
computer_assettype, measuringtool_env):
"""A plain shopfloor PC gets no measuring-tool asset."""
from plugins.measuringtools.models import MeasuringTool
payload = {'hostname': 'WJPC900', 'pctype': 'gea-shopfloor-collections'}
resp = client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': KEY})
assert resp.status_code == 200
assert resp.get_json()['data']['measuringtoollinkcount'] == 0
with client.application.app_context():
assert MeasuringTool.query.count() == 0
def test_repurposed_pc_archives_tool_link(client, db, collector_key,
computer_assettype, measuringtool_env):
"""A metrology PC re-imaged to a non-metrology type has its collector tool
link archived (tool asset kept, relationship deactivated)."""
from plugins.measuringtools.models import MeasuringTool
from shopdb.core.models import AssetRelationship
host = {'hostname': 'WJCMM03'}
client.post('/api/collector/computers',
json={**host, 'pctype': 'gea-shopfloor-cmm'},
headers={'X-API-Key': KEY})
resp = client.post('/api/collector/computers',
json={**host, 'pctype': 'gea-shopfloor-collections'},
headers={'X-API-Key': KEY})
assert resp.status_code == 200
assert resp.get_json()['data']['measuringtoollinkcount'] == 0
with client.application.app_context():
# asset preserved (may carry calibration history), link deactivated
assert MeasuringTool.query.count() == 1
rels = AssetRelationship.query.filter_by(
label='collector:measuringtool').all()
assert len(rels) == 1
assert rels[0].isactive is False