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 = []
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
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
|
- 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}>"
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user