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

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