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:
@@ -374,11 +374,11 @@ function renderMarkers() {
|
||||
markers.value = []
|
||||
|
||||
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)
|
||||
const leafletY = MAP_HEIGHT - item.maptop
|
||||
const leafletX = item.mapleft
|
||||
const leafletY = MAP_HEIGHT - item.mapy
|
||||
const leafletX = item.mapx
|
||||
|
||||
// Determine color based on mode
|
||||
let color, typeName, displayName, detailRoute
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
class="asset-item"
|
||||
:class="{
|
||||
selected: selectedAsset?.assetid === asset.assetid,
|
||||
placed: asset.mapleft && asset.maptop,
|
||||
unplaced: !asset.mapleft || !asset.maptop
|
||||
placed: asset.mapx && asset.mapy,
|
||||
unplaced: !asset.mapx || !asset.mapy
|
||||
}"
|
||||
@click="selectAsset(asset)"
|
||||
>
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="asset-name">{{ asset.name || asset.assetnumber }}</div>
|
||||
<div class="asset-meta">
|
||||
<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>
|
||||
@@ -75,7 +75,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
v-if="selectedAsset.mapleft && selectedAsset.maptop"
|
||||
v-if="selectedAsset.mapx && selectedAsset.mapy"
|
||||
@click="clearPosition"
|
||||
>
|
||||
Remove from Map
|
||||
@@ -94,7 +94,7 @@
|
||||
:assetTypeMode="true"
|
||||
:theme="currentTheme"
|
||||
:pickerMode="!!selectedAsset"
|
||||
:initialPosition="selectedAsset ? { left: selectedAsset.mapleft, top: selectedAsset.maptop } : null"
|
||||
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
|
||||
@positionPicked="handlePositionPicked"
|
||||
@markerClick="handleMarkerClick"
|
||||
/>
|
||||
@@ -125,14 +125,14 @@ const filteredAssets = computed(() => {
|
||||
}
|
||||
|
||||
if (showUnplacedOnly.value) {
|
||||
result = result.filter(a => !a.mapleft || !a.maptop)
|
||||
result = result.filter(a => !a.mapx || !a.mapy)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const placedAssets = computed(() => {
|
||||
return assets.value.filter(a => a.mapleft && a.maptop)
|
||||
return assets.value.filter(a => a.mapx && a.mapy)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -163,8 +163,8 @@ function getTypeIcon(assettype) {
|
||||
|
||||
function selectAsset(asset) {
|
||||
selectedAsset.value = asset
|
||||
pickedPosition.value = asset.mapleft && asset.maptop
|
||||
? { left: asset.mapleft, top: asset.maptop }
|
||||
pickedPosition.value = asset.mapx && asset.mapy
|
||||
? { left: asset.mapx, top: asset.mapy }
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -181,15 +181,15 @@ async function savePosition() {
|
||||
|
||||
try {
|
||||
await assetsApi.update(selectedAsset.value.assetid, {
|
||||
mapleft: Math.round(pickedPosition.value.left),
|
||||
maptop: Math.round(pickedPosition.value.top)
|
||||
mapx: Math.round(pickedPosition.value.left),
|
||||
mapy: Math.round(pickedPosition.value.top)
|
||||
})
|
||||
|
||||
// Update local state
|
||||
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
|
||||
if (asset) {
|
||||
asset.mapleft = Math.round(pickedPosition.value.left)
|
||||
asset.maptop = Math.round(pickedPosition.value.top)
|
||||
asset.mapx = Math.round(pickedPosition.value.left)
|
||||
asset.mapy = Math.round(pickedPosition.value.top)
|
||||
}
|
||||
|
||||
selectedAsset.value = null
|
||||
@@ -207,15 +207,15 @@ async function clearPosition() {
|
||||
|
||||
try {
|
||||
await assetsApi.update(selectedAsset.value.assetid, {
|
||||
mapleft: null,
|
||||
maptop: null
|
||||
mapx: null,
|
||||
mapy: null
|
||||
})
|
||||
|
||||
// Update local state
|
||||
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
|
||||
if (asset) {
|
||||
asset.mapleft = null
|
||||
asset.maptop = null
|
||||
asset.mapx = null
|
||||
asset.mapy = null
|
||||
}
|
||||
|
||||
selectedAsset.value = null
|
||||
|
||||
@@ -162,9 +162,9 @@
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="equipment.mapleft != null && equipment.maptop != null"
|
||||
:left="equipment.mapleft"
|
||||
:top="equipment.maptop"
|
||||
v-if="equipment.mapx != null && equipment.mapy != null"
|
||||
:left="equipment.mapx"
|
||||
:top="equipment.mapy"
|
||||
:machineName="equipment.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ equipment.locationname || 'On Map' }}</span>
|
||||
|
||||
@@ -212,8 +212,8 @@
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position">
|
||||
Position: {{ form.mapleft }}, {{ form.maptop }}
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
@@ -227,7 +227,7 @@
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
: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"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
@@ -358,8 +358,8 @@ const form = ref({
|
||||
requiresmanualconfig: false,
|
||||
islocationonly: false,
|
||||
notes: '',
|
||||
mapleft: null,
|
||||
maptop: null
|
||||
mapx: null,
|
||||
mapy: null
|
||||
})
|
||||
|
||||
const equipmentTypes = ref([])
|
||||
@@ -468,8 +468,8 @@ onMounted(async () => {
|
||||
requiresmanualconfig: data.equipment?.requiresmanualconfig || false,
|
||||
islocationonly: data.equipment?.islocationonly || false,
|
||||
notes: data.notes || '',
|
||||
mapleft: data.mapleft ?? null,
|
||||
maptop: data.maptop ?? null
|
||||
mapx: data.mapx ?? null,
|
||||
mapy: data.mapy ?? null
|
||||
}
|
||||
|
||||
// Load existing relationships to find controlling PC
|
||||
@@ -506,15 +506,15 @@ function handlePositionPicked(position) {
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapleft = tempMapPosition.value.left
|
||||
form.value.maptop = tempMapPosition.value.top
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapleft = null
|
||||
form.value.maptop = null
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
@@ -538,8 +538,8 @@ async function saveEquipment() {
|
||||
requiresmanualconfig: form.value.requiresmanualconfig,
|
||||
islocationonly: form.value.islocationonly,
|
||||
notes: form.value.notes || null,
|
||||
mapleft: form.value.mapleft,
|
||||
maptop: form.value.maptop
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
|
||||
let savedEquipment
|
||||
|
||||
@@ -169,20 +169,20 @@
|
||||
<legend>Map Position (Optional)</legend>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="mapleft">Map X Position</label>
|
||||
<label for="mapx">Map X Position</label>
|
||||
<input
|
||||
id="mapleft"
|
||||
v-model.number="form.mapleft"
|
||||
id="mapx"
|
||||
v-model.number="form.mapx"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="maptop">Map Y Position</label>
|
||||
<label for="mapy">Map Y Position</label>
|
||||
<input
|
||||
id="maptop"
|
||||
v-model.number="form.maptop"
|
||||
id="mapy"
|
||||
v-model.number="form.mapy"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
@@ -251,8 +251,8 @@ const form = ref({
|
||||
rackunit: '',
|
||||
ispoe: false,
|
||||
ismanaged: false,
|
||||
mapleft: null,
|
||||
maptop: null,
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
@@ -335,8 +335,8 @@ async function loadDevice() {
|
||||
form.value.statusid = data.statusid || ''
|
||||
form.value.locationid = data.locationid || ''
|
||||
form.value.businessunitid = data.businessunitid || ''
|
||||
form.value.mapleft = data.mapleft
|
||||
form.value.maptop = data.maptop
|
||||
form.value.mapx = data.mapx
|
||||
form.value.mapy = data.mapy
|
||||
form.value.notes = data.notes || ''
|
||||
|
||||
// Network device specific
|
||||
@@ -376,8 +376,8 @@ async function submitForm() {
|
||||
rackunit: form.value.rackunit || null,
|
||||
ispoe: form.value.ispoe,
|
||||
ismanaged: form.value.ismanaged,
|
||||
mapleft: form.value.mapleft,
|
||||
maptop: form.value.maptop,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy,
|
||||
notes: form.value.notes || null
|
||||
}
|
||||
|
||||
|
||||
@@ -121,9 +121,9 @@
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="computer.mapleft != null && computer.maptop != null"
|
||||
:left="computer.mapleft"
|
||||
:top="computer.maptop"
|
||||
v-if="computer.mapx != null && computer.mapy != null"
|
||||
:left="computer.mapx"
|
||||
:top="computer.mapy"
|
||||
:machineName="computer.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
||||
|
||||
@@ -226,8 +226,8 @@
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position">
|
||||
Position: {{ form.mapleft }}, {{ form.maptop }}
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
@@ -241,7 +241,7 @@
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
: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"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
@@ -299,8 +299,8 @@ const form = ref({
|
||||
isvnc: false,
|
||||
iswinrm: false,
|
||||
notes: '',
|
||||
mapleft: null,
|
||||
maptop: null,
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
ipaddress: ''
|
||||
})
|
||||
|
||||
@@ -366,8 +366,8 @@ onMounted(async () => {
|
||||
isvnc: pc.isvnc || false,
|
||||
iswinrm: pc.iswinrm || false,
|
||||
notes: pc.notes || '',
|
||||
mapleft: pc.mapleft ?? null,
|
||||
maptop: pc.maptop ?? null,
|
||||
mapx: pc.mapx ?? null,
|
||||
mapy: pc.mapy ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
}
|
||||
@@ -385,15 +385,15 @@ function handlePositionPicked(position) {
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapleft = tempMapPosition.value.left
|
||||
form.value.maptop = tempMapPosition.value.top
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapleft = null
|
||||
form.value.maptop = null
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
@@ -417,8 +417,8 @@ async function savePC() {
|
||||
isvnc: form.value.isvnc,
|
||||
iswinrm: form.value.iswinrm,
|
||||
notes: form.value.notes,
|
||||
mapleft: form.value.mapleft,
|
||||
maptop: form.value.maptop
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
|
||||
let machineId
|
||||
|
||||
@@ -126,9 +126,9 @@
|
||||
<span class="info-label">Map Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="printer.mapleft != null && printer.maptop != null"
|
||||
:left="printer.mapleft"
|
||||
:top="printer.maptop"
|
||||
v-if="printer.mapx != null && printer.mapy != null"
|
||||
:left="printer.mapx"
|
||||
:top="printer.mapy"
|
||||
:machineName="printer.name || printer.assetnumber"
|
||||
>
|
||||
<span class="location-link">View on Map</span>
|
||||
|
||||
@@ -224,8 +224,8 @@
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position">
|
||||
Position: {{ form.mapleft }}, {{ form.maptop }}
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
@@ -239,7 +239,7 @@
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
: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"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
@@ -295,8 +295,8 @@ const form = ref({
|
||||
modelnumberid: '',
|
||||
locationid: '',
|
||||
notes: '',
|
||||
mapleft: null,
|
||||
maptop: null,
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
// Printer-specific
|
||||
ipaddress: '',
|
||||
csfname: '',
|
||||
@@ -456,8 +456,8 @@ onMounted(async () => {
|
||||
modelnumberid: printer.model?.modelnumberid || '',
|
||||
locationid: printer.location?.locationid || '',
|
||||
notes: printer.notes || '',
|
||||
mapleft: printer.mapleft ?? null,
|
||||
maptop: printer.maptop ?? null,
|
||||
mapx: printer.mapx ?? null,
|
||||
mapy: printer.mapy ?? null,
|
||||
// Printer-specific
|
||||
ipaddress: primaryComm?.ipaddress || '',
|
||||
csfname: printer.printerdata?.sharename || '',
|
||||
@@ -483,15 +483,15 @@ function handlePositionPicked(position) {
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapleft = tempMapPosition.value.left
|
||||
form.value.maptop = tempMapPosition.value.top
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapleft = null
|
||||
form.value.maptop = null
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
@@ -511,8 +511,8 @@ async function savePrinter() {
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
notes: form.value.notes,
|
||||
mapleft: form.value.mapleft,
|
||||
maptop: form.value.maptop
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
|
||||
const printerData = {
|
||||
|
||||
133
migrations/versions/7a01_adr001_position_contract.py
Normal file
133
migrations/versions/7a01_adr001_position_contract.py
Normal 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
|
||||
@@ -280,7 +280,7 @@ def create_computer():
|
||||
- name, serialnumber, statusid, locationid, businessunitid
|
||||
- computertypeid, hostname, osid
|
||||
- isvnc, iswinrm, isshopfloor
|
||||
- mapleft, maptop, notes
|
||||
- mapx, mapy, notes
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
@@ -325,8 +325,8 @@ def create_computer():
|
||||
statusid=data.get('statusid', 1),
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapleft=data.get('mapleft'),
|
||||
maptop=data.get('maptop'),
|
||||
mapx=data.get('mapx'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
@@ -405,7 +405,7 @@ def update_computer(computer_id: int):
|
||||
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
old_val = getattr(asset, key)
|
||||
|
||||
@@ -251,7 +251,7 @@ def create_equipment():
|
||||
- name, serialnumber, statusid, locationid, businessunitid
|
||||
- equipmenttypeid, vendorid, modelnumberid
|
||||
- requiresmanualconfig, islocationonly
|
||||
- mapleft, maptop, notes
|
||||
- mapx, mapy, notes
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
@@ -287,8 +287,8 @@ def create_equipment():
|
||||
statusid=data.get('statusid', 1),
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapleft=data.get('mapleft'),
|
||||
maptop=data.get('maptop'),
|
||||
mapx=data.get('mapx'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
@@ -358,7 +358,7 @@ def update_equipment(equipment_id: int):
|
||||
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
old_val = getattr(asset, key)
|
||||
|
||||
@@ -283,7 +283,7 @@ def create_network_device():
|
||||
- name, serialnumber, statusid, locationid, businessunitid
|
||||
- networkdevicetypeid, vendorid, hostname
|
||||
- firmwareversion, portcount, ispoe, ismanaged, rackunit
|
||||
- mapleft, maptop, notes
|
||||
- mapx, mapy, notes
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
@@ -328,8 +328,8 @@ def create_network_device():
|
||||
statusid=data.get('statusid', 1),
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapleft=data.get('mapleft'),
|
||||
maptop=data.get('maptop'),
|
||||
mapx=data.get('mapx'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
@@ -407,7 +407,7 @@ def update_network_device(device_id: int):
|
||||
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
old_val = getattr(asset, key)
|
||||
|
||||
@@ -246,7 +246,7 @@ def create_printer():
|
||||
- printertypeid, vendorid, modelnumberid, hostname
|
||||
- windowsname, sharename, iscsf, installpath, pin
|
||||
- iscolor, isduplex, isnetwork
|
||||
- mapleft, maptop, notes
|
||||
- mapx, mapy, notes
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
@@ -282,8 +282,8 @@ def create_printer():
|
||||
statusid=data.get('statusid', 1),
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapleft=data.get('mapleft'),
|
||||
maptop=data.get('maptop'),
|
||||
mapx=data.get('mapx'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
@@ -359,7 +359,7 @@ def update_printer(printer_id: int):
|
||||
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
setattr(asset, key, data[key])
|
||||
|
||||
@@ -73,11 +73,11 @@ def migrate_machine_to_asset(machine_row, asset_type_id, target_session):
|
||||
INSERT INTO assets (
|
||||
assetid, assetnumber, name, serialnumber,
|
||||
assettypeid, statusid, locationid, businessunitid,
|
||||
mapleft, maptop, notes, isactive, createddate, modifieddate
|
||||
mapx, mapy, notes, isactive, createddate, modifieddate
|
||||
) VALUES (
|
||||
:assetid, :assetnumber, :name, :serialnumber,
|
||||
:assettypeid, :statusid, :locationid, :businessunitid,
|
||||
:mapleft, :maptop, :notes, :isactive, :createddate, :modifieddate
|
||||
:mapx, :mapy, :notes, :isactive, :createddate, :modifieddate
|
||||
)
|
||||
"""), {
|
||||
'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),
|
||||
'locationid': machine_row.get('locationid'),
|
||||
'businessunitid': machine_row.get('businessunitid'),
|
||||
'mapleft': machine_row.get('mapleft'),
|
||||
'maptop': machine_row.get('maptop'),
|
||||
'mapx': machine_row.get('mapx'),
|
||||
'mapy': machine_row.get('mapy'),
|
||||
'notes': machine_row.get('notes'),
|
||||
'isactive': machine_row.get('isactive', True),
|
||||
'createddate': machine_row.get('createddate', datetime.utcnow()),
|
||||
|
||||
@@ -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]]:
|
||||
"""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)
|
||||
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)
|
||||
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
|
||||
position can be resolved.
|
||||
|
||||
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.
|
||||
Returns a dict {'mapx', 'mapy', 'positionsource'} where positionsource
|
||||
is one of 'self', 'related', 'location'. Returns None when no priority
|
||||
yields coordinates.
|
||||
"""
|
||||
if hasattr(asset, 'mapx') and hasattr(asset, 'mapy'):
|
||||
if asset.mapx is not None and asset.mapy is not None:
|
||||
return {
|
||||
'mapx': asset.mapx,
|
||||
'mapy': asset.mapy,
|
||||
'positionsource': 'self',
|
||||
}
|
||||
mapx = getattr(asset, 'mapx', None)
|
||||
mapy = getattr(asset, 'mapy', None)
|
||||
if mapx is not None and mapy is not None:
|
||||
return {'mapx': mapx, 'mapy': 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)
|
||||
if location is not None:
|
||||
location_mapx = getattr(location, 'mapx', None)
|
||||
location_mapy = getattr(location, 'mapy', None)
|
||||
if location_mapx is not None and location_mapy is not None:
|
||||
loc_mapx = getattr(location, 'mapx', None)
|
||||
loc_mapy = getattr(location, 'mapy', None)
|
||||
if loc_mapx is not None and loc_mapy is not None:
|
||||
return {
|
||||
'mapx': location_mapx,
|
||||
'mapy': location_mapy,
|
||||
'mapx': loc_mapx,
|
||||
'mapy': loc_mapy,
|
||||
'positionsource': 'location',
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,9 @@ def seed_reference_data():
|
||||
os_obj = OperatingSystem(**os_data)
|
||||
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 = [
|
||||
{'relationshiptype': 'Serial Cable', 'description': 'RS-232 or similar serial connection'},
|
||||
{'relationshiptype': 'Direct Ethernet', 'description': 'Direct network cable (airgapped)'},
|
||||
@@ -112,6 +114,27 @@ 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_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()
|
||||
click.echo(click.style("Reference data seeded.", fg='green'))
|
||||
|
||||
|
||||
@@ -299,8 +299,8 @@ def create_asset():
|
||||
statusid=data.get('statusid', 1),
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapleft=data.get('mapleft'),
|
||||
maptop=data.get('maptop'),
|
||||
mapx=data.get('mapx'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
@@ -339,7 +339,7 @@ def update_asset(asset_id: int):
|
||||
# Update allowed fields
|
||||
allowed_fields = [
|
||||
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive'
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'
|
||||
]
|
||||
|
||||
for key in allowed_fields:
|
||||
@@ -527,7 +527,7 @@ def get_assets_map():
|
||||
"""
|
||||
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:
|
||||
- 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(
|
||||
Asset.isactive == True,
|
||||
Asset.mapleft.isnot(None),
|
||||
Asset.maptop.isnot(None)
|
||||
Asset.mapx.isnot(None),
|
||||
Asset.mapy.isnot(None)
|
||||
)
|
||||
|
||||
selected_assettype = request.args.get('assettype')
|
||||
@@ -719,8 +719,8 @@ def get_assets_map():
|
||||
'name': asset.name,
|
||||
'displayname': asset.display_name,
|
||||
'serialnumber': asset.serialnumber,
|
||||
'mapleft': asset.mapleft,
|
||||
'maptop': asset.maptop,
|
||||
'mapx': asset.mapx,
|
||||
'mapy': asset.mapy,
|
||||
'assettype': asset.assettype.assettype if asset.assettype else None,
|
||||
'assettypeid': asset.assettypeid,
|
||||
'status': asset.status.status if asset.status else None,
|
||||
|
||||
@@ -105,9 +105,9 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Floor map position
|
||||
mapleft = db.Column(db.Integer, comment='X coordinate on floor map')
|
||||
maptop = db.Column(db.Integer, comment='Y coordinate on floor map')
|
||||
# Floor map position (ADR-001: asset-specific override; nullable)
|
||||
mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
|
||||
mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
|
||||
|
||||
# Notes
|
||||
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.
|
||||
|
||||
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.
|
||||
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.mapleft is not None and self.maptop is not None):
|
||||
if self.locationid is not None or (self.mapx is not None and self.mapy is not None):
|
||||
return None
|
||||
|
||||
# Check related assets for location data
|
||||
# Look in both incoming and outgoing relationships
|
||||
related_assets = []
|
||||
|
||||
if hasattr(self, 'incoming_relationships'):
|
||||
@@ -182,14 +179,13 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
if rel.targetasset and rel.isactive:
|
||||
related_assets.append(rel.targetasset)
|
||||
|
||||
# Find first related asset with location data
|
||||
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 {
|
||||
'locationid': related.locationid,
|
||||
'locationname': related.location.locationname if related.location else None,
|
||||
'mapleft': related.mapleft,
|
||||
'maptop': related.maptop,
|
||||
'mapx': related.mapx,
|
||||
'mapy': related.mapy,
|
||||
'inheritedfrom': related.assetnumber
|
||||
}
|
||||
|
||||
@@ -234,10 +230,10 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
if result.get('locationid') is None:
|
||||
result['locationid'] = inherited['locationid']
|
||||
result['locationname'] = inherited['locationname']
|
||||
if result.get('mapleft') is None:
|
||||
result['mapleft'] = inherited['mapleft']
|
||||
if result.get('maptop') is None:
|
||||
result['maptop'] = inherited['maptop']
|
||||
if result.get('mapx') is None:
|
||||
result['mapx'] = inherited['mapx']
|
||||
if result.get('mapy') is None:
|
||||
result['mapy'] = inherited['mapy']
|
||||
|
||||
# Include extension data if requested
|
||||
if include_type_data:
|
||||
|
||||
@@ -20,5 +20,11 @@ class Location(BaseModel):
|
||||
mapwidth = 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):
|
||||
return f"<Location {self.locationname}>"
|
||||
|
||||
@@ -5,17 +5,40 @@ from .base import 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'
|
||||
|
||||
relationshiptypeid = db.Column(db.Integer, primary_key=True)
|
||||
relationshiptype = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Example types:
|
||||
# - "Controls" (PC controls Equipment)
|
||||
# - "Dualpath" (Redundant path partner)
|
||||
# - "Backup" (Backup machine)
|
||||
# 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'
|
||||
)
|
||||
|
||||
propagatesthrough = db.relationship(
|
||||
'RelationshipType',
|
||||
remote_side=[relationshiptypeid],
|
||||
foreign_keys=[propagatesthroughid],
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RelationshipType {self.relationshiptype}>"
|
||||
@@ -50,9 +73,23 @@ class AssetRelationship(BaseModel):
|
||||
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)
|
||||
|
||||
# Relationships
|
||||
sourceasset = db.relationship(
|
||||
'Asset',
|
||||
foreign_keys=[sourceassetid],
|
||||
|
||||
@@ -84,6 +84,149 @@ def test_resolve_asset_position_handles_asset_without_mapx_attr():
|
||||
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):
|
||||
"""A plugin reading an unset setting gets the default."""
|
||||
from shopdb.plugins import plugin_manager
|
||||
|
||||
Reference in New Issue
Block a user