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:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
<div class="rel-line">
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.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>
|
||||
<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"
|
||||
>
|
||||
×
|
||||
@@ -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' }}
|
||||
<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">-></span>
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rel-arrow"><-</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"
|
||||
>
|
||||
×
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -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.
|
||||
|
||||
106
tests/test_core/test_relationship_directional.py
Normal file
106
tests/test_core/test_relationship_directional.py
Normal 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)
|
||||
249
tests/test_core/test_relationship_propagation.py
Normal file
249
tests/test_core/test_relationship_propagation.py
Normal 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
|
||||
Reference in New Issue
Block a user