Migrate frontend to plugin-based asset architecture

- 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>
This commit is contained in:
cproudlock
2026-01-29 16:07:41 -05:00
parent 9c220a4194
commit c3ce69da12
28 changed files with 4123 additions and 3454 deletions

View File

@@ -47,12 +47,12 @@ class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_ECHO = True
# Use SQLite for local development if no DATABASE_URL set
# Use MySQL from DATABASE_URL
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
'sqlite:///shopdb_dev.db'
'mysql+pymysql://root:rootpassword@127.0.0.1:3306/shopdb_flask'
)
SQLALCHEMY_ENGINE_OPTIONS = {} # SQLite doesn't need pool options
# Keep pool options from base Config for MySQL
class TestingConfig(Config):

View File

@@ -393,7 +393,7 @@ def lookup_asset_by_number(assetnumber: str):
# =============================================================================
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def get_asset_relationships(asset_id: int):
"""
Get all relationships for an asset.
@@ -521,7 +521,7 @@ def delete_asset_relationship(rel_id: int):
# =============================================================================
@assets_bp.route('/map', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def get_assets_map():
"""
Get all assets with map positions for unified floor map display.
@@ -529,13 +529,14 @@ def get_assets_map():
Returns assets with mapleft/maptop coordinates, joined with type-specific data.
Query parameters:
- assettype: Filter by asset type name (equipment, computer, network, printer)
- assettype: Filter by asset type name (equipment, computer, network_device, printer)
- subtype: Filter by subtype ID (machinetype for equipment/computer, networkdevicetype for network, printertype for printer)
- businessunitid: Filter by business unit ID
- statusid: Filter by status ID
- locationid: Filter by location ID
- search: Search by assetnumber, name, or serialnumber
"""
from shopdb.core.models import Location, BusinessUnit
from shopdb.core.models import Location, BusinessUnit, MachineType, Machine
query = Asset.query.filter(
Asset.isactive == True,
@@ -543,10 +544,52 @@ def get_assets_map():
Asset.maptop.isnot(None)
)
selected_assettype = request.args.get('assettype')
# Filter by asset type name
if assettype := request.args.get('assettype'):
types = assettype.split(',')
query = query.join(AssetType).filter(AssetType.assettype.in_(types))
if selected_assettype:
query = query.join(AssetType).filter(AssetType.assettype == selected_assettype)
# Filter by subtype (depends on asset type) - case-insensitive matching
if subtype_id := request.args.get('subtype'):
subtype_id = int(subtype_id)
asset_type_lower = selected_assettype.lower() if selected_assettype else ''
if asset_type_lower == 'equipment':
# Filter by equipment type
try:
from plugins.equipment.models import Equipment
query = query.join(Equipment, Equipment.assetid == Asset.assetid).filter(
Equipment.equipmenttypeid == subtype_id
)
except ImportError:
pass
elif asset_type_lower == 'computer':
# Filter by computer type
try:
from plugins.computers.models import Computer
query = query.join(Computer, Computer.assetid == Asset.assetid).filter(
Computer.computertypeid == subtype_id
)
except ImportError:
pass
elif asset_type_lower == 'network device':
# Filter by network device type
try:
from plugins.network.models import NetworkDevice
query = query.join(NetworkDevice, NetworkDevice.assetid == Asset.assetid).filter(
NetworkDevice.networkdevicetypeid == subtype_id
)
except ImportError:
pass
elif asset_type_lower == 'printer':
# Filter by printer type
try:
from plugins.printers.models import Printer
query = query.join(Printer, Printer.assetid == Asset.assetid).filter(
Printer.printertypeid == subtype_id
)
except ImportError:
pass
# Filter by business unit
if bu_id := request.args.get('businessunitid'):
@@ -618,6 +661,41 @@ def get_assets_map():
locations = Location.query.filter(Location.isactive == True).all()
loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations]
# Get subtypes based on asset type categories (keys match database asset type values)
subtypes = {}
# Equipment types from equipment plugin
try:
from plugins.equipment.models import EquipmentType
equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all()
subtypes['Equipment'] = [{'id': et.equipmenttypeid, 'name': et.equipmenttype} for et in equipment_types]
except ImportError:
subtypes['Equipment'] = []
# Computer types from computers plugin
try:
from plugins.computers.models import ComputerType
computer_types = ComputerType.query.filter(ComputerType.isactive == True).order_by(ComputerType.computertype).all()
subtypes['Computer'] = [{'id': ct.computertypeid, 'name': ct.computertype} for ct in computer_types]
except ImportError:
subtypes['Computer'] = []
# Network device types
try:
from plugins.network.models import NetworkDeviceType
net_types = NetworkDeviceType.query.filter(NetworkDeviceType.isactive == True).order_by(NetworkDeviceType.networkdevicetype).all()
subtypes['Network Device'] = [{'id': nt.networkdevicetypeid, 'name': nt.networkdevicetype} for nt in net_types]
except ImportError:
subtypes['Network Device'] = []
# Printer types
try:
from plugins.printers.models import PrinterType
printer_types = PrinterType.query.filter(PrinterType.isactive == True).order_by(PrinterType.printertype).all()
subtypes['Printer'] = [{'id': pt.printertypeid, 'name': pt.printertype} for pt in printer_types]
except ImportError:
subtypes['Printer'] = []
return success_response({
'assets': data,
'total': len(data),
@@ -625,7 +703,8 @@ def get_assets_map():
'assettypes': types_data,
'statuses': status_data,
'businessunits': bu_data,
'locations': loc_data
'locations': loc_data,
'subtypes': subtypes
}
})

View File

@@ -5,7 +5,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
Machine, Application, KnowledgeBase,
Application, KnowledgeBase,
Asset, AssetType
)
from shopdb.utils.responses import success_response
@@ -46,74 +46,9 @@ def global_search():
results = []
search_term = f'%{query}%'
# Search Machines (Equipment and PCs)
try:
machines = Machine.query.filter(
Machine.isactive == True,
db.or_(
Machine.machinenumber.ilike(search_term),
Machine.alias.ilike(search_term),
Machine.hostname.ilike(search_term),
Machine.serialnumber.ilike(search_term),
Machine.notes.ilike(search_term)
)
).limit(10).all()
except Exception as e:
import logging
logging.error(f"Machine search failed: {e}")
machines = []
for m in machines:
# Determine type: PC, Printer, or Equipment
is_pc = m.pctypeid is not None
is_printer = m.is_printer
# Calculate relevance - exact matches score higher
relevance = 15
if m.machinenumber and query.lower() == m.machinenumber.lower():
relevance = 100
elif m.hostname and query.lower() == m.hostname.lower():
relevance = 100
elif m.alias and query.lower() in m.alias.lower():
relevance = 50
display_name = m.hostname if is_pc and m.hostname else m.machinenumber
if m.alias and not is_pc:
display_name = f"{m.machinenumber} ({m.alias})"
# Determine result type and URL
if is_printer:
result_type = 'printer'
url = f"/printers/{m.machineid}"
elif is_pc:
result_type = 'pc'
url = f"/pcs/{m.machineid}"
else:
result_type = 'machine'
url = f"/machines/{m.machineid}"
# Get location - prefer machine's own location, fall back to parent machine's location
location_name = None
if m.location:
location_name = m.location.locationname
elif m.parent_relationships:
# Check parent machines for location
for rel in m.parent_relationships:
if rel.parent_machine and rel.parent_machine.location:
location_name = rel.parent_machine.location.locationname
break
# Get machinetype from model (single source of truth)
mt = m.derived_machinetype
results.append({
'type': result_type,
'id': m.machineid,
'title': display_name,
'subtitle': mt.machinetype if mt else None,
'location': location_name,
'url': url,
'relevance': relevance
})
# NOTE: Legacy Machine search is disabled - all data is now in the Asset table
# The Asset search below handles equipment, computers, network devices, and printers
# with proper plugin-specific IDs for correct routing
# Search Applications
try:
@@ -173,37 +108,8 @@ def global_search():
import logging
logging.error(f"KnowledgeBase search failed: {e}")
# Search Printers (check if printers model exists)
try:
from shopdb.plugins.printers.models import Printer
printers = Printer.query.filter(
Printer.isactive == True,
db.or_(
Printer.printercsfname.ilike(search_term),
Printer.printerwindowsname.ilike(search_term),
Printer.serialnumber.ilike(search_term),
Printer.fqdn.ilike(search_term)
)
).limit(10).all()
for p in printers:
relevance = 15
if p.printercsfname and query.lower() == p.printercsfname.lower():
relevance = 100
display_name = p.printercsfname or p.printerwindowsname or f"Printer #{p.printerid}"
results.append({
'type': 'printer',
'id': p.printerid,
'title': display_name,
'subtitle': p.printerwindowsname if p.printercsfname else None,
'url': f"/printers/{p.printerid}",
'relevance': relevance
})
except Exception as e:
import logging
logging.error(f"Printer search failed: {e}")
# NOTE: Legacy Printer search removed - printers are now in the unified Asset table
# The Asset search below handles printers with correct plugin-specific IDs
# Search Employees (separate database)
try:
@@ -281,11 +187,23 @@ def global_search():
# Determine URL and type based on asset type
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
# Get the plugin-specific ID for proper routing
plugin_id = asset.assetid # fallback
if asset_type_name == 'equipment' and hasattr(asset, 'equipment') and asset.equipment:
plugin_id = asset.equipment.equipmentid
elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer:
plugin_id = asset.computer.computerid
elif asset_type_name == 'network_device' and hasattr(asset, 'network_device') and asset.network_device:
plugin_id = asset.network_device.networkdeviceid
elif asset_type_name == 'printer' and hasattr(asset, 'printer') and asset.printer:
plugin_id = asset.printer.printerid
url_map = {
'equipment': f"/equipment/{asset.assetid}",
'computer': f"/pcs/{asset.assetid}",
'network_device': f"/network/{asset.assetid}",
'printer': f"/printers/{asset.assetid}",
'equipment': f"/machines/{plugin_id}",
'computer': f"/pcs/{plugin_id}",
'network_device': f"/network/{plugin_id}",
'printer': f"/printers/{plugin_id}",
}
url = url_map.get(asset_type_name, f"/assets/{asset.assetid}")
@@ -299,7 +217,7 @@ def global_search():
results.append({
'type': asset_type_name,
'id': asset.assetid,
'id': plugin_id,
'title': display_name,
'subtitle': subtitle,
'location': location_name,

View File

@@ -156,12 +156,52 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
comm = self.communications.filter_by(comtypeid=1).first()
return comm.ipaddress if comm else None
def to_dict(self, include_type_data=False):
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()
@@ -175,6 +215,30 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
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()