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:
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user