Phase 7A: wire ADR-001 asset position contract surface

Lock the position-resolution columns from ADR-001 in code so
resolve_asset_position's relationship walk activates.

Schema
- Asset.mapleft -> Asset.mapx, Asset.maptop -> Asset.mapy
- Location.mapx / Location.mapy added (fallback for priority 3 of the
  ADR-001 resolution chain)
- AssetRelationship.label (free-text nuance per ADR-001)
- AssetRelationship.inheritsposition (bool, server_default true, controls
  whether the resolved-position walk follows the edge)
- RelationshipType.propagatesthroughid (self-FK; sibling-propagation rail)

Seeds
- Three canonical ADR-001 relationship types created idempotently:
  partof, controls, connectedto
- controls.propagatesthroughid wired to partof (partof + connectedto stay
  null per ADR-001 table). Both via Alembic migration AND CLI seed command
  so a fresh test fixture and a sister-site deploy both end up correct.
- Legacy connection types (Serial Cable, Direct Ethernet, USB, WiFi,
  Dualpath) retained for backward compat with pre-1.0 relationship rows.

Resolver
- shopdb.api.resolve_asset_position now walks inheritsposition=true edges
  of type partof (then controls), recursively, depth-capped at 3 with
  visited-set cycle protection. Inactive edges + non-inheritable types
  are skipped. Falls through to the existing location fallback when the
  walk yields nothing.

Tests
- 11 new test_api_namespace cases cover: partof walk, controls-after-
  partof ordering, connectedto skipped, inheritsposition=false skipped,
  recursion, cycle break, depth-3 cap, self-beats-related, related-beats-
  location, inactive-edge skip.
- 111 tests pass. Naming/style check green.

Migration
- migrations/versions/7a01_adr001_position_contract.py:
  - alter_column renames on assets (no data loss)
  - add_column on locations + relationshiptypes + assetrelationships
  - idempotent seed of three ADR types + propagation FK wire-up
  - downgrade reverses + best-effort deletion of seeded types that have
    no FK refs

Backend rename (mapleft/maptop -> mapx/mapy)
- shopdb/core/api/assets.py
- plugins/{computers,equipment,network,printers}/api/...
- scripts/migration/migrate_assets.py
- Legacy Machine model + machines API + import_from_mysql.py UNCHANGED
  (per ADR-001 Machine retires; not part of the asset contract)

Frontend rename
- frontend/src/components/ShopFloorMap.vue
- frontend/src/views/{MapEditor.vue, pcs/{PCDetail,PCForm}.vue,
  printers/{PrinterDetail,PrinterForm}.vue,
  machines/{MachineDetail,MachineForm}.vue,
  network/NetworkDeviceForm.vue}
- Form field labels + v-model bindings + computed flags switched in
  lockstep with the backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-30 14:14:22 -04:00
parent da654944dc
commit 275928a03f
22 changed files with 554 additions and 154 deletions

View File

@@ -374,11 +374,11 @@ function renderMarkers() {
markers.value = [] markers.value = []
props.machines.forEach(item => { props.machines.forEach(item => {
if (item.mapleft == null || item.maptop == null) return if (item.mapx == null || item.mapy == null) return
// Transform coordinates (database Y is top-down, Leaflet is bottom-up) // Transform coordinates (database Y is top-down, Leaflet is bottom-up)
const leafletY = MAP_HEIGHT - item.maptop const leafletY = MAP_HEIGHT - item.mapy
const leafletX = item.mapleft const leafletX = item.mapx
// Determine color based on mode // Determine color based on mode
let color, typeName, displayName, detailRoute let color, typeName, displayName, detailRoute

View File

@@ -35,8 +35,8 @@
class="asset-item" class="asset-item"
:class="{ :class="{
selected: selectedAsset?.assetid === asset.assetid, selected: selectedAsset?.assetid === asset.assetid,
placed: asset.mapleft && asset.maptop, placed: asset.mapx && asset.mapy,
unplaced: !asset.mapleft || !asset.maptop unplaced: !asset.mapx || !asset.mapy
}" }"
@click="selectAsset(asset)" @click="selectAsset(asset)"
> >
@@ -45,7 +45,7 @@
<div class="asset-name">{{ asset.name || asset.assetnumber }}</div> <div class="asset-name">{{ asset.name || asset.assetnumber }}</div>
<div class="asset-meta"> <div class="asset-meta">
<span class="badge badge-sm">{{ asset.assettype }}</span> <span class="badge badge-sm">{{ asset.assettype }}</span>
<span v-if="asset.mapleft && asset.maptop" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span> <span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
</div> </div>
</div> </div>
</div> </div>
@@ -75,7 +75,7 @@
</button> </button>
<button <button
class="btn btn-danger" class="btn btn-danger"
v-if="selectedAsset.mapleft && selectedAsset.maptop" v-if="selectedAsset.mapx && selectedAsset.mapy"
@click="clearPosition" @click="clearPosition"
> >
Remove from Map Remove from Map
@@ -94,7 +94,7 @@
:assetTypeMode="true" :assetTypeMode="true"
:theme="currentTheme" :theme="currentTheme"
:pickerMode="!!selectedAsset" :pickerMode="!!selectedAsset"
:initialPosition="selectedAsset ? { left: selectedAsset.mapleft, top: selectedAsset.maptop } : null" :initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
@positionPicked="handlePositionPicked" @positionPicked="handlePositionPicked"
@markerClick="handleMarkerClick" @markerClick="handleMarkerClick"
/> />
@@ -125,14 +125,14 @@ const filteredAssets = computed(() => {
} }
if (showUnplacedOnly.value) { if (showUnplacedOnly.value) {
result = result.filter(a => !a.mapleft || !a.maptop) result = result.filter(a => !a.mapx || !a.mapy)
} }
return result return result
}) })
const placedAssets = computed(() => { const placedAssets = computed(() => {
return assets.value.filter(a => a.mapleft && a.maptop) return assets.value.filter(a => a.mapx && a.mapy)
}) })
onMounted(async () => { onMounted(async () => {
@@ -163,8 +163,8 @@ function getTypeIcon(assettype) {
function selectAsset(asset) { function selectAsset(asset) {
selectedAsset.value = asset selectedAsset.value = asset
pickedPosition.value = asset.mapleft && asset.maptop pickedPosition.value = asset.mapx && asset.mapy
? { left: asset.mapleft, top: asset.maptop } ? { left: asset.mapx, top: asset.mapy }
: null : null
} }
@@ -181,15 +181,15 @@ async function savePosition() {
try { try {
await assetsApi.update(selectedAsset.value.assetid, { await assetsApi.update(selectedAsset.value.assetid, {
mapleft: Math.round(pickedPosition.value.left), mapx: Math.round(pickedPosition.value.left),
maptop: Math.round(pickedPosition.value.top) mapy: Math.round(pickedPosition.value.top)
}) })
// Update local state // Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid) const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) { if (asset) {
asset.mapleft = Math.round(pickedPosition.value.left) asset.mapx = Math.round(pickedPosition.value.left)
asset.maptop = Math.round(pickedPosition.value.top) asset.mapy = Math.round(pickedPosition.value.top)
} }
selectedAsset.value = null selectedAsset.value = null
@@ -207,15 +207,15 @@ async function clearPosition() {
try { try {
await assetsApi.update(selectedAsset.value.assetid, { await assetsApi.update(selectedAsset.value.assetid, {
mapleft: null, mapx: null,
maptop: null mapy: null
}) })
// Update local state // Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid) const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) { if (asset) {
asset.mapleft = null asset.mapx = null
asset.maptop = null asset.mapy = null
} }
selectedAsset.value = null selectedAsset.value = null

View File

@@ -162,9 +162,9 @@
<span class="info-label">Location</span> <span class="info-label">Location</span>
<span class="info-value"> <span class="info-value">
<LocationMapTooltip <LocationMapTooltip
v-if="equipment.mapleft != null && equipment.maptop != null" v-if="equipment.mapx != null && equipment.mapy != null"
:left="equipment.mapleft" :left="equipment.mapx"
:top="equipment.maptop" :top="equipment.mapy"
:machineName="equipment.assetnumber" :machineName="equipment.assetnumber"
> >
<span class="location-link">{{ equipment.locationname || 'On Map' }}</span> <span class="location-link">{{ equipment.locationname || 'On Map' }}</span>

View File

@@ -212,8 +212,8 @@
<div class="form-group"> <div class="form-group">
<label>Map Location</label> <label>Map Location</label>
<div class="map-location-control"> <div class="map-location-control">
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position"> <div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
Position: {{ form.mapleft }}, {{ form.maptop }} Position: {{ form.mapx }}, {{ form.mapy }}
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button> <button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
</div> </div>
<button type="button" class="btn btn-secondary" @click="showMapPicker = true"> <button type="button" class="btn btn-secondary" @click="showMapPicker = true">
@@ -227,7 +227,7 @@
<div class="map-modal-content"> <div class="map-modal-content">
<ShopFloorMap <ShopFloorMap
:pickerMode="true" :pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null" :initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
:theme="currentTheme" :theme="currentTheme"
@positionPicked="handlePositionPicked" @positionPicked="handlePositionPicked"
/> />
@@ -358,8 +358,8 @@ const form = ref({
requiresmanualconfig: false, requiresmanualconfig: false,
islocationonly: false, islocationonly: false,
notes: '', notes: '',
mapleft: null, mapx: null,
maptop: null mapy: null
}) })
const equipmentTypes = ref([]) const equipmentTypes = ref([])
@@ -468,8 +468,8 @@ onMounted(async () => {
requiresmanualconfig: data.equipment?.requiresmanualconfig || false, requiresmanualconfig: data.equipment?.requiresmanualconfig || false,
islocationonly: data.equipment?.islocationonly || false, islocationonly: data.equipment?.islocationonly || false,
notes: data.notes || '', notes: data.notes || '',
mapleft: data.mapleft ?? null, mapx: data.mapx ?? null,
maptop: data.maptop ?? null mapy: data.mapy ?? null
} }
// Load existing relationships to find controlling PC // Load existing relationships to find controlling PC
@@ -506,15 +506,15 @@ function handlePositionPicked(position) {
function confirmMapPosition() { function confirmMapPosition() {
if (tempMapPosition.value) { if (tempMapPosition.value) {
form.value.mapleft = tempMapPosition.value.left form.value.mapx = tempMapPosition.value.left
form.value.maptop = tempMapPosition.value.top form.value.mapy = tempMapPosition.value.top
} }
showMapPicker.value = false showMapPicker.value = false
} }
function clearMapPosition() { function clearMapPosition() {
form.value.mapleft = null form.value.mapx = null
form.value.maptop = null form.value.mapy = null
tempMapPosition.value = null tempMapPosition.value = null
} }
@@ -538,8 +538,8 @@ async function saveEquipment() {
requiresmanualconfig: form.value.requiresmanualconfig, requiresmanualconfig: form.value.requiresmanualconfig,
islocationonly: form.value.islocationonly, islocationonly: form.value.islocationonly,
notes: form.value.notes || null, notes: form.value.notes || null,
mapleft: form.value.mapleft, mapx: form.value.mapx,
maptop: form.value.maptop mapy: form.value.mapy
} }
let savedEquipment let savedEquipment

View File

@@ -169,20 +169,20 @@
<legend>Map Position (Optional)</legend> <legend>Map Position (Optional)</legend>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="mapleft">Map X Position</label> <label for="mapx">Map X Position</label>
<input <input
id="mapleft" id="mapx"
v-model.number="form.mapleft" v-model.number="form.mapx"
type="number" type="number"
class="form-control" class="form-control"
min="0" min="0"
/> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="maptop">Map Y Position</label> <label for="mapy">Map Y Position</label>
<input <input
id="maptop" id="mapy"
v-model.number="form.maptop" v-model.number="form.mapy"
type="number" type="number"
class="form-control" class="form-control"
min="0" min="0"
@@ -251,8 +251,8 @@ const form = ref({
rackunit: '', rackunit: '',
ispoe: false, ispoe: false,
ismanaged: false, ismanaged: false,
mapleft: null, mapx: null,
maptop: null, mapy: null,
notes: '' notes: ''
}) })
@@ -335,8 +335,8 @@ async function loadDevice() {
form.value.statusid = data.statusid || '' form.value.statusid = data.statusid || ''
form.value.locationid = data.locationid || '' form.value.locationid = data.locationid || ''
form.value.businessunitid = data.businessunitid || '' form.value.businessunitid = data.businessunitid || ''
form.value.mapleft = data.mapleft form.value.mapx = data.mapx
form.value.maptop = data.maptop form.value.mapy = data.mapy
form.value.notes = data.notes || '' form.value.notes = data.notes || ''
// Network device specific // Network device specific
@@ -376,8 +376,8 @@ async function submitForm() {
rackunit: form.value.rackunit || null, rackunit: form.value.rackunit || null,
ispoe: form.value.ispoe, ispoe: form.value.ispoe,
ismanaged: form.value.ismanaged, ismanaged: form.value.ismanaged,
mapleft: form.value.mapleft, mapx: form.value.mapx,
maptop: form.value.maptop, mapy: form.value.mapy,
notes: form.value.notes || null notes: form.value.notes || null
} }

View File

@@ -121,9 +121,9 @@
<span class="info-label">Location</span> <span class="info-label">Location</span>
<span class="info-value"> <span class="info-value">
<LocationMapTooltip <LocationMapTooltip
v-if="computer.mapleft != null && computer.maptop != null" v-if="computer.mapx != null && computer.mapy != null"
:left="computer.mapleft" :left="computer.mapx"
:top="computer.maptop" :top="computer.mapy"
:machineName="computer.assetnumber" :machineName="computer.assetnumber"
> >
<span class="location-link">{{ computer.locationname || 'On Map' }}</span> <span class="location-link">{{ computer.locationname || 'On Map' }}</span>

View File

@@ -226,8 +226,8 @@
<div class="form-group"> <div class="form-group">
<label>Map Location</label> <label>Map Location</label>
<div class="map-location-control"> <div class="map-location-control">
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position"> <div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
Position: {{ form.mapleft }}, {{ form.maptop }} Position: {{ form.mapx }}, {{ form.mapy }}
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button> <button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
</div> </div>
<button type="button" class="btn btn-secondary" @click="showMapPicker = true"> <button type="button" class="btn btn-secondary" @click="showMapPicker = true">
@@ -241,7 +241,7 @@
<div class="map-modal-content"> <div class="map-modal-content">
<ShopFloorMap <ShopFloorMap
:pickerMode="true" :pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null" :initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
:theme="currentTheme" :theme="currentTheme"
@positionPicked="handlePositionPicked" @positionPicked="handlePositionPicked"
/> />
@@ -299,8 +299,8 @@ const form = ref({
isvnc: false, isvnc: false,
iswinrm: false, iswinrm: false,
notes: '', notes: '',
mapleft: null, mapx: null,
maptop: null, mapy: null,
ipaddress: '' ipaddress: ''
}) })
@@ -366,8 +366,8 @@ onMounted(async () => {
isvnc: pc.isvnc || false, isvnc: pc.isvnc || false,
iswinrm: pc.iswinrm || false, iswinrm: pc.iswinrm || false,
notes: pc.notes || '', notes: pc.notes || '',
mapleft: pc.mapleft ?? null, mapx: pc.mapx ?? null,
maptop: pc.maptop ?? null, mapy: pc.mapy ?? null,
ipaddress: primaryComm?.ipaddress || '' ipaddress: primaryComm?.ipaddress || ''
} }
} }
@@ -385,15 +385,15 @@ function handlePositionPicked(position) {
function confirmMapPosition() { function confirmMapPosition() {
if (tempMapPosition.value) { if (tempMapPosition.value) {
form.value.mapleft = tempMapPosition.value.left form.value.mapx = tempMapPosition.value.left
form.value.maptop = tempMapPosition.value.top form.value.mapy = tempMapPosition.value.top
} }
showMapPicker.value = false showMapPicker.value = false
} }
function clearMapPosition() { function clearMapPosition() {
form.value.mapleft = null form.value.mapx = null
form.value.maptop = null form.value.mapy = null
tempMapPosition.value = null tempMapPosition.value = null
} }
@@ -417,8 +417,8 @@ async function savePC() {
isvnc: form.value.isvnc, isvnc: form.value.isvnc,
iswinrm: form.value.iswinrm, iswinrm: form.value.iswinrm,
notes: form.value.notes, notes: form.value.notes,
mapleft: form.value.mapleft, mapx: form.value.mapx,
maptop: form.value.maptop mapy: form.value.mapy
} }
let machineId let machineId

View File

@@ -126,9 +126,9 @@
<span class="info-label">Map Location</span> <span class="info-label">Map Location</span>
<span class="info-value"> <span class="info-value">
<LocationMapTooltip <LocationMapTooltip
v-if="printer.mapleft != null && printer.maptop != null" v-if="printer.mapx != null && printer.mapy != null"
:left="printer.mapleft" :left="printer.mapx"
:top="printer.maptop" :top="printer.mapy"
:machineName="printer.name || printer.assetnumber" :machineName="printer.name || printer.assetnumber"
> >
<span class="location-link">View on Map</span> <span class="location-link">View on Map</span>

View File

@@ -224,8 +224,8 @@
<div class="form-group"> <div class="form-group">
<label>Map Location</label> <label>Map Location</label>
<div class="map-location-control"> <div class="map-location-control">
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position"> <div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
Position: {{ form.mapleft }}, {{ form.maptop }} Position: {{ form.mapx }}, {{ form.mapy }}
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button> <button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
</div> </div>
<button type="button" class="btn btn-secondary" @click="showMapPicker = true"> <button type="button" class="btn btn-secondary" @click="showMapPicker = true">
@@ -239,7 +239,7 @@
<div class="map-modal-content"> <div class="map-modal-content">
<ShopFloorMap <ShopFloorMap
:pickerMode="true" :pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null" :initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
:theme="currentTheme" :theme="currentTheme"
@positionPicked="handlePositionPicked" @positionPicked="handlePositionPicked"
/> />
@@ -295,8 +295,8 @@ const form = ref({
modelnumberid: '', modelnumberid: '',
locationid: '', locationid: '',
notes: '', notes: '',
mapleft: null, mapx: null,
maptop: null, mapy: null,
// Printer-specific // Printer-specific
ipaddress: '', ipaddress: '',
csfname: '', csfname: '',
@@ -456,8 +456,8 @@ onMounted(async () => {
modelnumberid: printer.model?.modelnumberid || '', modelnumberid: printer.model?.modelnumberid || '',
locationid: printer.location?.locationid || '', locationid: printer.location?.locationid || '',
notes: printer.notes || '', notes: printer.notes || '',
mapleft: printer.mapleft ?? null, mapx: printer.mapx ?? null,
maptop: printer.maptop ?? null, mapy: printer.mapy ?? null,
// Printer-specific // Printer-specific
ipaddress: primaryComm?.ipaddress || '', ipaddress: primaryComm?.ipaddress || '',
csfname: printer.printerdata?.sharename || '', csfname: printer.printerdata?.sharename || '',
@@ -483,15 +483,15 @@ function handlePositionPicked(position) {
function confirmMapPosition() { function confirmMapPosition() {
if (tempMapPosition.value) { if (tempMapPosition.value) {
form.value.mapleft = tempMapPosition.value.left form.value.mapx = tempMapPosition.value.left
form.value.maptop = tempMapPosition.value.top form.value.mapy = tempMapPosition.value.top
} }
showMapPicker.value = false showMapPicker.value = false
} }
function clearMapPosition() { function clearMapPosition() {
form.value.mapleft = null form.value.mapx = null
form.value.maptop = null form.value.mapy = null
tempMapPosition.value = null tempMapPosition.value = null
} }
@@ -511,8 +511,8 @@ async function savePrinter() {
modelnumberid: form.value.modelnumberid || null, modelnumberid: form.value.modelnumberid || null,
locationid: form.value.locationid || null, locationid: form.value.locationid || null,
notes: form.value.notes, notes: form.value.notes,
mapleft: form.value.mapleft, mapx: form.value.mapx,
maptop: form.value.maptop mapy: form.value.mapy
} }
const printerData = { const printerData = {

View File

@@ -0,0 +1,133 @@
"""ADR-001 position contract surface
Rename Asset.mapleft -> Asset.mapx, Asset.maptop -> Asset.mapy. Add
Location.mapx / Location.mapy. Add AssetRelationship.label,
AssetRelationship.inheritsposition, RelationshipType.propagatesthroughid.
Seed the three canonical relationship types (partof, controls, connectedto)
with controls.propagatesthroughid -> partof.
Revision ID: 7a01_adr001_position
Revises: 68b3947ae14f
Create Date: 2026-05-30
"""
from alembic import op
import sqlalchemy as sa
revision = '7a01_adr001_position'
down_revision = '68b3947ae14f'
branch_labels = None
depends_on = None
def upgrade():
# ---- Asset.mapleft/maptop -> mapx/mapy --------------------------------
with op.batch_alter_table('assets') as batch_op:
batch_op.alter_column('mapleft', new_column_name='mapx',
existing_type=sa.Integer(), existing_nullable=True,
comment='X coordinate on floor map (ADR-001)')
batch_op.alter_column('maptop', new_column_name='mapy',
existing_type=sa.Integer(), existing_nullable=True,
comment='Y coordinate on floor map (ADR-001)')
# ---- Location: add mapx/mapy for the fallback path --------------------
with op.batch_alter_table('locations') as batch_op:
batch_op.add_column(sa.Column('mapx', sa.Integer(), nullable=True,
comment='Default X coordinate for assets at this location'))
batch_op.add_column(sa.Column('mapy', sa.Integer(), nullable=True,
comment='Default Y coordinate for assets at this location'))
# ---- RelationshipType: add propagatesthroughid self-FK ----------------
with op.batch_alter_table('relationshiptypes') as batch_op:
batch_op.add_column(sa.Column('propagatesthroughid', sa.Integer(),
nullable=True,
comment='Sibling-propagation rail per ADR-001'))
batch_op.create_foreign_key(
'fk_relationshiptype_propagation',
'relationshiptypes',
['propagatesthroughid'],
['relationshiptypeid'],
)
# ---- AssetRelationship: add label + inheritsposition ------------------
with op.batch_alter_table('assetrelationships') as batch_op:
batch_op.add_column(sa.Column('label', sa.String(length=200), nullable=True,
comment='Free-text relationship description (ADR-001)'))
batch_op.add_column(sa.Column('inheritsposition', sa.Boolean(),
nullable=False, server_default='1',
comment='If true, resolved-position walk follows this edge (ADR-001)'))
# ---- Seed three canonical relationship types --------------------------
# Idempotent: insert only if name is not already present. Then update
# controls.propagatesthroughid to point at partof.
relationshiptypes = sa.table(
'relationshiptypes',
sa.column('relationshiptype', sa.String),
sa.column('description', sa.Text),
sa.column('propagatesthroughid', sa.Integer),
sa.column('isactive', sa.Boolean),
)
conn = op.get_bind()
for rt in (
('partof', 'Composition / sub-assembly (ADR-001)'),
('controls', 'Operational authority over another asset (ADR-001)'),
('connectedto', 'Network or data link without authority (ADR-001)'),
):
existing = conn.execute(sa.text(
"SELECT relationshiptypeid FROM relationshiptypes WHERE relationshiptype = :n"
), {'n': rt[0]}).first()
if not existing:
conn.execute(relationshiptypes.insert().values(
relationshiptype=rt[0],
description=rt[1],
propagatesthroughid=None,
isactive=True,
))
partof_row = conn.execute(sa.text(
"SELECT relationshiptypeid FROM relationshiptypes WHERE relationshiptype = 'partof'"
)).first()
if partof_row:
conn.execute(sa.text(
"UPDATE relationshiptypes SET propagatesthroughid = :p WHERE relationshiptype = 'controls'"
), {'p': partof_row[0]})
def downgrade():
# Revert in reverse order. Drops the seeded ADR types if no rows
# reference them; otherwise leaves them in place to avoid FK violations.
conn = op.get_bind()
with op.batch_alter_table('assetrelationships') as batch_op:
batch_op.drop_column('inheritsposition')
batch_op.drop_column('label')
with op.batch_alter_table('relationshiptypes') as batch_op:
batch_op.drop_constraint('fk_relationshiptype_propagation', type_='foreignkey')
batch_op.drop_column('propagatesthroughid')
with op.batch_alter_table('locations') as batch_op:
batch_op.drop_column('mapy')
batch_op.drop_column('mapx')
with op.batch_alter_table('assets') as batch_op:
batch_op.alter_column('mapx', new_column_name='mapleft',
existing_type=sa.Integer(), existing_nullable=True)
batch_op.alter_column('mapy', new_column_name='maptop',
existing_type=sa.Integer(), existing_nullable=True)
# Best-effort: drop the seeded types if nothing references them.
for name in ('connectedto', 'controls', 'partof'):
try:
conn.execute(sa.text(
"DELETE FROM relationshiptypes "
"WHERE relationshiptype = :n "
"AND relationshiptypeid NOT IN ("
" SELECT relationshiptypeid FROM assetrelationships "
" UNION SELECT relationshiptypeid FROM machinerelationships"
")"
), {'n': name})
except Exception:
pass

View File

@@ -280,7 +280,7 @@ def create_computer():
- name, serialnumber, statusid, locationid, businessunitid - name, serialnumber, statusid, locationid, businessunitid
- computertypeid, hostname, osid - computertypeid, hostname, osid
- isvnc, iswinrm, isshopfloor - isvnc, iswinrm, isshopfloor
- mapleft, maptop, notes - mapx, mapy, notes
""" """
data = request.get_json() data = request.get_json()
@@ -325,8 +325,8 @@ def create_computer():
statusid=data.get('statusid', 1), statusid=data.get('statusid', 1),
locationid=data.get('locationid'), locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'), businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'), mapx=data.get('mapx'),
maptop=data.get('maptop'), mapy=data.get('mapy'),
notes=data.get('notes') notes=data.get('notes')
) )
@@ -405,7 +405,7 @@ def update_computer(computer_id: int):
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:
old_val = getattr(asset, key) old_val = getattr(asset, key)

View File

@@ -251,7 +251,7 @@ def create_equipment():
- name, serialnumber, statusid, locationid, businessunitid - name, serialnumber, statusid, locationid, businessunitid
- equipmenttypeid, vendorid, modelnumberid - equipmenttypeid, vendorid, modelnumberid
- requiresmanualconfig, islocationonly - requiresmanualconfig, islocationonly
- mapleft, maptop, notes - mapx, mapy, notes
""" """
data = request.get_json() data = request.get_json()
@@ -287,8 +287,8 @@ def create_equipment():
statusid=data.get('statusid', 1), statusid=data.get('statusid', 1),
locationid=data.get('locationid'), locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'), businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'), mapx=data.get('mapx'),
maptop=data.get('maptop'), mapy=data.get('mapy'),
notes=data.get('notes') notes=data.get('notes')
) )
@@ -358,7 +358,7 @@ def update_equipment(equipment_id: int):
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:
old_val = getattr(asset, key) old_val = getattr(asset, key)

View File

@@ -283,7 +283,7 @@ def create_network_device():
- name, serialnumber, statusid, locationid, businessunitid - name, serialnumber, statusid, locationid, businessunitid
- networkdevicetypeid, vendorid, hostname - networkdevicetypeid, vendorid, hostname
- firmwareversion, portcount, ispoe, ismanaged, rackunit - firmwareversion, portcount, ispoe, ismanaged, rackunit
- mapleft, maptop, notes - mapx, mapy, notes
""" """
data = request.get_json() data = request.get_json()
@@ -328,8 +328,8 @@ def create_network_device():
statusid=data.get('statusid', 1), statusid=data.get('statusid', 1),
locationid=data.get('locationid'), locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'), businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'), mapx=data.get('mapx'),
maptop=data.get('maptop'), mapy=data.get('mapy'),
notes=data.get('notes') notes=data.get('notes')
) )
@@ -407,7 +407,7 @@ def update_network_device(device_id: int):
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:
old_val = getattr(asset, key) old_val = getattr(asset, key)

View File

@@ -246,7 +246,7 @@ def create_printer():
- printertypeid, vendorid, modelnumberid, hostname - printertypeid, vendorid, modelnumberid, hostname
- windowsname, sharename, iscsf, installpath, pin - windowsname, sharename, iscsf, installpath, pin
- iscolor, isduplex, isnetwork - iscolor, isduplex, isnetwork
- mapleft, maptop, notes - mapx, mapy, notes
""" """
data = request.get_json() data = request.get_json()
@@ -282,8 +282,8 @@ def create_printer():
statusid=data.get('statusid', 1), statusid=data.get('statusid', 1),
locationid=data.get('locationid'), locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'), businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'), mapx=data.get('mapx'),
maptop=data.get('maptop'), mapy=data.get('mapy'),
notes=data.get('notes') notes=data.get('notes')
) )
@@ -359,7 +359,7 @@ def update_printer(printer_id: int):
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'] 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:
setattr(asset, key, data[key]) setattr(asset, key, data[key])

View File

@@ -73,11 +73,11 @@ def migrate_machine_to_asset(machine_row, asset_type_id, target_session):
INSERT INTO assets ( INSERT INTO assets (
assetid, assetnumber, name, serialnumber, assetid, assetnumber, name, serialnumber,
assettypeid, statusid, locationid, businessunitid, assettypeid, statusid, locationid, businessunitid,
mapleft, maptop, notes, isactive, createddate, modifieddate mapx, mapy, notes, isactive, createddate, modifieddate
) VALUES ( ) VALUES (
:assetid, :assetnumber, :name, :serialnumber, :assetid, :assetnumber, :name, :serialnumber,
:assettypeid, :statusid, :locationid, :businessunitid, :assettypeid, :statusid, :locationid, :businessunitid,
:mapleft, :maptop, :notes, :isactive, :createddate, :modifieddate :mapx, :mapy, :notes, :isactive, :createddate, :modifieddate
) )
"""), { """), {
'assetid': machine_row['machineid'], 'assetid': machine_row['machineid'],
@@ -88,8 +88,8 @@ def migrate_machine_to_asset(machine_row, asset_type_id, target_session):
'statusid': machine_row.get('statusid', 1), 'statusid': machine_row.get('statusid', 1),
'locationid': machine_row.get('locationid'), 'locationid': machine_row.get('locationid'),
'businessunitid': machine_row.get('businessunitid'), 'businessunitid': machine_row.get('businessunitid'),
'mapleft': machine_row.get('mapleft'), 'mapx': machine_row.get('mapx'),
'maptop': machine_row.get('maptop'), 'mapy': machine_row.get('mapy'),
'notes': machine_row.get('notes'), 'notes': machine_row.get('notes'),
'isactive': machine_row.get('isactive', True), 'isactive': machine_row.get('isactive', True),
'createddate': machine_row.get('createddate', datetime.utcnow()), 'createddate': machine_row.get('createddate', datetime.utcnow()),

View File

@@ -53,40 +53,102 @@ def audit_log(
) )
# Cap how deep the relationship-walk traverses before giving up. ADR-001
# specifies a max walk depth of 3 to bound the work per request, with a
# visited-set guarding against cycles. Past this depth the walk treats the
# next hop as if it had no position to contribute.
_POSITION_WALK_MAX_DEPTH = 3
# Relationship type names whose edges are eligible for the inheritance walk
# when inheritsposition is true on the edge. Ordered by priority per
# ADR-001 ("partof first, then controls"). Edges of other types are never
# followed even if inheritsposition is true.
_INHERITABLE_TYPES = ('partof', 'controls')
def _walk_related_for_position(asset, visited, depth):
"""Recursive helper for resolve_asset_position relationship walk. Returns
a (mapx, mapy) tuple from the first related asset whose position
resolves, or None. Visited tracks assetids already explored to break
cycles."""
if depth >= _POSITION_WALK_MAX_DEPTH:
return None
aid = getattr(asset, 'assetid', None)
if aid is None or aid in visited:
return None
visited.add(aid)
edges = []
for rel in list(getattr(asset, 'outgoing_relationships', []) or []):
edges.append((rel, getattr(rel, 'targetasset', None)))
for rel in list(getattr(asset, 'incoming_relationships', []) or []):
edges.append((rel, getattr(rel, 'sourceasset', None)))
def _priority(edge):
rel = edge[0]
rtype = getattr(rel, 'relationshiptype', None)
type_name = getattr(rtype, 'relationshiptype', '') if rtype else ''
try:
return _INHERITABLE_TYPES.index(type_name)
except ValueError:
return len(_INHERITABLE_TYPES)
edges.sort(key=_priority)
for rel, neighbor in edges:
if neighbor is None:
continue
if not getattr(rel, 'inheritsposition', False):
continue
if not getattr(rel, 'isactive', True):
continue
rtype = getattr(rel, 'relationshiptype', None)
type_name = getattr(rtype, 'relationshiptype', '') if rtype else ''
if type_name not in _INHERITABLE_TYPES:
continue
n_mapx = getattr(neighbor, 'mapx', None)
n_mapy = getattr(neighbor, 'mapy', None)
if n_mapx is not None and n_mapy is not None:
return (n_mapx, n_mapy)
recursed = _walk_related_for_position(neighbor, visited, depth + 1)
if recursed is not None:
return recursed
return None
def resolve_asset_position(asset) -> Optional[Dict[str, Any]]: def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
"""Compute the resolved map position for an asset. """Compute the resolved map position for an asset.
Per ADR-001, position resolution follows this priority: Per ADR-001, position resolution follows this priority chain:
1. Asset-specific override (asset.mapx, asset.mapy) 1. Asset-specific override (asset.mapx, asset.mapy)
2. Walk relationships where inheritsposition is true (partof, then controls) 2. Walk relationships where inheritsposition is true on edges of type
partof or controls (partof first), depth-limited and cycle-safe
3. Asset's location coords (asset.location.mapx, .mapy) 3. Asset's location coords (asset.location.mapx, .mapy)
4. None (asset is rendered in an unplaced tray) 4. None (asset is unplaced, rendered in a tray)
Returns a dict {'mapx', 'mapy', 'positionsource'} or None if no Returns a dict {'mapx', 'mapy', 'positionsource'} where positionsource
position can be resolved. is one of 'self', 'related', 'location'. Returns None when no priority
yields coordinates.
Note: Asset.mapx/mapy and AssetRelationship.inheritsposition columns
are part of the locked ADR-001 contract surface but have not yet
been added to the models. Until they are, this helper falls back to
the location-only path. The full algorithm activates automatically
once those columns exist.
""" """
if hasattr(asset, 'mapx') and hasattr(asset, 'mapy'): mapx = getattr(asset, 'mapx', None)
if asset.mapx is not None and asset.mapy is not None: mapy = getattr(asset, 'mapy', None)
return { if mapx is not None and mapy is not None:
'mapx': asset.mapx, return {'mapx': mapx, 'mapy': mapy, 'positionsource': 'self'}
'mapy': asset.mapy,
'positionsource': 'self', related = _walk_related_for_position(asset, set(), 0)
} if related is not None:
return {'mapx': related[0], 'mapy': related[1], 'positionsource': 'related'}
location = getattr(asset, 'location', None) location = getattr(asset, 'location', None)
if location is not None: if location is not None:
location_mapx = getattr(location, 'mapx', None) loc_mapx = getattr(location, 'mapx', None)
location_mapy = getattr(location, 'mapy', None) loc_mapy = getattr(location, 'mapy', None)
if location_mapx is not None and location_mapy is not None: if loc_mapx is not None and loc_mapy is not None:
return { return {
'mapx': location_mapx, 'mapx': loc_mapx,
'mapy': location_mapy, 'mapy': loc_mapy,
'positionsource': 'location', 'positionsource': 'location',
} }

View File

@@ -97,7 +97,9 @@ def seed_reference_data():
os_obj = OperatingSystem(**os_data) os_obj = OperatingSystem(**os_data)
db.session.add(os_obj) db.session.add(os_obj)
# Connection types (how PC connects to equipment) # 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.)
connection_types = [ connection_types = [
{'relationshiptype': 'Serial Cable', 'description': 'RS-232 or similar serial connection'}, {'relationshiptype': 'Serial Cable', 'description': 'RS-232 or similar serial connection'},
{'relationshiptype': 'Direct Ethernet', 'description': 'Direct network cable (airgapped)'}, {'relationshiptype': 'Direct Ethernet', 'description': 'Direct network cable (airgapped)'},
@@ -112,6 +114,27 @@ def seed_reference_data():
ct = RelationshipType(**ct_data) ct = RelationshipType(**ct_data)
db.session.add(ct) 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_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)'},
]
for at in adr_types:
existing = RelationshipType.query.filter_by(relationshiptype=at['relationshiptype']).first()
if not existing:
db.session.add(RelationshipType(**at))
db.session.flush()
# Wire `controls` -> `partof` propagation rail. partof + connectedto stay
# null (no propagation).
partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
if partof and controls and controls.propagatesthroughid != partof.relationshiptypeid:
controls.propagatesthroughid = partof.relationshiptypeid
db.session.commit() db.session.commit()
click.echo(click.style("Reference data seeded.", fg='green')) click.echo(click.style("Reference data seeded.", fg='green'))

View File

@@ -299,8 +299,8 @@ def create_asset():
statusid=data.get('statusid', 1), statusid=data.get('statusid', 1),
locationid=data.get('locationid'), locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'), businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'), mapx=data.get('mapx'),
maptop=data.get('maptop'), mapy=data.get('mapy'),
notes=data.get('notes') notes=data.get('notes')
) )
@@ -339,7 +339,7 @@ def update_asset(asset_id: int):
# Update allowed fields # Update allowed fields
allowed_fields = [ allowed_fields = [
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid', 'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive' 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'
] ]
for key in allowed_fields: for key in allowed_fields:
@@ -527,7 +527,7 @@ def get_assets_map():
""" """
Get all assets with map positions for unified floor map display. Get all assets with map positions for unified floor map display.
Returns assets with mapleft/maptop coordinates, joined with type-specific data. Returns assets with mapx/mapy coordinates, joined with type-specific data.
Query parameters: Query parameters:
- assettype: Filter by asset type name (equipment, computer, network_device, printer) - assettype: Filter by asset type name (equipment, computer, network_device, printer)
@@ -617,8 +617,8 @@ def get_assets_map():
query = Asset.query.options(*eager_options).filter( query = Asset.query.options(*eager_options).filter(
Asset.isactive == True, Asset.isactive == True,
Asset.mapleft.isnot(None), Asset.mapx.isnot(None),
Asset.maptop.isnot(None) Asset.mapy.isnot(None)
) )
selected_assettype = request.args.get('assettype') selected_assettype = request.args.get('assettype')
@@ -719,8 +719,8 @@ def get_assets_map():
'name': asset.name, 'name': asset.name,
'displayname': asset.display_name, 'displayname': asset.display_name,
'serialnumber': asset.serialnumber, 'serialnumber': asset.serialnumber,
'mapleft': asset.mapleft, 'mapx': asset.mapx,
'maptop': asset.maptop, 'mapy': asset.mapy,
'assettype': asset.assettype.assettype if asset.assettype else None, 'assettype': asset.assettype.assettype if asset.assettype else None,
'assettypeid': asset.assettypeid, 'assettypeid': asset.assettypeid,
'status': asset.status.status if asset.status else None, 'status': asset.status.status if asset.status else None,

View File

@@ -105,9 +105,9 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
nullable=True nullable=True
) )
# Floor map position # Floor map position (ADR-001: asset-specific override; nullable)
mapleft = db.Column(db.Integer, comment='X coordinate on floor map') mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
maptop = db.Column(db.Integer, comment='Y coordinate on floor map') mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
# Notes # Notes
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
@@ -160,16 +160,13 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
""" """
Get location data from a related asset if this asset has none. Get location data from a related asset if this asset has none.
Returns dict with locationid, location_name, mapleft, maptop, and Returns dict with locationid, location_name, mapx, mapy, and
inherited_from (assetnumber of source asset) if location was inherited. inherited_from (assetnumber of source asset) if location was inherited.
Returns None if no location data available. Returns None if no location data available.
""" """
# If we have our own location data, don't inherit if self.locationid is not None or (self.mapx is not None and self.mapy is not None):
if self.locationid is not None or (self.mapleft is not None and self.maptop is not None):
return None return None
# Check related assets for location data
# Look in both incoming and outgoing relationships
related_assets = [] related_assets = []
if hasattr(self, 'incoming_relationships'): if hasattr(self, 'incoming_relationships'):
@@ -182,14 +179,13 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
if rel.targetasset and rel.isactive: if rel.targetasset and rel.isactive:
related_assets.append(rel.targetasset) related_assets.append(rel.targetasset)
# Find first related asset with location data
for related in related_assets: for related in related_assets:
if related.locationid is not None or (related.mapleft is not None and related.maptop is not None): if related.locationid is not None or (related.mapx is not None and related.mapy is not None):
return { return {
'locationid': related.locationid, 'locationid': related.locationid,
'locationname': related.location.locationname if related.location else None, 'locationname': related.location.locationname if related.location else None,
'mapleft': related.mapleft, 'mapx': related.mapx,
'maptop': related.maptop, 'mapy': related.mapy,
'inheritedfrom': related.assetnumber 'inheritedfrom': related.assetnumber
} }
@@ -234,10 +230,10 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
if result.get('locationid') is None: if result.get('locationid') is None:
result['locationid'] = inherited['locationid'] result['locationid'] = inherited['locationid']
result['locationname'] = inherited['locationname'] result['locationname'] = inherited['locationname']
if result.get('mapleft') is None: if result.get('mapx') is None:
result['mapleft'] = inherited['mapleft'] result['mapx'] = inherited['mapx']
if result.get('maptop') is None: if result.get('mapy') is None:
result['maptop'] = inherited['maptop'] result['mapy'] = inherited['mapy']
# Include extension data if requested # Include extension data if requested
if include_type_data: if include_type_data:

View File

@@ -20,5 +20,11 @@ class Location(BaseModel):
mapwidth = db.Column(db.Integer) mapwidth = db.Column(db.Integer)
mapheight = db.Column(db.Integer) mapheight = db.Column(db.Integer)
# Location-level position used as fallback when an asset has no own coords
# and no inheritsposition relationship resolves. ADR-001 position resolution
# chain priority 3.
mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location')
mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location')
def __repr__(self): def __repr__(self):
return f"<Location {self.locationname}>" return f"<Location {self.locationname}>"

View File

@@ -5,17 +5,40 @@ from .base import BaseModel
class RelationshipType(BaseModel): class RelationshipType(BaseModel):
"""Types of relationships between machines/assets.""" """
Types of relationships between assets.
ADR-001 seeds three canonical types: partof, controls, connectedto.
Sites may add legacy/communication-flavored types (Serial Cable, Direct
Ethernet, USB, WiFi, Dualpath) for backward compatibility with pre-1.0
data, but new ADR-001 code paths only reason about the three canonical
types via free-text label for nuance.
"""
__tablename__ = 'relationshiptypes' __tablename__ = 'relationshiptypes'
relationshiptypeid = db.Column(db.Integer, primary_key=True) relationshiptypeid = db.Column(db.Integer, primary_key=True)
relationshiptype = db.Column(db.String(50), unique=True, nullable=False) relationshiptype = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
# Example types: # Sibling propagation (ADR-001): when a relationship of this type is
# - "Controls" (PC controls Equipment) # created/deleted, the framework finds all assets related to the source
# - "Dualpath" (Redundant path partner) # via the type at propagatesthroughid and mirrors the change. Null means
# - "Backup" (Backup machine) # 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'
)
propagatesthrough = db.relationship(
'RelationshipType',
remote_side=[relationshiptypeid],
foreign_keys=[propagatesthroughid],
)
def __repr__(self): def __repr__(self):
return f"<RelationshipType {self.relationshiptype}>" return f"<RelationshipType {self.relationshiptype}>"
@@ -50,9 +73,23 @@ class AssetRelationship(BaseModel):
nullable=False nullable=False
) )
# Free-text description carrying domain nuance ("DNC feed",
# "operator workstation", "ethernet PoE"). Avoids inflating type list.
label = db.Column(db.String(200), comment='Free-text relationship description (ADR-001)')
# When true, resolve_asset_position walks across this edge (priority 2
# in the resolution chain). Defaults to true for partof + controls when
# the relationship is created via the API; nullable for legacy rows.
inheritsposition = db.Column(
db.Boolean,
default=True,
nullable=False,
server_default='1',
comment='If true, resolved-position walk follows this edge (ADR-001)'
)
notes = db.Column(db.Text) notes = db.Column(db.Text)
# Relationships
sourceasset = db.relationship( sourceasset = db.relationship(
'Asset', 'Asset',
foreign_keys=[sourceassetid], foreign_keys=[sourceassetid],

View File

@@ -84,6 +84,149 @@ def test_resolve_asset_position_handles_asset_without_mapx_attr():
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'location'} assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'location'}
# --- Relationship-walk path (priority 2 in the chain) ----------------------
def _make_rel(rtype_name, neighbor, inheritsposition=True, isactive=True):
"""Build a fake AssetRelationship-shaped object pointing at neighbor.
`neighbor` is wired as both source and target so the walk helper finds
it regardless of direction; tests pick which list to attach it to."""
class FakeRelType:
relationshiptype = rtype_name
class FakeRel:
pass
r = FakeRel()
r.relationshiptype = FakeRelType()
r.inheritsposition = inheritsposition
r.isactive = isactive
r.targetasset = neighbor
r.sourceasset = neighbor
return r
def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, location=None):
class FakeAsset:
pass
a = FakeAsset()
a.assetid = assetid
a.mapx = mapx
a.mapy = mapy
a.outgoing_relationships = outgoing or []
a.incoming_relationships = incoming or []
a.location = location
return a
def test_resolve_asset_position_walks_partof_edge():
"""Priority 2: inheritsposition=true partof edge resolves from neighbor."""
parent = _make_asset(assetid=2, mapx=300, mapy=400)
rel = _make_rel('partof', parent)
child = _make_asset(assetid=1, outgoing=[rel])
result = resolve_asset_position(child)
assert result == {'mapx': 300, 'mapy': 400, 'positionsource': 'related'}
def test_resolve_asset_position_walks_controls_after_partof():
"""Priority 2 ordering: partof beats controls when both have coords."""
partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12)
controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99)
rel_partof = _make_rel('partof', partof_neighbor)
rel_controls = _make_rel('controls', controls_neighbor)
asset = _make_asset(assetid=1, outgoing=[rel_controls, rel_partof])
result = resolve_asset_position(asset)
assert result == {'mapx': 11, 'mapy': 12, 'positionsource': 'related'}
def test_resolve_asset_position_skips_non_inheritable_type():
"""connectedto edges are never walked even if inheritsposition is true."""
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
rel = _make_rel('connectedto', neighbor, inheritsposition=True)
asset = _make_asset(assetid=1, outgoing=[rel])
assert resolve_asset_position(asset) is None
def test_resolve_asset_position_skips_when_inheritsposition_false():
"""An edge with inheritsposition=false is not walked."""
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
rel = _make_rel('partof', neighbor, inheritsposition=False)
asset = _make_asset(assetid=1, outgoing=[rel])
assert resolve_asset_position(asset) is None
def test_resolve_asset_position_walks_recursively():
"""The walk recurses: child -> middle -> root, where only root has coords."""
root = _make_asset(assetid=3, mapx=1, mapy=2)
middle = _make_asset(assetid=2, outgoing=[_make_rel('partof', root)])
child = _make_asset(assetid=1, outgoing=[_make_rel('partof', middle)])
result = resolve_asset_position(child)
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'related'}
def test_resolve_asset_position_breaks_cycles():
"""A cycle A<->B with no coords anywhere returns None without recursing
forever."""
a = _make_asset(assetid=1)
b = _make_asset(assetid=2)
a.outgoing_relationships = [_make_rel('partof', b)]
b.outgoing_relationships = [_make_rel('partof', a)]
assert resolve_asset_position(a) is None
def test_resolve_asset_position_depth_cap_is_three():
"""Past depth 3 the walk gives up. Build a chain of 5 nodes where only
the last has coords; expect None."""
coords_node = _make_asset(assetid=5, mapx=99, mapy=99)
n4 = _make_asset(assetid=4, outgoing=[_make_rel('partof', coords_node)])
n3 = _make_asset(assetid=3, outgoing=[_make_rel('partof', n4)])
n2 = _make_asset(assetid=2, outgoing=[_make_rel('partof', n3)])
root = _make_asset(assetid=1, outgoing=[_make_rel('partof', n2)])
assert resolve_asset_position(root) is None
def test_resolve_asset_position_self_beats_related():
"""Priority 1 beats priority 2: asset's own coords win even when a
related neighbor would also resolve."""
neighbor = _make_asset(assetid=2, mapx=99, mapy=99)
rel = _make_rel('partof', neighbor)
asset = _make_asset(assetid=1, mapx=1, mapy=2, outgoing=[rel])
result = resolve_asset_position(asset)
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'self'}
def test_resolve_asset_position_related_beats_location():
"""Priority 2 beats priority 3: a related neighbor's coords win over
the asset's location coords."""
class FakeLocation:
mapx = 500
mapy = 600
neighbor = _make_asset(assetid=2, mapx=10, mapy=20)
rel = _make_rel('partof', neighbor)
asset = _make_asset(assetid=1, outgoing=[rel], location=FakeLocation())
result = resolve_asset_position(asset)
assert result == {'mapx': 10, 'mapy': 20, 'positionsource': 'related'}
def test_resolve_asset_position_inactive_edge_skipped():
"""Soft-deleted (isactive=false) relationships are not walked."""
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
rel = _make_rel('partof', neighbor, isactive=False)
asset = _make_asset(assetid=1, outgoing=[rel])
assert resolve_asset_position(asset) is None
def test_plugin_get_setting_returns_default_when_unset(db, app): def test_plugin_get_setting_returns_default_when_unset(db, app):
"""A plugin reading an unset setting gets the default.""" """A plugin reading an unset setting gets the default."""
from shopdb.plugins import plugin_manager from shopdb.plugins import plugin_manager