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>
159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
"""Public API namespace exposed to plugins.
|
|
|
|
Plugin authors at sister sites import from this module. The contract is
|
|
locked in ADR-001 and versioned per ADR-002. Helpers added here become
|
|
part of the platform contract; bumps follow ADR-002 rules.
|
|
|
|
Currently exposed:
|
|
- audit_log: record an audit log entry with consistent schema
|
|
- resolve_asset_position: compute an asset's resolved map position
|
|
|
|
Setting helpers are exposed via BasePlugin instance methods
|
|
(plugin.get_setting, plugin.set_setting), not from this namespace.
|
|
"""
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
from shopdb.core.models import AuditLog
|
|
|
|
|
|
def audit_log(
|
|
action: str,
|
|
entitytype: str,
|
|
entityid: int = None,
|
|
entityname: str = None,
|
|
changes: Dict = None,
|
|
details: Dict = None,
|
|
) -> AuditLog:
|
|
"""Record an audit log entry with the framework's standard schema.
|
|
|
|
Plugins call this to record state changes on their own assets in a
|
|
way that is consistent with core auditing. The function delegates to
|
|
AuditLog.log() which already captures the current user, IP address,
|
|
and user agent from the Flask request context.
|
|
|
|
Args:
|
|
action: Action verb in past tense ('created', 'updated', 'deleted')
|
|
entitytype: Class name of the entity affected ('Computer', 'Printer')
|
|
entityid: Primary key of the entity
|
|
entityname: Human-readable identifier (hostname, asset number)
|
|
changes: Dict with 'before' and 'after' snapshots for updates
|
|
details: Arbitrary additional context
|
|
|
|
Returns:
|
|
The created AuditLog instance, already committed to the DB.
|
|
"""
|
|
return AuditLog.log(
|
|
action=action,
|
|
entitytype=entitytype,
|
|
entityid=entityid,
|
|
entityname=entityname,
|
|
changes=changes,
|
|
details=details,
|
|
)
|
|
|
|
|
|
# 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 chain:
|
|
1. Asset-specific override (asset.mapx, asset.mapy)
|
|
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 unplaced, rendered in a tray)
|
|
|
|
Returns a dict {'mapx', 'mapy', 'positionsource'} where positionsource
|
|
is one of 'self', 'related', 'location'. Returns None when no priority
|
|
yields coordinates.
|
|
"""
|
|
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:
|
|
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': loc_mapx,
|
|
'mapy': loc_mapy,
|
|
'positionsource': 'location',
|
|
}
|
|
|
|
return None
|
|
|
|
|
|
__all__ = ['audit_log', 'resolve_asset_position']
|