- Add equipmentApi and computersApi to replace legacy machinesApi - Add controller vendor/model fields to Equipment model and forms - Fix map marker navigation to use plugin-specific IDs (equipmentid, computerid, printerid, networkdeviceid) instead of assetid - Fix search to use unified Asset table with correct plugin IDs - Remove legacy printer search that used non-existent field names - Enable optional JWT auth for detail endpoints (public read access) - Clean up USB plugin models (remove unused checkout model) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
265 lines
9.3 KiB
Python
265 lines
9.3 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'
|
|
)
|
|
plugin_name = db.Column(
|
|
db.String(100),
|
|
nullable=True,
|
|
comment='Plugin that owns this type'
|
|
)
|
|
table_name = 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
|
|
mapleft = db.Column(db.Integer, comment='X coordinate on floor map')
|
|
maptop = db.Column(db.Integer, comment='Y coordinate on floor map')
|
|
|
|
# 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, mapleft, maptop, 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):
|
|
return None
|
|
|
|
# Check related assets for location data
|
|
# Look in both incoming and outgoing relationships
|
|
related_assets = []
|
|
|
|
if hasattr(self, 'incoming_relationships'):
|
|
for rel in self.incoming_relationships:
|
|
if rel.source_asset and rel.isactive:
|
|
related_assets.append(rel.source_asset)
|
|
|
|
if hasattr(self, 'outgoing_relationships'):
|
|
for rel in self.outgoing_relationships:
|
|
if rel.target_asset and rel.isactive:
|
|
related_assets.append(rel.target_asset)
|
|
|
|
# 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):
|
|
return {
|
|
'locationid': related.locationid,
|
|
'location_name': related.location.locationname if related.location else None,
|
|
'mapleft': related.mapleft,
|
|
'maptop': related.maptop,
|
|
'inherited_from': 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['assettype_name'] = self.assettype.assettype
|
|
if self.status:
|
|
result['status_name'] = self.status.status
|
|
if self.location:
|
|
result['location_name'] = self.location.locationname
|
|
if self.businessunit:
|
|
result['businessunit_name'] = self.businessunit.businessunit
|
|
|
|
# Add plugin-specific ID for navigation purposes
|
|
if hasattr(self, 'equipment') and self.equipment:
|
|
result['plugin_id'] = self.equipment.equipmentid
|
|
elif hasattr(self, 'computer') and self.computer:
|
|
result['plugin_id'] = self.computer.computerid
|
|
elif hasattr(self, 'network_device') and self.network_device:
|
|
result['plugin_id'] = self.network_device.networkdeviceid
|
|
elif hasattr(self, 'printer') and self.printer:
|
|
result['plugin_id'] = 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['inherited_location'] = inherited
|
|
# Also set the location fields if they're missing
|
|
if result.get('locationid') is None:
|
|
result['locationid'] = inherited['locationid']
|
|
result['location_name'] = inherited['location_name']
|
|
if result.get('mapleft') is None:
|
|
result['mapleft'] = inherited['mapleft']
|
|
if result.get('maptop') is None:
|
|
result['maptop'] = inherited['maptop']
|
|
|
|
# Include extension data if requested
|
|
if include_type_data:
|
|
ext_data = self._get_extension_data()
|
|
if ext_data:
|
|
result['type_data'] = 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
|