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>
261 lines
9.0 KiB
Python
261 lines
9.0 KiB
Python
"""Polymorphic Asset models - core of the new asset architecture."""
|
|
|
|
from shopdb.extensions import db
|
|
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
|
|
|
|
|
class AssetType(BaseModel):
|
|
"""
|
|
Registry of asset categories.
|
|
|
|
Each type maps to a plugin-owned extension table.
|
|
Examples: equipment, computer, network_device, printer
|
|
"""
|
|
__tablename__ = 'assettypes'
|
|
|
|
assettypeid = db.Column(db.Integer, primary_key=True)
|
|
assettype = db.Column(
|
|
db.String(50),
|
|
unique=True,
|
|
nullable=False,
|
|
comment='Category name: equipment, computer, network_device, printer'
|
|
)
|
|
pluginname = db.Column(
|
|
db.String(100),
|
|
nullable=True,
|
|
comment='Plugin that owns this type'
|
|
)
|
|
tablename = db.Column(
|
|
db.String(100),
|
|
nullable=True,
|
|
comment='Extension table name for this type'
|
|
)
|
|
description = db.Column(db.Text)
|
|
icon = db.Column(db.String(50), comment='Icon name for UI')
|
|
|
|
def __repr__(self):
|
|
return f"<AssetType {self.assettype}>"
|
|
|
|
|
|
class AssetStatus(BaseModel):
|
|
"""Asset status options."""
|
|
__tablename__ = 'assetstatuses'
|
|
|
|
statusid = db.Column(db.Integer, primary_key=True)
|
|
status = db.Column(db.String(50), unique=True, nullable=False)
|
|
description = db.Column(db.Text)
|
|
color = db.Column(db.String(20), comment='CSS color for UI')
|
|
|
|
def __repr__(self):
|
|
return f"<AssetStatus {self.status}>"
|
|
|
|
|
|
class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
|
"""
|
|
Core asset model - minimal shared fields.
|
|
|
|
Category-specific data lives in plugin extension tables
|
|
(equipment, computers, network_devices, printers).
|
|
The assetid matches original machineid for migration compatibility.
|
|
"""
|
|
__tablename__ = 'assets'
|
|
|
|
assetid = db.Column(db.Integer, primary_key=True)
|
|
|
|
# Identification
|
|
assetnumber = db.Column(
|
|
db.String(50),
|
|
unique=True,
|
|
nullable=False,
|
|
index=True,
|
|
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
|
|
)
|
|
name = db.Column(
|
|
db.String(100),
|
|
comment='Display name/alias'
|
|
)
|
|
serialnumber = db.Column(
|
|
db.String(100),
|
|
index=True,
|
|
comment='Hardware serial number'
|
|
)
|
|
|
|
# Classification
|
|
assettypeid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('assettypes.assettypeid'),
|
|
nullable=False
|
|
)
|
|
statusid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('assetstatuses.statusid'),
|
|
default=1,
|
|
comment='In Use, Spare, Retired, etc.'
|
|
)
|
|
|
|
# Location and organization
|
|
locationid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('locations.locationid'),
|
|
nullable=True
|
|
)
|
|
businessunitid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('businessunits.businessunitid'),
|
|
nullable=True
|
|
)
|
|
|
|
# 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)
|
|
|
|
# Relationships
|
|
assettype = db.relationship('AssetType', backref='assets')
|
|
status = db.relationship('AssetStatus', backref='assets')
|
|
location = db.relationship('Location', backref='assets')
|
|
businessunit = db.relationship('BusinessUnit', backref='assets')
|
|
|
|
# Communications (one-to-many) - will be migrated to use assetid
|
|
communications = db.relationship(
|
|
'Communication',
|
|
foreign_keys='Communication.assetid',
|
|
backref='asset',
|
|
cascade='all, delete-orphan',
|
|
lazy='dynamic'
|
|
)
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'),
|
|
db.Index('idx_asset_location', 'locationid'),
|
|
db.Index('idx_asset_active', 'isactive'),
|
|
db.Index('idx_asset_status', 'statusid'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<Asset {self.assetnumber}>"
|
|
|
|
@property
|
|
def display_name(self):
|
|
"""Get display name (name if set, otherwise assetnumber)."""
|
|
return self.name or self.assetnumber
|
|
|
|
@property
|
|
def primary_ip(self):
|
|
"""Get primary IP address from communications."""
|
|
comm = self.communications.filter_by(
|
|
isprimary=True,
|
|
comtypeid=1 # IP type
|
|
).first()
|
|
if comm:
|
|
return comm.ipaddress
|
|
# Fall back to any IP
|
|
comm = self.communications.filter_by(comtypeid=1).first()
|
|
return comm.ipaddress if comm else None
|
|
|
|
def get_inherited_location(self):
|
|
"""
|
|
Get location data from a related asset if this asset has none.
|
|
|
|
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 self.locationid is not None or (self.mapx is not None and self.mapy is not None):
|
|
return None
|
|
|
|
related_assets = []
|
|
|
|
if hasattr(self, 'incoming_relationships'):
|
|
for rel in self.incoming_relationships:
|
|
if rel.sourceasset and rel.isactive:
|
|
related_assets.append(rel.sourceasset)
|
|
|
|
if hasattr(self, 'outgoing_relationships'):
|
|
for rel in self.outgoing_relationships:
|
|
if rel.targetasset and rel.isactive:
|
|
related_assets.append(rel.targetasset)
|
|
|
|
for related in related_assets:
|
|
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,
|
|
'mapx': related.mapx,
|
|
'mapy': related.mapy,
|
|
'inheritedfrom': related.assetnumber
|
|
}
|
|
|
|
return None
|
|
|
|
def to_dict(self, include_type_data=False, include_inherited_location=True):
|
|
"""
|
|
Convert model to dictionary.
|
|
|
|
Args:
|
|
include_type_data: If True, include category-specific data from extension table
|
|
include_inherited_location: If True, include location from related assets when missing
|
|
"""
|
|
result = super().to_dict()
|
|
|
|
# Add related object names for convenience
|
|
if self.assettype:
|
|
result['assettypename'] = self.assettype.assettype
|
|
if self.status:
|
|
result['statusname'] = self.status.status
|
|
if self.location:
|
|
result['locationname'] = self.location.locationname
|
|
if self.businessunit:
|
|
result['businessunitname'] = self.businessunit.businessunit
|
|
|
|
# Add plugin-specific ID for navigation purposes
|
|
if hasattr(self, 'equipment') and self.equipment:
|
|
result['pluginid'] = self.equipment.equipmentid
|
|
elif hasattr(self, 'computer') and self.computer:
|
|
result['pluginid'] = self.computer.computerid
|
|
elif hasattr(self, 'network_device') and self.network_device:
|
|
result['pluginid'] = self.network_device.networkdeviceid
|
|
elif hasattr(self, 'printer') and self.printer:
|
|
result['pluginid'] = self.printer.printerid
|
|
|
|
# Include inherited location if this asset has no location data
|
|
if include_inherited_location:
|
|
inherited = self.get_inherited_location()
|
|
if inherited:
|
|
result['inheritedlocation'] = inherited
|
|
# Also set the location fields if they're missing
|
|
if result.get('locationid') is None:
|
|
result['locationid'] = inherited['locationid']
|
|
result['locationname'] = inherited['locationname']
|
|
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:
|
|
ext_data = self._get_extension_data()
|
|
if ext_data:
|
|
result['typedata'] = ext_data
|
|
|
|
return result
|
|
|
|
def _get_extension_data(self):
|
|
"""Get category-specific data from extension table."""
|
|
# Check for equipment extension
|
|
if hasattr(self, 'equipment') and self.equipment:
|
|
return self.equipment.to_dict()
|
|
# Check for computer extension
|
|
if hasattr(self, 'computer') and self.computer:
|
|
return self.computer.to_dict()
|
|
# Check for network_device extension
|
|
if hasattr(self, 'network_device') and self.network_device:
|
|
return self.network_device.to_dict()
|
|
# Check for printer extension
|
|
if hasattr(self, 'printer') and self.printer:
|
|
return self.printer.to_dict()
|
|
return None
|