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