Wire relationship directionality and dualpath controls propagation
All checks were successful
CI / backend (push) Successful in 1m13s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

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:
cproudlock
2026-07-12 06:07:36 -04:00
parent 1d21bf0206
commit 4fd110a33d
10 changed files with 1023 additions and 76 deletions

View File

@@ -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():

View File

@@ -237,12 +237,7 @@ def delete_asset_status(status_id: int):
def list_relationship_types():
"""List all asset relationship types."""
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
return success_response([{
'relationshiptypeid': t.relationshiptypeid,
'relationshiptype': t.relationshiptype,
'description': t.description,
'color': t.color
} for t in types])
return success_response([_rel_type_dict(t) for t in types])
@assets_bp.route('/relationshiptypes', methods=['POST'])
@@ -264,7 +259,8 @@ def create_relationship_type():
rel_type = RelationshipType(
relationshiptype=data['relationshiptype'],
description=data.get('description'),
color=data.get('color')
color=data.get('color'),
isdirectional=data.get('isdirectional', True)
)
db.session.add(rel_type)
db.session.commit()
@@ -278,6 +274,16 @@ def _rel_type_dict(t):
'relationshiptype': t.relationshiptype,
'description': t.description,
'color': t.color,
'isdirectional': t.isdirectional,
# read-only view of the propagation rails (seed/CLI-managed, no UI)
'propagatesthrough': [
{
'relationshiptypeid': p.relationshiptypeid,
'relationshiptype': p.relationshiptype,
'isdirectional': p.isdirectional,
}
for p in t.propagatesthrough
],
}
@@ -294,7 +300,7 @@ def update_relationship_type(type_id: int):
if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first():
return error_response(ErrorCodes.CONFLICT,
f"Relationship type '{data['relationshiptype']}' already exists", http_code=409)
for key in ('relationshiptype', 'description', 'color'):
for key in ('relationshiptype', 'description', 'color', 'isdirectional'):
if key in data:
setattr(t, key, data[key])
db.session.commit()
@@ -563,6 +569,97 @@ def lookup_asset_by_number(assetnumber: str):
# Asset Relationships
# =============================================================================
def _partners_via(assetid, throughtypeid):
"""Return the set of asset ids linked to assetid via throughtypeid.
Direction-blind: matches rows where assetid is source OR target, so a
symmetric through-type (Dualpath) stored in either direction is caught.
"""
rows = AssetRelationship.query.filter(
AssetRelationship.relationshiptypeid == throughtypeid,
AssetRelationship.isactive == True,
db.or_(
AssetRelationship.sourceassetid == assetid,
AssetRelationship.targetassetid == assetid,
),
).all()
partners = set()
for row in rows:
other = row.targetassetid if row.sourceassetid == assetid else row.sourceassetid
partners.add(other)
return partners
def _relationship_exists(sourceid, targetid, typeid):
# existence check ignores isactive so a soft-deleted row still blocks a
# duplicate insert (the uniqueness constraint spans all rows).
return AssetRelationship.query.filter_by(
sourceassetid=sourceid,
targetassetid=targetid,
relationshiptypeid=typeid,
).first() is not None
def _add_propagated(sourceid, targetid, rel, created):
# idempotent add; skip if source-T->target already exists (any isactive)
if sourceid in (targetid,):
return
if _relationship_exists(sourceid, targetid, rel.relationshiptypeid):
return
newrel = AssetRelationship(
sourceassetid=sourceid,
targetassetid=targetid,
relationshiptypeid=rel.relationshiptypeid,
label=rel.label,
)
db.session.add(newrel)
created.append(newrel)
def propagate_relationship(rel):
"""Fan out rel across the SYMMETRIC through-types of its type.
A Dualpath pair is one physical dual-bay machine, so a relationship that
touches one bay must be mirrored to the partner bay - whichever end (source
or target) carries the Dualpath link. For each symmetric through-type P
(isdirectional false) in the type's propagation set we mirror BOTH ends,
keeping the relationship's direction:
* for each partner X of the TARGET via P: create source -T-> X
* for each partner X of the SOURCE via P: create X -T-> target
In practice only one end has partners (the bay end of a controls edge,
whichever direction the row is stored), so there is no double fan-out.
Canonical controls direction is PC -> machine (the PC is the controller:
it sends programs to the machine and receives logs); reversed legacy rows
are corrected by `flask relationships fix-controls-direction`, and
propagation preserves whatever direction the row carries. Same-direction
idempotency: controls is directional, so we skip when the same
source-T->target row already exists. Returns the newly created
AssetRelationship rows (added to the session, not committed).
Directional through-types (partof) are declared but NOT consumed: the
parent/child fan-out direction is ambiguous, so their propagation semantics
are deferred and partof stays inert exactly as before.
"""
created = []
reltype = rel.relationshiptype or db.session.get(RelationshipType, rel.relationshiptypeid)
if not reltype:
return created
for through in reltype.propagatesthrough:
if through.isdirectional:
continue # directional propagation deferred (parent/child ambiguity)
# target-side partners inherit being controlled BY the source
for partnerid in _partners_via(rel.targetassetid, through.relationshiptypeid):
if partnerid != rel.sourceassetid:
_add_propagated(rel.sourceassetid, partnerid, rel, created)
# source-side partners inherit controlling the target
for partnerid in _partners_via(rel.sourceassetid, through.relationshiptypeid):
if partnerid != rel.targetassetid:
_add_propagated(partnerid, rel.targetassetid, rel, created)
return created
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
@jwt_required(optional=True)
def get_asset_relationships(asset_id: int):
@@ -595,6 +692,8 @@ def get_asset_relationships(asset_id: int):
r = rel.to_dict()
r['targetasset'] = rel.targetasset.to_dict() if rel.targetasset else None
r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None
# directionality drives card display (symmetric collapse vs arrows)
r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True
outgoing_data.append(r)
incoming_data = []
@@ -602,6 +701,7 @@ def get_asset_relationships(asset_id: int):
r = rel.to_dict()
r['sourceasset'] = rel.sourceasset.to_dict() if rel.sourceasset else None
r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None
r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True
incoming_data.append(r)
return success_response({
@@ -614,7 +714,19 @@ def get_asset_relationships(asset_id: int):
@jwt_required()
@require_permission('assets.create')
def create_asset_relationship():
"""Create a relationship between two assets."""
"""Create a relationship between two assets.
After the row is created, the relationship is fanned out across its type's
propagation rails (relationshiptypepropagations). For each SYMMETRIC
through-type (e.g. Dualpath) the edge is mirrored to the partner of
whichever endpoint carries the through-type link, keeping direction.
Concretely, `controls` between a bay of a dual-bay machine and its PC is
also created for the partner bay, since a Dualpath pair is one physical
machine with a single controller. Directional through-types (partof) are
declared but not consumed yet. The response carries `propagated` and
`propagatedcount`. Propagation pairs are seed/CLI-managed (there is no
management UI); backfill legacy data with `flask relationships propagate`.
"""
data = request.get_json()
if not data:
@@ -661,16 +773,30 @@ def create_asset_relationship():
db.session.add(rel)
apply_import_timestamps(rel, data)
db.session.flush()
# fan out across symmetric propagation rails (e.g. Dualpath partner bays)
propagated = propagate_relationship(rel)
for prop in propagated:
apply_import_timestamps(prop, data)
db.session.commit()
return success_response(rel.to_dict(), message='Relationship created', http_code=201)
payload = rel.to_dict()
payload['propagated'] = [prop.to_dict() for prop in propagated]
payload['propagatedcount'] = len(propagated)
return success_response(payload, message='Relationship created', http_code=201)
@assets_bp.route('/relationships/<int:rel_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset_relationship(rel_id: int):
"""Delete an asset relationship."""
"""Delete an asset relationship.
Deletion is manual and per-row: propagated rows (e.g. the mirrored controls
link on a Dualpath partner bay) are real independent rows and each is
deleted on its own. Removing one bay's link does NOT cascade to the other.
"""
rel = db.session.get(AssetRelationship, rel_id)
if not rel:

View File

@@ -21,30 +21,78 @@ class RelationshipType(BaseModel):
description = db.Column(db.Text)
color = db.Column(db.String(20), comment='CSS color for relationship badges')
# Sibling propagation (ADR-001): when a relationship of this type is
# created/deleted, the framework finds all assets related to the source
# via the type at propagatesthroughid and mirrors the change. Null means
# no propagation. Seeded values:
# partof -> null (propagation rail itself)
# controls -> partof (controls propagates across siblings)
# connectedto -> null (network paths don't propagate)
propagatesthroughid = db.Column(
db.Integer,
db.ForeignKey('relationshiptypes.relationshiptypeid'),
nullable=True,
comment='Sibling-propagation rail per ADR-001'
# True: edge has a source->target meaning (controls, partof, Backup For).
# False: symmetric link (Dualpath, connectedto, USB...) shown on the card
# once per peer with no direction, both stored direction rows collapsed.
isdirectional = db.Column(
db.Boolean,
default=True,
nullable=False,
server_default='1',
comment='False = symmetric connection, shown direction-blind'
)
# Sibling propagation (ADR-001), WIRED. When a relationship of this type is
# created, the create path fans it out across every through-type in this
# type's propagation set (relationshiptypepropagations rows). For each
# SYMMETRIC through-type P, the source's edge to the target is mirrored to
# every asset linked to the target via P. This is M:N so one type can
# propagate through several rails at once. Seeded rows on this DB:
# controls -> partof (declared; directional rail, NOT consumed yet -
# parent/child fan-out direction is ambiguous)
# controls -> Dualpath (consumed; a dual-bay pair is one physical machine
# with one controller, so both bays carry controls)
# A dual-bay Dualpath pair thus gets the PC's controls link on both bays.
# propagatesthrough is the list of through-type RelationshipType rows.
propagatesthrough = db.relationship(
'RelationshipType',
remote_side=[relationshiptypeid],
foreign_keys=[propagatesthroughid],
secondary='relationshiptypepropagations',
primaryjoin='RelationshipType.relationshiptypeid'
' == RelationshipTypePropagation.relationshiptypeid',
secondaryjoin='RelationshipType.relationshiptypeid'
' == RelationshipTypePropagation.throughtypeid',
viewonly=True,
)
def __repr__(self):
return f"<RelationshipType {self.relationshiptype}>"
class RelationshipTypePropagation(db.Model):
"""
M:N propagation rails (ADR-001). One row means: a relationship of type
relationshiptypeid propagates through the connections of type throughtypeid.
Replaces the old single-valued relationshiptypes.propagatesthroughid so a
type (controls) can propagate through several through-types (partof AND
Dualpath) at once. Seed/CLI-managed; no management UI.
"""
__tablename__ = 'relationshiptypepropagations'
propagationid = db.Column(db.Integer, primary_key=True)
relationshiptypeid = db.Column(
db.Integer,
db.ForeignKey('relationshiptypes.relationshiptypeid'),
nullable=False
)
throughtypeid = db.Column(
db.Integer,
db.ForeignKey('relationshiptypes.relationshiptypeid'),
nullable=False
)
__table_args__ = (
db.UniqueConstraint(
'relationshiptypeid',
'throughtypeid',
name='uq_reltype_propagation'
),
)
def __repr__(self):
return f"<RelationshipTypePropagation {self.relationshiptypeid} through {self.throughtypeid}>"
class AssetRelationship(BaseModel):
"""
Relationships between assets.