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

@@ -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')

View 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')