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 = {
|
||||
|
||||
Reference in New Issue
Block a user