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:
70
migrations/versions/7d19_relationshiptype_directional.py
Normal file
70
migrations/versions/7d19_relationshiptype_directional.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Relationship types get isdirectional; flip symmetric legacy types
|
||||
|
||||
Adds relationshiptypes.isdirectional (default true). The asset relationships
|
||||
card uses it: directional types keep a source->target reading, symmetric types
|
||||
(physical links, network connections) collapse both stored direction rows into
|
||||
one direction-blind "connected" entry per peer.
|
||||
|
||||
Seeds isdirectional=false for the connection-like types present pre-1.0:
|
||||
Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet, USB,
|
||||
WiFi. Everything else (controls, Controlled By, Backup For, Master-Slave,
|
||||
partof, defaultprinter) stays directional.
|
||||
|
||||
Idempotent guards so it is safe on a partially-migrated box.
|
||||
|
||||
Revision ID: 7d19_relationshiptype_directional
|
||||
Revises: 7d18_supportteamcontacts
|
||||
Create Date: 2026-07-11
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d19_relationshiptype_directional'
|
||||
down_revision = '7d18_supportteamcontacts'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# names seeded symmetric (isdirectional=false)
|
||||
SYMMETRIC_TYPES = [
|
||||
'Dualpath',
|
||||
'connectedto',
|
||||
'Cluster Member',
|
||||
'Serial Cable',
|
||||
'Direct Ethernet',
|
||||
'USB',
|
||||
'WiFi',
|
||||
]
|
||||
|
||||
|
||||
def _has_column(insp, table, column):
|
||||
return column in [c['name'] for c in insp.get_columns(table)]
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
|
||||
if not _has_column(insp, 'relationshiptypes', 'isdirectional'):
|
||||
op.add_column(
|
||||
'relationshiptypes',
|
||||
sa.Column('isdirectional', sa.Boolean(), nullable=False,
|
||||
server_default='1'))
|
||||
|
||||
# flip the known symmetric types. re-running just re-sets the same values.
|
||||
placeholders = ', '.join([f':n{i}' for i in range(len(SYMMETRIC_TYPES))])
|
||||
params = {f'n{i}': name for i, name in enumerate(SYMMETRIC_TYPES)}
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"UPDATE relationshiptypes SET isdirectional = 0 "
|
||||
f"WHERE relationshiptype IN ({placeholders})"),
|
||||
params)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if _has_column(insp, 'relationshiptypes', 'isdirectional'):
|
||||
op.drop_column('relationshiptypes', 'isdirectional')
|
||||
109
migrations/versions/7d20_relationshiptypepropagations.py
Normal file
109
migrations/versions/7d20_relationshiptypepropagations.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Relationship-type propagation goes M:N (relationshiptypepropagations)
|
||||
|
||||
Replaces the single-valued relationshiptypes.propagatesthroughid with a join
|
||||
table so one type can propagate through several through-types at once. On this
|
||||
DB `controls` needs both `partof` (declared) and `Dualpath` (consumed: a
|
||||
dual-bay pair is one physical machine with a single controller, so both bays
|
||||
must carry the controls link to that PC).
|
||||
|
||||
Upgrade: create relationshiptypepropagations, migrate the existing
|
||||
propagatesthroughid value into a row, drop the column, then seed the
|
||||
controls -> Dualpath row (resolved by name, skipped if either type missing).
|
||||
Downgrade: recreate the column from the first propagation row per type (best
|
||||
effort) and drop the table.
|
||||
|
||||
Idempotent guards so it is safe on a partially-migrated box.
|
||||
|
||||
Revision ID: 7d20_relationshiptypepropagations
|
||||
Revises: 7d19_relationshiptype_directional
|
||||
Create Date: 2026-07-11
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d20_relationshiptypepropagations'
|
||||
down_revision = '7d19_relationshiptype_directional'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(insp, table, column):
|
||||
return column in [c['name'] for c in insp.get_columns(table)]
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
|
||||
if 'relationshiptypepropagations' not in insp.get_table_names():
|
||||
op.create_table(
|
||||
'relationshiptypepropagations',
|
||||
sa.Column('propagationid', sa.Integer(), primary_key=True),
|
||||
sa.Column('relationshiptypeid', sa.Integer(), nullable=False),
|
||||
sa.Column('throughtypeid', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['relationshiptypeid'],
|
||||
['relationshiptypes.relationshiptypeid']),
|
||||
sa.ForeignKeyConstraint(['throughtypeid'],
|
||||
['relationshiptypes.relationshiptypeid']),
|
||||
sa.UniqueConstraint('relationshiptypeid', 'throughtypeid',
|
||||
name='uq_reltype_propagation'),
|
||||
)
|
||||
|
||||
if _has_column(insp, 'relationshiptypes', 'propagatesthroughid'):
|
||||
# migrate the single-FK value into a row (dedup guard for re-runs)
|
||||
bind.execute(sa.text(
|
||||
"INSERT INTO relationshiptypepropagations "
|
||||
"(relationshiptypeid, throughtypeid) "
|
||||
"SELECT rt.relationshiptypeid, rt.propagatesthroughid "
|
||||
"FROM relationshiptypes rt "
|
||||
"WHERE rt.propagatesthroughid IS NOT NULL AND NOT EXISTS ("
|
||||
" SELECT 1 FROM relationshiptypepropagations p "
|
||||
" WHERE p.relationshiptypeid = rt.relationshiptypeid "
|
||||
" AND p.throughtypeid = rt.propagatesthroughid)"))
|
||||
# drop the self-FK (known name from 7a01, else reflect) then the column
|
||||
fk_names = [fk['name'] for fk in insp.get_foreign_keys('relationshiptypes')
|
||||
if fk.get('constrained_columns') == ['propagatesthroughid']
|
||||
and fk.get('name')]
|
||||
for name in fk_names:
|
||||
op.drop_constraint(name, 'relationshiptypes', type_='foreignkey')
|
||||
op.drop_column('relationshiptypes', 'propagatesthroughid')
|
||||
|
||||
# seed controls -> Dualpath by name; skip if either type is missing
|
||||
rows = bind.execute(sa.text(
|
||||
"SELECT relationshiptypeid, relationshiptype FROM relationshiptypes")).fetchall()
|
||||
byname = {r[1]: r[0] for r in rows}
|
||||
controls = byname.get('controls')
|
||||
dualpath = byname.get('Dualpath')
|
||||
if controls and dualpath:
|
||||
exists = bind.execute(sa.text(
|
||||
"SELECT 1 FROM relationshiptypepropagations "
|
||||
"WHERE relationshiptypeid = :c AND throughtypeid = :d"),
|
||||
{'c': controls, 'd': dualpath}).fetchone()
|
||||
if not exists:
|
||||
bind.execute(sa.text(
|
||||
"INSERT INTO relationshiptypepropagations "
|
||||
"(relationshiptypeid, throughtypeid) VALUES (:c, :d)"),
|
||||
{'c': controls, 'd': dualpath})
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
|
||||
if not _has_column(insp, 'relationshiptypes', 'propagatesthroughid'):
|
||||
op.add_column('relationshiptypes',
|
||||
sa.Column('propagatesthroughid', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key('fk_relationshiptype_propagation',
|
||||
'relationshiptypes', 'relationshiptypes',
|
||||
['propagatesthroughid'], ['relationshiptypeid'])
|
||||
|
||||
if 'relationshiptypepropagations' in insp.get_table_names():
|
||||
# best effort: restore the first propagation row per type
|
||||
bind.execute(sa.text(
|
||||
"UPDATE relationshiptypes rt SET propagatesthroughid = ("
|
||||
" SELECT p.throughtypeid FROM relationshiptypepropagations p "
|
||||
" WHERE p.relationshiptypeid = rt.relationshiptypeid "
|
||||
" ORDER BY p.propagationid LIMIT 1)"))
|
||||
op.drop_table('relationshiptypepropagations')
|
||||
Reference in New Issue
Block a user