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