Wire relationship directionality and dualpath controls propagation
Symmetric relationship types (isdirectional flag, migration 7d19) show
one entry per peer on the relationships card - a Dualpath pair no
longer lists its partner twice - and directional types read naturally
instead of Outgoing/Incoming. Deleting a collapsed entry removes every
underlying direction row.
Propagation is now real (migration 7d20): relationship types declare
propagation-through pairs in relationshiptypepropagations (M:N,
replacing the never-consumed single column); creating a controls link
on either bay of a Dualpath pair auto-creates it on the partner,
mirrored across both endpoints because live data stores controls as
bay -> PC. flask relationships propagate backfills existing data (29
rows fanned out on the WJ dataset, idempotent).
This also completes the tree that commit 1d21bf0 accidentally split
(core/models/__init__ imported RelationshipTypePropagation ahead of the
file that defines it), returning CI to green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -196,12 +196,14 @@ def seed_reference_data():
|
||||
# Connection types (pre-1.0 legacy; kept for backward compat with
|
||||
# existing relationship rows. New ADR-001 code reasons about the three
|
||||
# canonical types below via free-text label.)
|
||||
# all symmetric physical/network links -> isdirectional=False so the
|
||||
# relationships card shows one direction-blind "connected" entry per peer.
|
||||
connection_types = [
|
||||
{'relationshiptype': 'Serial Cable', 'description': 'RS-232 or similar serial connection'},
|
||||
{'relationshiptype': 'Direct Ethernet', 'description': 'Direct network cable (airgapped)'},
|
||||
{'relationshiptype': 'USB', 'description': 'USB connection'},
|
||||
{'relationshiptype': 'WiFi', 'description': 'Wireless network connection'},
|
||||
{'relationshiptype': 'Dualpath', 'description': 'Redundant/failover network path'},
|
||||
{'relationshiptype': 'Serial Cable', 'description': 'RS-232 or similar serial connection', 'isdirectional': False},
|
||||
{'relationshiptype': 'Direct Ethernet', 'description': 'Direct network cable (airgapped)', 'isdirectional': False},
|
||||
{'relationshiptype': 'USB', 'description': 'USB connection', 'isdirectional': False},
|
||||
{'relationshiptype': 'WiFi', 'description': 'Wireless network connection', 'isdirectional': False},
|
||||
{'relationshiptype': 'Dualpath', 'description': 'Redundant/failover network path', 'isdirectional': False},
|
||||
]
|
||||
|
||||
for ct_data in connection_types:
|
||||
@@ -210,9 +212,9 @@ def seed_reference_data():
|
||||
ct = RelationshipType(**ct_data)
|
||||
db.session.add(ct)
|
||||
|
||||
# ADR-001 canonical relationship types. Created first without propagation
|
||||
# FKs, then patched with propagatesthroughid since `controls` points at
|
||||
# `partof` (same table). All three are idempotent.
|
||||
# ADR-001 canonical relationship types. Created first, then their
|
||||
# propagation rails are seeded as relationshiptypepropagations rows (M:N).
|
||||
# All idempotent.
|
||||
#
|
||||
# MySQL collation is case-insensitive by default, which would let a
|
||||
# legacy capitalized row (e.g. "Controls") match the lowercase
|
||||
@@ -229,21 +231,38 @@ def seed_reference_data():
|
||||
return RelationshipType.query.filter_by(relationshiptype=name).first()
|
||||
|
||||
adr_types = [
|
||||
{'relationshiptype': 'partof', 'description': 'Composition / sub-assembly (ADR-001)'},
|
||||
{'relationshiptype': 'controls', 'description': 'Operational authority over another asset (ADR-001)'},
|
||||
{'relationshiptype': 'connectedto', 'description': 'Network or data link without authority (ADR-001)'},
|
||||
{'relationshiptype': 'partof', 'description': 'Composition / sub-assembly (ADR-001)', 'isdirectional': True},
|
||||
{'relationshiptype': 'controls', 'description': 'Operational authority over another asset (ADR-001)', 'isdirectional': True},
|
||||
{'relationshiptype': 'connectedto', 'description': 'Network or data link without authority (ADR-001)', 'isdirectional': False},
|
||||
]
|
||||
for at in adr_types:
|
||||
if not _lookup_binary(at['relationshiptype']):
|
||||
db.session.add(RelationshipType(**at))
|
||||
db.session.flush()
|
||||
|
||||
# Wire `controls` -> `partof` propagation rail. partof + connectedto stay
|
||||
# null (no propagation).
|
||||
partof = _lookup_binary('partof')
|
||||
controls = _lookup_binary('controls')
|
||||
if partof and controls and controls.propagatesthroughid != partof.relationshiptypeid:
|
||||
controls.propagatesthroughid = partof.relationshiptypeid
|
||||
# Seed `controls` propagation rails as M:N rows. controls -> partof
|
||||
# (declared; directional rail, not consumed yet) and controls -> Dualpath
|
||||
# (consumed; a dual-bay pair shares one controller so both bays carry
|
||||
# controls). Idempotent, resolved by name, skipped if a type is missing.
|
||||
from shopdb.core.models.relationship import RelationshipTypePropagation
|
||||
|
||||
def _seed_propagation(sourcename, throughname):
|
||||
source = _lookup_binary(sourcename)
|
||||
through = _lookup_binary(throughname)
|
||||
if not source or not through:
|
||||
return
|
||||
exists = RelationshipTypePropagation.query.filter_by(
|
||||
relationshiptypeid=source.relationshiptypeid,
|
||||
throughtypeid=through.relationshiptypeid,
|
||||
).first()
|
||||
if not exists:
|
||||
db.session.add(RelationshipTypePropagation(
|
||||
relationshiptypeid=source.relationshiptypeid,
|
||||
throughtypeid=through.relationshiptypeid,
|
||||
))
|
||||
|
||||
_seed_propagation('controls', 'partof')
|
||||
_seed_propagation('controls', 'Dualpath')
|
||||
|
||||
# Default-printer link: a PC asset -> its default printer asset. Read by the
|
||||
# printer-installer endpoint (parity with classic apipcdefaultprinter.asp).
|
||||
@@ -332,6 +351,109 @@ def seed_admin(username, email, password):
|
||||
click.echo(click.style('=' * 52, fg='cyan'))
|
||||
|
||||
|
||||
@click.group('relationships')
|
||||
def relationships_cli():
|
||||
"""Asset-relationship maintenance commands."""
|
||||
pass
|
||||
|
||||
|
||||
@relationships_cli.command('propagate')
|
||||
@with_appcontext
|
||||
def propagate_relationships():
|
||||
"""Backfill propagated relationship rows across symmetric rails.
|
||||
|
||||
Scans every existing relationship of a type that propagates through a
|
||||
symmetric through-type (e.g. controls through Dualpath) and creates the
|
||||
missing fanned-out rows. Idempotent. Also serves the legacy-import flow:
|
||||
the import creates controls links on primary bays, this fans them out to
|
||||
the Dualpath partner bays.
|
||||
"""
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
from shopdb.core.api.assets import propagate_relationship
|
||||
|
||||
# types that actually propagate through at least one symmetric through-type
|
||||
propagating_ids = [
|
||||
t.relationshiptypeid for t in RelationshipType.query.all()
|
||||
if any(not through.isdirectional for through in t.propagatesthrough)
|
||||
]
|
||||
|
||||
total = 0
|
||||
if propagating_ids:
|
||||
rels = AssetRelationship.query.filter(
|
||||
AssetRelationship.relationshiptypeid.in_(propagating_ids),
|
||||
AssetRelationship.isactive == True,
|
||||
).all()
|
||||
for rel in rels:
|
||||
total += len(propagate_relationship(rel))
|
||||
db.session.commit()
|
||||
|
||||
click.echo(click.style(f"Propagated {total} relationship row(s).", fg='green'))
|
||||
|
||||
|
||||
@relationships_cli.command('fix-controls-direction')
|
||||
@with_appcontext
|
||||
def fix_controls_direction():
|
||||
"""Flip reversed legacy controls rows to PC -> machine.
|
||||
|
||||
Legacy import stores controls as machine -> PC, which reads as the machine
|
||||
having authority over the PC. In reality the PC is the controller (it
|
||||
sends programs to the machine and receives logs), so per ADR-001 the PC
|
||||
must be the source. Flips every active controls row whose source is a
|
||||
machine asset and target is a computer asset. If the flipped row already
|
||||
exists, the reversed duplicate is deactivated instead. Idempotent.
|
||||
"""
|
||||
from sqlalchemy.orm import aliased
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
|
||||
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
|
||||
if not controls:
|
||||
click.echo(click.style("No 'controls' relationship type; nothing to do.", fg='yellow'))
|
||||
return
|
||||
|
||||
sourceasset = aliased(Asset)
|
||||
targetasset = aliased(Asset)
|
||||
sourcetype = aliased(AssetType)
|
||||
targettype = aliased(AssetType)
|
||||
|
||||
reversed_rows = (
|
||||
AssetRelationship.query
|
||||
.join(sourceasset, AssetRelationship.sourceassetid == sourceasset.assetid)
|
||||
.join(targetasset, AssetRelationship.targetassetid == targetasset.assetid)
|
||||
.join(sourcetype, sourceasset.assettypeid == sourcetype.assettypeid)
|
||||
.join(targettype, targetasset.assettypeid == targettype.assettypeid)
|
||||
.filter(
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.isactive == True,
|
||||
sourcetype.assettype == 'machine',
|
||||
targettype.assettype == 'computer',
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
flipped = 0
|
||||
deactivated = 0
|
||||
for row in reversed_rows:
|
||||
duplicate = AssetRelationship.query.filter_by(
|
||||
sourceassetid=row.targetassetid,
|
||||
targetassetid=row.sourceassetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
).first()
|
||||
if duplicate:
|
||||
row.isactive = False # flipped row already exists, retire this one
|
||||
deactivated += 1
|
||||
else:
|
||||
row.sourceassetid, row.targetassetid = row.targetassetid, row.sourceassetid
|
||||
flipped += 1
|
||||
db.session.commit()
|
||||
|
||||
click.echo(click.style(
|
||||
f"Flipped {flipped} controls row(s) to PC -> machine"
|
||||
f" ({deactivated} reversed duplicate(s) deactivated).", fg='green'))
|
||||
|
||||
|
||||
@seed_cli.command('permissions')
|
||||
@with_appcontext
|
||||
def seed_permissions():
|
||||
|
||||
Reference in New Issue
Block a user