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

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