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

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

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

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

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

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

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

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

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

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

View File

@@ -374,11 +374,11 @@ function renderMarkers() {
markers.value = []
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

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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 = {

View File

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

View File

@@ -280,7 +280,7 @@ def create_computer():
- name, serialnumber, statusid, locationid, businessunitid
- 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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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])

View File

@@ -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()),

View File

@@ -53,40 +53,102 @@ def audit_log(
)
# Cap how deep the relationship-walk traverses before giving up. ADR-001
# specifies a max walk depth of 3 to bound the work per request, with a
# visited-set guarding against cycles. Past this depth the walk treats the
# next hop as if it had no position to contribute.
_POSITION_WALK_MAX_DEPTH = 3
# Relationship type names whose edges are eligible for the inheritance walk
# when inheritsposition is true on the edge. Ordered by priority per
# ADR-001 ("partof first, then controls"). Edges of other types are never
# followed even if inheritsposition is true.
_INHERITABLE_TYPES = ('partof', 'controls')
def _walk_related_for_position(asset, visited, depth):
"""Recursive helper for resolve_asset_position relationship walk. Returns
a (mapx, mapy) tuple from the first related asset whose position
resolves, or None. Visited tracks assetids already explored to break
cycles."""
if depth >= _POSITION_WALK_MAX_DEPTH:
return None
aid = getattr(asset, 'assetid', None)
if aid is None or aid in visited:
return None
visited.add(aid)
edges = []
for rel in list(getattr(asset, 'outgoing_relationships', []) or []):
edges.append((rel, getattr(rel, 'targetasset', None)))
for rel in list(getattr(asset, 'incoming_relationships', []) or []):
edges.append((rel, getattr(rel, 'sourceasset', None)))
def _priority(edge):
rel = edge[0]
rtype = getattr(rel, 'relationshiptype', None)
type_name = getattr(rtype, 'relationshiptype', '') if rtype else ''
try:
return _INHERITABLE_TYPES.index(type_name)
except ValueError:
return len(_INHERITABLE_TYPES)
edges.sort(key=_priority)
for rel, neighbor in edges:
if neighbor is None:
continue
if not getattr(rel, 'inheritsposition', False):
continue
if not getattr(rel, 'isactive', True):
continue
rtype = getattr(rel, 'relationshiptype', None)
type_name = getattr(rtype, 'relationshiptype', '') if rtype else ''
if type_name not in _INHERITABLE_TYPES:
continue
n_mapx = getattr(neighbor, 'mapx', None)
n_mapy = getattr(neighbor, 'mapy', None)
if n_mapx is not None and n_mapy is not None:
return (n_mapx, n_mapy)
recursed = _walk_related_for_position(neighbor, visited, depth + 1)
if recursed is not None:
return recursed
return None
def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
"""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',
}

View File

@@ -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'))

View File

@@ -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,

View File

@@ -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:

View File

@@ -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}>"

View File

@@ -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],

View File

@@ -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