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

@@ -12,6 +12,16 @@ ADR-007 and ADR-002.
### Added
- Relationship propagation, wired and data-driven: relationship types
declare propagation-through pairs (relationshiptypepropagations M:N,
replacing the never-consumed single column); creating a controls link on
one Dualpath bay auto-creates it on the partner bay, and
`flask relationships propagate` backfills existing data.
- Employee photos, mode-aware: self-hosted directory employees support
upload/replace/delete (admin), served publicly for kiosk cards; external
directory mode passes the HR-supplied picture URL through read-only. One
resolver feeds the shopfloor recognition/recert cards and the employee
detail hero in either mode.
- Vendor-model photo management. New admin-gated core endpoints
`POST /api/models/<modelid>/image` (multipart `file`, png/jpg/jpeg/gif/webp/svg,
one image per model, replace semantics) and
@@ -66,6 +76,9 @@ ADR-007 and ADR-002.
### Fixed
- Following a relationship link between two assets of the same type now loads
the destination page instead of stale content (router-view keyed on path;
query-only URL changes still avoid a remount).
- Asset relationships card no longer lists a symmetric peer twice. Relationship
types gain `relationshiptypes.isdirectional` (migration
`7d19_relationshiptype_directional`; seeded false for the connection-like

View File

@@ -18,30 +18,32 @@
</div>
<template v-else>
<!-- Outgoing relationships (this asset controls/connects to...) -->
<div v-if="outgoing.length > 0" class="relationship-group">
<h4 class="group-title">Outgoing</h4>
<!-- Symmetric connections: one direction-blind entry per peer -->
<div v-if="connectedItems.length > 0" class="relationship-group">
<h4 class="group-title">Connected</h4>
<div class="relationship-list">
<div
v-for="rel in outgoing"
:key="rel.relationshipid"
v-for="item in connectedItems"
:key="item.key"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(rel.targetasset?.assettypename || rel.targetasset?.assettype)" :size="16" /></div>
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.targetasset)" class="rel-name">
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettypename || rel.targetasset?.assettype }}</span>
<div class="rel-line">
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
</router-link>
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
<div class="rel-meta">
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
</div>
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteRelationship(rel.relationshipid)"
@click="deleteItem(item)"
title="Remove relationship"
>
&times;
@@ -50,30 +52,42 @@
</div>
</div>
<!-- Incoming relationships (...controls/connects to this asset) -->
<div v-if="incoming.length > 0" class="relationship-group">
<h4 class="group-title">Incoming</h4>
<!-- Directional relationships: natural per-row phrasing, no jargon -->
<div v-if="directionalItems.length > 0" class="relationship-group">
<div class="relationship-list">
<div
v-for="rel in incoming"
:key="rel.relationshipid"
v-for="item in directionalItems"
:key="item.key"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettypename || rel.sourceasset?.assettype)" :size="16" /></div>
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.sourceasset)" class="rel-name">
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettypename || rel.sourceasset?.assettype }}</span>
<div class="rel-line">
<template v-if="item.direction === 'outgoing'">
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
<span class="rel-arrow">-&gt;</span>
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
</router-link>
</template>
<template v-else>
<span class="rel-arrow">&lt;-</span>
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
<span class="rel-from">from</span>
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
</router-link>
</template>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
<div class="rel-meta">
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
</div>
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteRelationship(rel.relationshipid)"
@click="deleteItem(item)"
title="Remove relationship"
>
&times;
@@ -225,6 +239,71 @@ let searchTimeout = null
const hasRelationships = computed(() => outgoing.value.length > 0 || incoming.value.length > 0)
// Symmetric types collapse to one entry per {peer, type} unordered pair. All
// stored direction rows (both directions, plus any legacy duplicates) fold
// into one displayed entry; its rowIds carries every collapsed relationshipid
// so a delete removes them all.
const connectedItems = computed(() => {
const byPair = new Map()
const rows = [
...outgoing.value.map(rel => ({ rel, peer: rel.targetasset })),
...incoming.value.map(rel => ({ rel, peer: rel.sourceasset })),
]
for (const { rel, peer } of rows) {
if (rel.isdirectional !== false) continue
const selfid = resolvedAssetId.value
const peerid = peer?.assetid
const lo = Math.min(selfid, peerid)
const hi = Math.max(selfid, peerid)
const key = `${rel.relationshiptypeid}:${lo}:${hi}`
const existing = byPair.get(key)
if (existing) {
existing.rowIds.push(rel.relationshipid)
if (!existing.notes && rel.notes) existing.notes = rel.notes
} else {
byPair.set(key, {
key,
rowIds: [rel.relationshipid],
peer,
relationshiptypeid: rel.relationshiptypeid,
relationshiptypename: rel.relationshiptypename,
notes: rel.notes || null,
})
}
}
return Array.from(byPair.values())
})
// Directional types keep one entry per stored row with arrow phrasing.
const directionalItems = computed(() => {
const items = []
for (const rel of outgoing.value) {
if (rel.isdirectional === false) continue
items.push({
key: `out-${rel.relationshipid}`,
rowIds: [rel.relationshipid],
peer: rel.targetasset,
direction: 'outgoing',
relationshiptypeid: rel.relationshiptypeid,
relationshiptypename: rel.relationshiptypename,
notes: rel.notes || null,
})
}
for (const rel of incoming.value) {
if (rel.isdirectional === false) continue
items.push({
key: `in-${rel.relationshipid}`,
rowIds: [rel.relationshipid],
peer: rel.sourceasset,
direction: 'incoming',
relationshiptypeid: rel.relationshiptypeid,
relationshiptypename: rel.relationshiptypename,
notes: rel.notes || null,
})
}
return items
})
const canSave = computed(() => {
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
})
@@ -356,11 +435,12 @@ async function saveRelationship() {
}
}
async function deleteRelationship(relationshipId) {
// Delete every collapsed direction row behind a displayed entry.
async function deleteItem(item) {
if (!confirm('Remove this relationship?')) return
try {
await assetsApi.deleteRelationship(relationshipId)
await Promise.all(item.rowIds.map(id => assetsApi.deleteRelationship(id)))
await loadRelationships()
emit('updated')
} catch (error) {
@@ -499,6 +579,24 @@ function getAssetRoute(asset) {
text-decoration: underline;
}
.rel-line {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.rel-arrow {
font-family: monospace;
font-weight: 600;
color: var(--text-light);
}
.rel-from {
font-size: 0.8rem;
color: var(--text-light);
}
.rel-meta {
display: flex;
align-items: center;

View File

@@ -16,6 +16,7 @@
<th>Relationship Type</th>
<th>Description</th>
<th>Color</th>
<th>Directional</th>
<th>Actions</th>
</tr>
</thead>
@@ -24,6 +25,7 @@
<td><span class="badge" :style="colorStyle(t.color)">{{ t.relationshiptype }}</span></td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="mono">{{ t.color || 'auto' }}</span></td>
<td>{{ t.isdirectional === false ? 'connection' : 'yes' }}</td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
@@ -31,7 +33,7 @@
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No relationship types found</td>
<td colspan="5" style="text-align: center; color: var(--text-light);">No relationship types found</td>
</tr>
</tbody>
</table>
@@ -56,6 +58,10 @@
<label>Color <span class="hint">(relationship badges; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isdirectional" /> Directional</label>
<span class="hint">off = shown as a plain connection, no direction</span>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
@@ -88,7 +94,7 @@ const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ relationshiptype: '', description: '', color: '', isactive: true })
const form = ref({ relationshiptype: '', description: '', color: '', isactive: true, isdirectional: true })
onMounted(loadData)
@@ -107,8 +113,8 @@ async function loadData() {
function openModal(item = null) {
editing.value = item
form.value = item
? { relationshiptype: item.relationshiptype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { relationshiptype: '', description: '', color: '', isactive: true }
? { relationshiptype: item.relationshiptype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false, isdirectional: item.isdirectional !== false }
: { relationshiptype: '', description: '', color: '', isactive: true, isdirectional: true }
error.value = ''
showModal.value = true
}

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

View File

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

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:

View File

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

View File

@@ -0,0 +1,106 @@
"""Relationship-type directionality: CRUD carries isdirectional and the
per-asset relationships endpoint tags each row with its type directionality.
The frontend uses isdirectional to collapse symmetric-type rows (Dualpath and
other physical links) into one direction-blind card entry, and to render
directional types (controls, partof...) with source->target phrasing. That
dedupe is view logic verified live; here we lock the backend surface it needs.
"""
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
def _make_asset(assetnumber, typeid):
asset = Asset(assetnumber=assetnumber, assettypeid=typeid)
db.session.add(asset)
db.session.flush()
return asset
# ---------------------------------------------------------------------------
# Relationship type CRUD carries isdirectional
# ---------------------------------------------------------------------------
def test_create_type_defaults_directional(client, db, auth_headers):
resp = client.post('/api/assets/relationshiptypes',
json={'relationshiptype': 'Backup For'},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
assert resp.get_json()['data']['isdirectional'] is True
def test_create_symmetric_type(client, db, auth_headers):
resp = client.post('/api/assets/relationshiptypes',
json={'relationshiptype': 'Dualpath',
'isdirectional': False},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
assert resp.get_json()['data']['isdirectional'] is False
def test_update_toggles_directional(client, db, auth_headers):
typeid = client.post('/api/assets/relationshiptypes',
json={'relationshiptype': 'USB'},
headers=auth_headers).get_json()['data']['relationshiptypeid']
resp = client.put(f'/api/assets/relationshiptypes/{typeid}',
json={'isdirectional': False}, headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['isdirectional'] is False
def test_list_includes_directional(client, db, auth_headers):
client.post('/api/assets/relationshiptypes',
json={'relationshiptype': 'WiFi', 'isdirectional': False},
headers=auth_headers)
resp = client.get('/api/assets/relationshiptypes')
assert resp.status_code == 200
row = next(t for t in resp.get_json()['data']
if t['relationshiptype'] == 'WiFi')
assert row['isdirectional'] is False
# ---------------------------------------------------------------------------
# Per-asset relationships endpoint tags each row with isdirectional
# ---------------------------------------------------------------------------
def test_relationships_row_carries_isdirectional(client, db, auth_headers):
atype = AssetType(assettype='machine')
db.session.add(atype)
db.session.flush()
symmetric = RelationshipType(relationshiptype='Dualpath', isdirectional=False)
directional = RelationshipType(relationshiptype='controls', isdirectional=True)
db.session.add_all([symmetric, directional])
db.session.flush()
a = _make_asset('M-A', atype.assettypeid)
b = _make_asset('M-B', atype.assettypeid)
# symmetric link stored in both directions (legacy shape)
db.session.add(AssetRelationship(sourceassetid=a.assetid, targetassetid=b.assetid,
relationshiptypeid=symmetric.relationshiptypeid))
db.session.add(AssetRelationship(sourceassetid=b.assetid, targetassetid=a.assetid,
relationshiptypeid=symmetric.relationshiptypeid))
# directional link a controls b
db.session.add(AssetRelationship(sourceassetid=a.assetid, targetassetid=b.assetid,
relationshiptypeid=directional.relationshiptypeid))
db.session.commit()
resp = client.get(f'/api/assets/{a.assetid}/relationships')
assert resp.status_code == 200
data = resp.get_json()['data']
for row in data['outgoing']:
assert 'isdirectional' in row
for row in data['incoming']:
assert 'isdirectional' in row
# symmetric type reports False both directions; directional reports True
sym_rows = [r for r in data['outgoing'] + data['incoming']
if r['relationshiptypename'] == 'Dualpath']
assert sym_rows and all(r['isdirectional'] is False for r in sym_rows)
ctrl_rows = [r for r in data['outgoing']
if r['relationshiptypename'] == 'controls']
assert ctrl_rows and all(r['isdirectional'] is True for r in ctrl_rows)

View File

@@ -0,0 +1,249 @@
"""Relationship propagation across symmetric rails (ADR-001, wired).
A Dualpath pair is one physical dual-bay machine with a single controller PC
and a bay-selector switch, so BOTH bay records must carry the controls link to
that PC. Creating PC controls bayA where bayA has a Dualpath partner bayB
auto-creates PC controls bayB. The propagation set is M:N
(relationshiptypepropagations): controls propagates through Dualpath
(symmetric, consumed) and through partof (directional, declared but inert).
"""
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import (
RelationshipType,
RelationshipTypePropagation,
AssetRelationship,
)
def _make_asset(assetnumber, typeid):
asset = Asset(assetnumber=assetnumber, assettypeid=typeid)
db.session.add(asset)
db.session.flush()
return asset
def _setup_types():
atype = AssetType(assettype='machine')
db.session.add(atype)
db.session.flush()
controls = RelationshipType(relationshiptype='controls', isdirectional=True)
dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False)
partof = RelationshipType(relationshiptype='partof', isdirectional=True)
db.session.add_all([controls, dualpath, partof])
db.session.flush()
return atype, controls, dualpath, partof
def _rel_exists(sourceid, targetid, typeid):
return AssetRelationship.query.filter_by(
sourceassetid=sourceid, targetassetid=targetid, relationshiptypeid=typeid
).first() is not None
# ---------------------------------------------------------------------------
# Symmetric through-type propagates (controls fans across a Dualpath pair)
# ---------------------------------------------------------------------------
def test_controls_propagates_across_dualpath_pair(client, db, auth_headers):
atype, controls, dualpath, partof = _setup_types()
db.session.add(RelationshipTypePropagation(
relationshiptypeid=controls.relationshiptypeid,
throughtypeid=dualpath.relationshiptypeid))
pc = _make_asset('PC-1', atype.assettypeid)
baya = _make_asset('BAY-A', atype.assettypeid)
bayb = _make_asset('BAY-B', atype.assettypeid)
# the two bays are Dualpath partners (one physical machine)
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
relationshiptypeid=dualpath.relationshiptypeid))
db.session.commit()
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
'sourceassetid': pc.assetid,
'targetassetid': baya.assetid,
'relationshiptypeid': controls.relationshiptypeid,
})
assert resp.status_code == 201, resp.get_json()
assert resp.get_json()['data']['propagatedcount'] == 1
# PC now controls BOTH bays
assert _rel_exists(pc.assetid, baya.assetid, controls.relationshiptypeid)
assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
def test_propagation_finds_partner_stored_either_direction(client, db, auth_headers):
# Dualpath row stored partner->target; propagation must still catch it.
atype, controls, dualpath, partof = _setup_types()
db.session.add(RelationshipTypePropagation(
relationshiptypeid=controls.relationshiptypeid,
throughtypeid=dualpath.relationshiptypeid))
pc = _make_asset('PC-2', atype.assettypeid)
baya = _make_asset('BAY-A2', atype.assettypeid)
bayb = _make_asset('BAY-B2', atype.assettypeid)
# note: bayb is the SOURCE of the Dualpath row, target is baya
db.session.add(AssetRelationship(sourceassetid=bayb.assetid, targetassetid=baya.assetid,
relationshiptypeid=dualpath.relationshiptypeid))
db.session.commit()
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
'sourceassetid': pc.assetid,
'targetassetid': baya.assetid,
'relationshiptypeid': controls.relationshiptypeid,
})
assert resp.status_code == 201
assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
def test_controls_propagates_when_bay_is_source(client, db, auth_headers):
# Matches real dev data: controls is stored bay -> PC (source is the bay).
# The bay's Dualpath partner must also get a controls -> PC row.
atype, controls, dualpath, partof = _setup_types()
db.session.add(RelationshipTypePropagation(
relationshiptypeid=controls.relationshiptypeid,
throughtypeid=dualpath.relationshiptypeid))
pc = _make_asset('PC-S', atype.assettypeid)
baya = _make_asset('BAY-AS', atype.assettypeid)
bayb = _make_asset('BAY-BS', atype.assettypeid)
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
relationshiptypeid=dualpath.relationshiptypeid))
db.session.commit()
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
'sourceassetid': baya.assetid,
'targetassetid': pc.assetid,
'relationshiptypeid': controls.relationshiptypeid,
})
assert resp.status_code == 201, resp.get_json()
assert resp.get_json()['data']['propagatedcount'] == 1
# partner bay now also controls the PC, same direction (bay -> PC)
assert _rel_exists(bayb.assetid, pc.assetid, controls.relationshiptypeid)
# ---------------------------------------------------------------------------
# Idempotent: pre-existing propagated row is not duplicated
# ---------------------------------------------------------------------------
def test_propagation_idempotent(client, db, auth_headers):
atype, controls, dualpath, partof = _setup_types()
db.session.add(RelationshipTypePropagation(
relationshiptypeid=controls.relationshiptypeid,
throughtypeid=dualpath.relationshiptypeid))
pc = _make_asset('PC-3', atype.assettypeid)
baya = _make_asset('BAY-A3', atype.assettypeid)
bayb = _make_asset('BAY-B3', atype.assettypeid)
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
relationshiptypeid=dualpath.relationshiptypeid))
# the mirrored row already exists
db.session.add(AssetRelationship(sourceassetid=pc.assetid, targetassetid=bayb.assetid,
relationshiptypeid=controls.relationshiptypeid))
db.session.commit()
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
'sourceassetid': pc.assetid,
'targetassetid': baya.assetid,
'relationshiptypeid': controls.relationshiptypeid,
})
assert resp.status_code == 201
assert resp.get_json()['data']['propagatedcount'] == 0
count = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, targetassetid=bayb.assetid,
relationshiptypeid=controls.relationshiptypeid).count()
assert count == 1
# ---------------------------------------------------------------------------
# Non-propagating type creates no extras
# ---------------------------------------------------------------------------
def test_non_propagating_type_no_extras(client, db, auth_headers):
atype, controls, dualpath, partof = _setup_types()
# controls has NO propagation rows here
pc = _make_asset('PC-4', atype.assettypeid)
baya = _make_asset('BAY-A4', atype.assettypeid)
bayb = _make_asset('BAY-B4', atype.assettypeid)
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
relationshiptypeid=dualpath.relationshiptypeid))
db.session.commit()
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
'sourceassetid': pc.assetid,
'targetassetid': baya.assetid,
'relationshiptypeid': controls.relationshiptypeid,
})
assert resp.status_code == 201
assert resp.get_json()['data']['propagatedcount'] == 0
assert not _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
# ---------------------------------------------------------------------------
# Directional through-type (partof) does NOT propagate (deferred)
# ---------------------------------------------------------------------------
def test_directional_through_type_does_not_propagate(client, db, auth_headers):
atype, controls, dualpath, partof = _setup_types()
# controls propagates only through partof, which is directional -> inert
db.session.add(RelationshipTypePropagation(
relationshiptypeid=controls.relationshiptypeid,
throughtypeid=partof.relationshiptypeid))
pc = _make_asset('PC-5', atype.assettypeid)
parent = _make_asset('PARENT-5', atype.assettypeid)
child = _make_asset('CHILD-5', atype.assettypeid)
db.session.add(AssetRelationship(sourceassetid=child.assetid, targetassetid=parent.assetid,
relationshiptypeid=partof.relationshiptypeid))
db.session.commit()
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
'sourceassetid': pc.assetid,
'targetassetid': parent.assetid,
'relationshiptypeid': controls.relationshiptypeid,
})
assert resp.status_code == 201
assert resp.get_json()['data']['propagatedcount'] == 0
assert not _rel_exists(pc.assetid, child.assetid, controls.relationshiptypeid)
# ---------------------------------------------------------------------------
# CLI backfill creates missing rows and is idempotent run twice
# ---------------------------------------------------------------------------
def test_cli_backfill_propagates_and_is_idempotent(client, db, auth_headers, runner):
atype, controls, dualpath, partof = _setup_types()
db.session.add(RelationshipTypePropagation(
relationshiptypeid=controls.relationshiptypeid,
throughtypeid=dualpath.relationshiptypeid))
pc = _make_asset('PC-6', atype.assettypeid)
baya = _make_asset('BAY-A6', atype.assettypeid)
bayb = _make_asset('BAY-B6', atype.assettypeid)
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
relationshiptypeid=dualpath.relationshiptypeid))
# legacy-import shape: controls exists only on the primary bay
db.session.add(AssetRelationship(sourceassetid=pc.assetid, targetassetid=baya.assetid,
relationshiptypeid=controls.relationshiptypeid))
db.session.commit()
assert not _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
result = runner.invoke(args=['relationships', 'propagate'])
assert result.exit_code == 0, result.output
assert 'Propagated 1' in result.output
assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
# second run creates nothing more
result2 = runner.invoke(args=['relationships', 'propagate'])
assert result2.exit_code == 0, result2.output
assert 'Propagated 0' in result2.output
count = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, targetassetid=bayb.assetid,
relationshiptypeid=controls.relationshiptypeid).count()
assert count == 1