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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user