diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index e4294a4..84eec0b 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -165,19 +165,19 @@ def list_computers(): ) # Computer type filter - if type_id := request.args.get('type_id'): + if type_id := request.args.get('typeid', request.args.get('type_id')): query = query.filter(Computer.computertypeid == int(type_id)) # OS filter - if os_id := request.args.get('os_id'): + if os_id := request.args.get('osid', request.args.get('os_id')): query = query.filter(Computer.osid == int(os_id)) # Location filter - if location_id := request.args.get('location_id'): + if location_id := request.args.get('locationid', request.args.get('location_id')): query = query.filter(Asset.locationid == int(location_id)) # Business unit filter - if bu_id := request.args.get('businessunit_id'): + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): query = query.filter(Asset.businessunitid == int(bu_id)) # Shopfloor filter diff --git a/plugins/equipment/api/routes.py b/plugins/equipment/api/routes.py index 59841c6..ca9ef74 100644 --- a/plugins/equipment/api/routes.py +++ b/plugins/equipment/api/routes.py @@ -160,19 +160,19 @@ def list_equipment(): ) # Equipment type filter - if type_id := request.args.get('type_id'): + if type_id := request.args.get('typeid', request.args.get('type_id')): query = query.filter(Equipment.equipmenttypeid == int(type_id)) # Vendor filter - if vendor_id := request.args.get('vendor_id'): + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): query = query.filter(Equipment.vendorid == int(vendor_id)) # Location filter - if location_id := request.args.get('location_id'): + if location_id := request.args.get('locationid', request.args.get('location_id')): query = query.filter(Asset.locationid == int(location_id)) # Business unit filter - if bu_id := request.args.get('businessunit_id'): + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): query = query.filter(Asset.businessunitid == int(bu_id)) # Sorting diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index fff93d9..2a71493 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -163,19 +163,19 @@ def list_network_devices(): ) # Type filter - if type_id := request.args.get('type_id'): + if type_id := request.args.get('typeid', request.args.get('type_id')): query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id)) # Vendor filter - if vendor_id := request.args.get('vendor_id'): + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): query = query.filter(NetworkDevice.vendorid == int(vendor_id)) # Location filter - if location_id := request.args.get('location_id'): + if location_id := request.args.get('locationid', request.args.get('location_id')): query = query.filter(Asset.locationid == int(location_id)) # Business unit filter - if bu_id := request.args.get('businessunit_id'): + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): query = query.filter(Asset.businessunitid == int(bu_id)) # PoE filter diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index fac86e4..4b738b6 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -94,7 +94,7 @@ def list_notifications(): query = query.filter(Notification.isactive == True) # Type filter - if type_id := request.args.get('type_id'): + if type_id := request.args.get('typeid', request.args.get('type_id')): query = query.filter(Notification.notificationtypeid == int(type_id)) # Current filter (active based on dates) diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 8ff7463..5e234e7 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -1,931 +1,931 @@ -"""Printers API routes - new Asset-based architecture.""" - -import logging - -from flask import Blueprint, request -from flask_jwt_extended import jwt_required - -from shopdb.extensions import db, cache -from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -from ..models import Printer, PrinterType, ModelSupply -from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS -from ..services import ( - ZabbixService, - classifysupply, - derivesupplytype, - derivecolor, - lookupsupplies, -) - -logger = logging.getLogger(__name__) - -printers_asset_bp = Blueprint('printers_asset', __name__) - - -# ============================================================================= -# Printer Types -# ============================================================================= - -@printers_asset_bp.route('/types', methods=['GET']) -@jwt_required(optional=True) -def list_printer_types(): - """List all printer types.""" - page, per_page = get_pagination_params(request) - - query = PrinterType.query - - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(PrinterType.isactive == True) - - if search := request.args.get('search'): - query = query.filter(PrinterType.printertype.ilike(f'%{search}%')) - - query = query.order_by(PrinterType.printertype) - - items, total = paginate_query(query, page, per_page) - data = [t.to_dict() for t in items] - - return paginated_response(data, page, per_page, total) - - -@printers_asset_bp.route('/types/', methods=['GET']) -@jwt_required(optional=True) -def get_printer_type(type_id: int): - """Get a single printer type.""" - t = PrinterType.query.get(type_id) - - if not t: - return error_response( - ErrorCodes.NOT_FOUND, - f'Printer type with ID {type_id} not found', - http_code=404 - ) - - return success_response(t.to_dict()) - - -@printers_asset_bp.route('/types', methods=['POST']) -@jwt_required() -def create_printer_type(): - """Create a new printer type.""" - data = request.get_json() - - if not data or not data.get('printertype'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'printertype is required') - - if PrinterType.query.filter_by(printertype=data['printertype']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Printer type '{data['printertype']}' already exists", - http_code=409 - ) - - t = PrinterType( - printertype=data['printertype'], - description=data.get('description'), - icon=data.get('icon') - ) - - db.session.add(t) - db.session.commit() - - return success_response(t.to_dict(), message='Printer type created', http_code=201) - - -# ============================================================================= -# Printers CRUD -# ============================================================================= - -@printers_asset_bp.route('', methods=['GET']) -@jwt_required(optional=True) -def list_printers(): - """ - List all printers with filtering and pagination. - - Query parameters: - - page, per_page: Pagination - - active: Filter by active status - - search: Search by asset number, name, or hostname - - type_id: Filter by printer type ID - - vendor_id: Filter by vendor ID - - location_id: Filter by location ID - - businessunit_id: Filter by business unit ID - """ - page, per_page = get_pagination_params(request) - - # Join Printer with Asset - query = db.session.query(Printer).join(Asset) - - # Active filter - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(Asset.isactive == True) - - # Search filter - if search := request.args.get('search'): - query = query.filter( - db.or_( - Asset.assetnumber.ilike(f'%{search}%'), - Asset.name.ilike(f'%{search}%'), - Asset.serialnumber.ilike(f'%{search}%'), - Printer.hostname.ilike(f'%{search}%'), - Printer.windowsname.ilike(f'%{search}%') - ) - ) - - # Type filter - if typeid := request.args.get('typeid', request.args.get('type_id')): - query = query.filter(Printer.printertypeid == int(typeid)) - - # Vendor filter - if vendor_id := request.args.get('vendor_id'): - query = query.filter(Printer.vendorid == int(vendor_id)) - - # Location filter - if location_id := request.args.get('location_id'): - query = query.filter(Asset.locationid == int(location_id)) - - # Business unit filter - if bu_id := request.args.get('businessunit_id'): - query = query.filter(Asset.businessunitid == int(bu_id)) - - # Sorting - sort_by = request.args.get('sort', 'hostname') - sort_dir = request.args.get('dir', 'asc') - - if sort_by == 'hostname': - col = Printer.hostname - elif sort_by == 'assetnumber': - col = Asset.assetnumber - elif sort_by == 'name': - col = Asset.name - else: - col = Printer.hostname - - query = query.order_by(col.desc() if sort_dir == 'desc' else col) - - items, total = paginate_query(query, page, per_page) - - # Build response with both asset and printer data - data = [] - for printer in items: - item = printer.asset.to_dict() if printer.asset else {} - item['printer'] = printer.to_dict() - - # Add primary IP address - if printer.asset: - primary_comm = Communication.query.filter_by( - assetid=printer.asset.assetid, - isprimary=True - ).first() - if not primary_comm: - primary_comm = Communication.query.filter_by( - assetid=printer.asset.assetid - ).first() - item['ipaddress'] = primary_comm.ipaddress if primary_comm else None - - data.append(item) - - return paginated_response(data, page, per_page, total) - - -@printers_asset_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def get_printer(printer_id: int): - """Get a single printer with full details.""" - printer = Printer.query.get(printer_id) - - if not printer: - return error_response( - ErrorCodes.NOT_FOUND, - f'Printer with ID {printer_id} not found', - http_code=404 - ) - - result = printer.asset.to_dict() if printer.asset else {} - result['printer'] = printer.to_dict() - - # Add communications - if printer.asset: - comms = Communication.query.filter_by(assetid=printer.asset.assetid).all() - result['communications'] = [c.to_dict() for c in comms] - - return success_response(result) - - -@printers_asset_bp.route('/by-asset/', methods=['GET']) -@jwt_required(optional=True) -def get_printer_by_asset(asset_id: int): - """Get printer data by asset ID.""" - printer = Printer.query.filter_by(assetid=asset_id).first() - - if not printer: - return error_response( - ErrorCodes.NOT_FOUND, - f'Printer for asset {asset_id} not found', - http_code=404 - ) - - result = printer.asset.to_dict() if printer.asset else {} - result['printer'] = printer.to_dict() - - return success_response(result) - - -@printers_asset_bp.route('', methods=['POST']) -@jwt_required() -def create_printer(): - """ - Create new printer (creates both Asset and Printer records). - - Required fields: - - assetnumber: Business identifier - - Optional fields: - - name, serialnumber, statusid, locationid, businessunitid - - printertypeid, vendorid, modelnumberid, hostname - - windowsname, sharename, iscsf, installpath, pin - - iscolor, isduplex, isnetwork - - mapx, mapy, notes - """ - data = request.get_json() - - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - if not data.get('assetnumber'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') - - # Check for duplicate assetnumber - if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Asset with number '{data['assetnumber']}' already exists", - http_code=409 - ) - - # Get printer asset type - printer_type = AssetType.query.filter_by(assettype='printer').first() - if not printer_type: - return error_response( - ErrorCodes.INTERNAL_ERROR, - 'Printer asset type not found. Plugin may not be properly installed.', - http_code=500 - ) - - # Create the core asset - asset = Asset( - assetnumber=data['assetnumber'], - name=data.get('name'), - serialnumber=data.get('serialnumber'), - assettypeid=printer_type.assettypeid, - statusid=data.get('statusid', 1), - locationid=data.get('locationid'), - businessunitid=data.get('businessunitid'), - mapx=data.get('mapx'), - mapy=data.get('mapy'), - notes=data.get('notes') - ) - - db.session.add(asset) - db.session.flush() # Get the assetid - - # Create the printer extension - printer = Printer( - assetid=asset.assetid, - printertypeid=data.get('printertypeid'), - vendorid=data.get('vendorid'), - modelnumberid=data.get('modelnumberid'), - hostname=data.get('hostname'), - windowsname=data.get('windowsname'), - sharename=data.get('sharename'), - iscsf=data.get('iscsf', False), - installpath=data.get('installpath'), - pin=data.get('pin'), - iscolor=data.get('iscolor', False), - isduplex=data.get('isduplex', False), - isnetwork=data.get('isnetwork', True) - ) - - db.session.add(printer) - - # Create communication record if IP provided - if data.get('ipaddress'): - ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() - if ip_comtype: - comm = Communication( - assetid=asset.assetid, - comtypeid=ip_comtype.comtypeid, - ipaddress=data['ipaddress'], - isprimary=True - ) - db.session.add(comm) - - db.session.commit() - - result = asset.to_dict() - result['printer'] = printer.to_dict() - - return success_response(result, message='Printer created', http_code=201) - - -@printers_asset_bp.route('/', methods=['PUT']) -@jwt_required() -def update_printer(printer_id: int): - """Update printer (both Asset and Printer records).""" - printer = Printer.query.get(printer_id) - - if not printer: - return error_response( - ErrorCodes.NOT_FOUND, - f'Printer with ID {printer_id} not found', - http_code=404 - ) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - asset = printer.asset - - # Check for conflicting assetnumber - if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: - if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Asset with number '{data['assetnumber']}' already exists", - http_code=409 - ) - - # Update asset fields (gauge lab / maintenance refs are equipment-only) - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', - 'locationid', 'businessunitid', 'mapx', 'mapy', - 'notes', 'isactive'] - for key in asset_fields: - if key in data: - setattr(asset, key, data[key]) - - # Update printer fields - printer_fields = ['printertypeid', 'vendorid', 'modelnumberid', 'hostname', - 'windowsname', 'sharename', 'iscsf', 'installpath', 'pin', - 'iscolor', 'isduplex', 'isnetwork'] - for key in printer_fields: - if key in data: - setattr(printer, key, data[key]) - - # Upsert the primary IP communication when an ipaddress is supplied, so a - # single PUT updates core, extension, and network in one call. - if 'ipaddress' in data: - ip = (data.get('ipaddress') or '').strip() - comm = Communication.query.filter_by( - assetid=asset.assetid, isprimary=True).first() - if ip: - if comm: - comm.ipaddress = ip - else: - ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() - if ip_comtype: - db.session.add(Communication( - assetid=asset.assetid, - comtypeid=ip_comtype.comtypeid, - ipaddress=ip, - isprimary=True, - )) - elif comm: - comm.ipaddress = None - - db.session.commit() - - result = asset.to_dict() - result['printer'] = printer.to_dict() - - return success_response(result, message='Printer updated') - - -@printers_asset_bp.route('/', methods=['DELETE']) -@jwt_required() -def delete_printer(printer_id: int): - """Delete (soft delete) printer.""" - printer = Printer.query.get(printer_id) - - if not printer: - return error_response( - ErrorCodes.NOT_FOUND, - f'Printer with ID {printer_id} not found', - http_code=404 - ) - - # Soft delete the asset - printer.asset.isactive = False - db.session.commit() - - return success_response(message='Printer deleted') - - -# ============================================================================= -# Supply Levels (Zabbix Integration) -# ============================================================================= - -@printers_asset_bp.route('//supplies', methods=['GET']) -@jwt_required(optional=True) -def get_printer_supplies(printer_id: int): - """Get supply levels from Zabbix (real-time lookup).""" - printer = Printer.query.get(printer_id) - - if not printer: - return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) - - # Get IP address from communications - comm = Communication.query.filter_by( - assetid=printer.assetid, - isprimary=True - ).first() - if not comm: - comm = Communication.query.filter_by(assetid=printer.assetid).first() - - if not comm or not comm.ipaddress: - return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') - - service = ZabbixService() - if not service.isconfigured or not service.isreachable: - # fail soft when zabbix off or down - return success_response({ - 'ipaddress': comm.ipaddress, - 'pingstatus': '-1', - 'supplies': [] - }) - - # vendor drives waste-cartridge rules; modelnumberid drives part lookup - vendor_name = printer.vendor.vendor if getattr(printer, 'vendor', None) else None - - raw_supplies = service.getsuppliesbyip(comm.ipaddress) or [] - supplies = [ - _annotate_supply(s, vendor_name, printer.modelnumberid) for s in raw_supplies - ] - - return success_response({ - 'ipaddress': comm.ipaddress, - 'pingstatus': service.getpingstatus(comm.ipaddress), - 'supplies': supplies - }) - - -# ============================================================================= -# Low Supplies -# ============================================================================= - -def _annotate_supply(supply, vendor_name, modelnumberid): - """Add status, remaining percent, and part numbers to a raw supply dict. - - Waste cartridge direction depends on vendor, so classification lives in - the supply_parts helper. Part numbers come from the modelsupplies table. - """ - level = supply.get('level', 0) - name = supply.get('name', 'Unknown') - supplytype = derivesupplytype(name) - color = derivecolor(name, supply.get('color')) - cls = classifysupply(level, name, vendor_name) - return { - 'name': name, - 'level': level, - 'color': color, - 'supplytype': supplytype, - 'status': cls['status'], - 'remaining': cls['remaining'], - 'iswaste': cls['iswaste'], - 'isdrum': cls['isdrum'], - 'partnumbers': lookupsupplies(modelnumberid, color, supplytype), - } - - -def _get_low_supplies_data(): - """Build low supplies data (cached for 5 minutes).""" - cached = cache.get('printers_low_supplies') - if cached is not None: - return cached - - service = ZabbixService() - if not service.isconfigured or not service.isreachable: - return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}} - - # active printers with an IP, with vendor and model for waste/part rules - rows = ( - db.session.query(Printer, Asset, Communication, Vendor, Model) - .join(Asset, Asset.assetid == Printer.assetid) - .join(Communication, Communication.assetid == Asset.assetid) - .outerjoin(Vendor, Vendor.vendorid == Printer.vendorid) - .outerjoin(Model, Model.modelnumberid == Printer.modelnumberid) - .filter(Asset.isactive == True) - .filter(Communication.ipaddress.isnot(None)) - .filter(Communication.ipaddress != '') - .all() - ) - - # dedupe by printer id (a printer may have several comms) - seen = set() - unique_printers = [] - for printer, asset, comm, vendor, model in rows: - if printer.printerid not in seen: - seen.add(printer.printerid) - unique_printers.append((printer, asset, comm, vendor, model)) - - results = [] - total_checked = 0 - - for printer, asset, comm, vendor, model in unique_printers: - supplies = service.getsuppliesbyip_cached(comm.ipaddress) - if supplies is None: - continue - - total_checked += 1 - - vendor_name = vendor.vendor if vendor else None - model_number = model.modelnumber if model else None - modelnumberid = model.modelnumberid if model else None - - annotated = [] - has_low = False - for s in supplies: - item = _annotate_supply(s, vendor_name, modelnumberid) - if item['status'] != 'ok': - has_low = True - annotated.append(item) - - if has_low: - # location name for the report row - location_name = None - if asset.locationid: - from shopdb.core.models import Location - loc = Location.query.get(asset.locationid) - if loc: - location_name = loc.locationname - - results.append({ - 'printerid': printer.printerid, - 'printername': asset.name or printer.hostname or '', - 'assetnumber': asset.assetnumber or '', - 'ipaddress': comm.ipaddress, - 'vendor': vendor_name, - 'model': model_number, - 'location': location_name, - 'supplies': annotated - }) - - low_count = 0 - critical_count = 0 - for p in results: - has_critical = any(s['status'] == 'critical' for s in p['supplies']) - has_low = any(s['status'] == 'low' for s in p['supplies']) - if has_critical: - critical_count += 1 - elif has_low: - low_count += 1 - - data = { - 'printers': results, - 'summary': { - 'total_checked': total_checked, - 'low': low_count, - 'critical': critical_count - } - } - - cache.set('printers_low_supplies', data, timeout=300) - return data - - -@printers_asset_bp.route('/lowsupplies', methods=['GET']) -@jwt_required(optional=True) -def low_supplies(): - """Get printers with low or critical supply levels.""" - data = _get_low_supplies_data() - return success_response(data) - - -@printers_asset_bp.route('/lookup', methods=['GET']) -@jwt_required(optional=True) -def printer_lookup(): - """Find a printer by IP or FQDN. Parity with the classic printerlookup.asp. - - Zabbix uses this to jump straight to a printer record. Query with - ?ip=x.x.x.x or ?fqdn=hostname; returns the matching printer id. - """ - ip = (request.args.get('ip') or '').strip() - fqdn = (request.args.get('fqdn') or '').strip() - lookup_value = ip or fqdn - - if not lookup_value: - return error_response( - ErrorCodes.VALIDATION_ERROR, - 'Provide ip or fqdn' - ) - - # match the IP against any active printer communication - row = ( - db.session.query(Printer, Asset) - .join(Asset, Asset.assetid == Printer.assetid) - .join(Communication, Communication.assetid == Asset.assetid) - .filter(Asset.isactive == True) - .filter(Communication.ipaddress == lookup_value) - .first() - ) - - if not row: - return error_response( - ErrorCodes.NOT_FOUND, - f'Printer not found: {lookup_value}', - http_code=404 - ) - - printer, asset = row - return success_response({ - 'printerid': printer.printerid, - 'assetid': asset.assetid, - 'assetnumber': asset.assetnumber, - 'name': asset.name or printer.hostname, - }) - - -@printers_asset_bp.route('/supplies/refresh', methods=['POST']) -@jwt_required() -def refresh_supplies_cache(): - """Clear cached Zabbix supply data so the next read pulls fresh values. - - Backs the toner report Refresh button (parity with adminclearcache.asp - type=zabbix). - """ - ZabbixService().clearcache() - return success_response(message='Supply cache cleared') - - -# ============================================================================= -# Dashboard -# ============================================================================= - -@printers_asset_bp.route('/dashboard/summary', methods=['GET']) -@jwt_required(optional=True) -def dashboard_summary(): - """Get printer dashboard summary data.""" - # Total active printers - total = db.session.query(Printer).join(Asset).filter( - Asset.isactive == True - ).count() - - # Count by printer type - by_type = db.session.query( - PrinterType.printertype, - db.func.count(Printer.printerid) - ).join(Printer, Printer.printertypeid == PrinterType.printertypeid - ).join(Asset, Asset.assetid == Printer.assetid - ).filter(Asset.isactive == True - ).group_by(PrinterType.printertype - ).all() - - # Count by vendor - by_vendor = db.session.query( - Vendor.vendor, - db.func.count(Printer.printerid) - ).join(Printer, Printer.vendorid == Vendor.vendorid - ).join(Asset, Asset.assetid == Printer.assetid - ).filter(Asset.isactive == True - ).group_by(Vendor.vendor - ).all() - - # Get real low/critical supply counts (skip if Zabbix not reachable) - low_count = 0 - critical_count = 0 - service = ZabbixService() - if service.isconfigured and service.isreachable: - try: - supply_data = _get_low_supplies_data() - low_count = supply_data['summary']['low'] - critical_count = supply_data['summary']['critical'] - except Exception as e: - logger.warning(f"Could not fetch supply data for dashboard: {e}") - - return success_response({ - 'total': total, - 'totalprinters': total, - 'online': total, - 'lowsupplies': low_count, - 'criticalsupplies': critical_count, - 'bytype': [{'type': t, 'count': c} for t, c in by_type], - 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], - }) - - -# ============================================================================= -# Model Supplies (data-driven toner/drum/waste part numbers) -# ============================================================================= - -def _validate_supply_payload(data): - """Return an error message if the supply payload is invalid, else None.""" - if not data: - return 'No data provided' - if not data.get('partnumber'): - return 'partnumber is required' - supplytype = data.get('supplytype', 'toner') - if supplytype not in SUPPLY_TYPES: - return f"supplytype must be one of {', '.join(SUPPLY_TYPES)}" - color = data.get('color', 'none') - if color not in SUPPLY_COLORS: - return f"color must be one of {', '.join(SUPPLY_COLORS)}" - capacitytier = data.get('capacitytier', 'standard') - if capacitytier not in CAPACITY_TIERS: - return f"capacitytier must be one of {', '.join(CAPACITY_TIERS)}" - return None - - -@printers_asset_bp.route('/supplies/meta', methods=['GET']) -@jwt_required(optional=True) -def supplies_meta(): - """Allowed values for supply type, color, and capacity tier (for the UI).""" - return success_response({ - 'supplytypes': list(SUPPLY_TYPES), - 'colors': list(SUPPLY_COLORS), - 'capacitytiers': list(CAPACITY_TIERS), - }) - - -@printers_asset_bp.route('/models', methods=['GET']) -@jwt_required(optional=True) -def list_supply_models(): - """List models with a supply count, for the supply-management picker. - - Query parameters: - - search: filter by model number - - vendor_id: filter by vendor - - withsupplies: 'true' to only return models that already have supplies - """ - page, per_page = get_pagination_params(request) - - supplycount = db.func.count(ModelSupply.modelsupplyid).label('supplycount') - query = ( - db.session.query(Model, Vendor.vendor, supplycount) - .outerjoin(Vendor, Vendor.vendorid == Model.vendorid) - .outerjoin(ModelSupply, db.and_( - ModelSupply.modelnumberid == Model.modelnumberid, - ModelSupply.isactive == True, - )) - .group_by(Model.modelnumberid, Vendor.vendor) - ) - - # Toner/drum/waste only apply to printers, so restrict the picker to - # printer models: those attached to a printer asset, or those that already - # carry supply mappings. Keeps machine/controller models out of the list. - printer_model_ids = ( - db.session.query(Printer.modelnumberid) - .filter(Printer.modelnumberid.isnot(None)) - ) - supply_model_ids = db.session.query(ModelSupply.modelnumberid) - query = query.filter(db.or_( - Model.modelnumberid.in_(printer_model_ids), - Model.modelnumberid.in_(supply_model_ids), - )) - - if search := request.args.get('search'): - query = query.filter(Model.modelnumber.ilike(f'%{search}%')) - if vendor_id := request.args.get('vendor_id'): - query = query.filter(Model.vendorid == int(vendor_id)) - if request.args.get('withsupplies', '').lower() == 'true': - query = query.having(supplycount > 0) - - query = query.order_by(Model.modelnumber) - - total = query.count() - rows = query.limit(per_page).offset((page - 1) * per_page).all() - - data = [{ - 'modelnumberid': model.modelnumberid, - 'modelnumber': model.modelnumber, - 'vendor': vendor, - 'vendorid': model.vendorid, - 'supplycount': count, - } for model, vendor, count in rows] - - return paginated_response(data, page, per_page, total) - - -@printers_asset_bp.route('/models//supplies', methods=['GET']) -@jwt_required(optional=True) -def list_model_supplies(modelnumberid: int): - """List all supplies mapped to a model.""" - model = Model.query.get(modelnumberid) - if not model: - return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) - - supplies = ( - ModelSupply.query - .filter_by(modelnumberid=modelnumberid, isactive=True) - .order_by(ModelSupply.supplytype, ModelSupply.color, ModelSupply.capacitytier) - .all() - ) - return success_response({ - 'modelnumberid': modelnumberid, - 'modelnumber': model.modelnumber, - 'supplies': [s.to_dict() for s in supplies], - }) - - -@printers_asset_bp.route('/models//supplies', methods=['POST']) -@jwt_required() -def create_model_supply(modelnumberid: int): - """Add a supply to a model.""" - model = Model.query.get(modelnumberid) - if not model: - return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) - - data = request.get_json() - message = _validate_supply_payload(data) - if message: - return error_response(ErrorCodes.VALIDATION_ERROR, message) - - existing = ModelSupply.query.filter_by( - modelnumberid=modelnumberid, - partnumber=data['partnumber'], - ).first() - if existing: - return error_response( - ErrorCodes.CONFLICT, - f"Part number '{data['partnumber']}' already mapped to this model", - http_code=409, - ) - - supply = ModelSupply( - modelnumberid=modelnumberid, - supplytype=data.get('supplytype', 'toner'), - color=data.get('color', 'none'), - capacitytier=data.get('capacitytier', 'standard'), - partnumber=data['partnumber'], - marketingname=data.get('marketingname'), - pageyield=data.get('pageyield'), - notes=data.get('notes'), - ) - db.session.add(supply) - db.session.commit() - - return success_response(supply.to_dict(), message='Supply added', http_code=201) - - -@printers_asset_bp.route('/supplies/', methods=['PUT']) -@jwt_required() -def update_model_supply(modelsupplyid: int): - """Update a model supply.""" - supply = ModelSupply.query.get(modelsupplyid) - if not supply: - return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # validate only the fields present - merged = { - 'partnumber': data.get('partnumber', supply.partnumber), - 'supplytype': data.get('supplytype', supply.supplytype), - 'color': data.get('color', supply.color), - 'capacitytier': data.get('capacitytier', supply.capacitytier), - } - message = _validate_supply_payload(merged) - if message: - return error_response(ErrorCodes.VALIDATION_ERROR, message) - - if 'partnumber' in data and data['partnumber'] != supply.partnumber: - clash = ModelSupply.query.filter_by( - modelnumberid=supply.modelnumberid, - partnumber=data['partnumber'], - ).first() - if clash: - return error_response( - ErrorCodes.CONFLICT, - f"Part number '{data['partnumber']}' already mapped to this model", - http_code=409, - ) - - for field in ('supplytype', 'color', 'capacitytier', 'partnumber', - 'marketingname', 'pageyield', 'notes'): - if field in data: - setattr(supply, field, data[field]) - - db.session.commit() - return success_response(supply.to_dict(), message='Supply updated') - - -@printers_asset_bp.route('/supplies/', methods=['DELETE']) -@jwt_required() -def delete_model_supply(modelsupplyid: int): - """Delete a model supply.""" - supply = ModelSupply.query.get(modelsupplyid) - if not supply: - return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) - - db.session.delete(supply) - db.session.commit() - return success_response(message='Supply deleted') +"""Printers API routes - new Asset-based architecture.""" + +import logging + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db, cache +from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from ..models import Printer, PrinterType, ModelSupply +from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS +from ..services import ( + ZabbixService, + classifysupply, + derivesupplytype, + derivecolor, + lookupsupplies, +) + +logger = logging.getLogger(__name__) + +printers_asset_bp = Blueprint('printers_asset', __name__) + + +# ============================================================================= +# Printer Types +# ============================================================================= + +@printers_asset_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_printer_types(): + """List all printer types.""" + page, per_page = get_pagination_params(request) + + query = PrinterType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(PrinterType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(PrinterType.printertype.ilike(f'%{search}%')) + + query = query.order_by(PrinterType.printertype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_printer_type(type_id: int): + """Get a single printer type.""" + t = PrinterType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@printers_asset_bp.route('/types', methods=['POST']) +@jwt_required() +def create_printer_type(): + """Create a new printer type.""" + data = request.get_json() + + if not data or not data.get('printertype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'printertype is required') + + if PrinterType.query.filter_by(printertype=data['printertype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Printer type '{data['printertype']}' already exists", + http_code=409 + ) + + t = PrinterType( + printertype=data['printertype'], + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Printer type created', http_code=201) + + +# ============================================================================= +# Printers CRUD +# ============================================================================= + +@printers_asset_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_printers(): + """ + List all printers with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by printer type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + """ + page, per_page = get_pagination_params(request) + + # Join Printer with Asset + query = db.session.query(Printer).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + Printer.hostname.ilike(f'%{search}%'), + Printer.windowsname.ilike(f'%{search}%') + ) + ) + + # Type filter + if typeid := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Printer.printertypeid == int(typeid)) + + # Vendor filter + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): + query = query.filter(Printer.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = Printer.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = Printer.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and printer data + data = [] + for printer in items: + item = printer.asset.to_dict() if printer.asset else {} + item['printer'] = printer.to_dict() + + # Add primary IP address + if printer.asset: + primary_comm = Communication.query.filter_by( + assetid=printer.asset.assetid, + isprimary=True + ).first() + if not primary_comm: + primary_comm = Communication.query.filter_by( + assetid=printer.asset.assetid + ).first() + item['ipaddress'] = primary_comm.ipaddress if primary_comm else None + + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_printer(printer_id: int): + """Get a single printer with full details.""" + printer = Printer.query.get(printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + result = printer.asset.to_dict() if printer.asset else {} + result['printer'] = printer.to_dict() + + # Add communications + if printer.asset: + comms = Communication.query.filter_by(assetid=printer.asset.assetid).all() + result['communications'] = [c.to_dict() for c in comms] + + return success_response(result) + + +@printers_asset_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_printer_by_asset(asset_id: int): + """Get printer data by asset ID.""" + printer = Printer.query.filter_by(assetid=asset_id).first() + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer for asset {asset_id} not found', + http_code=404 + ) + + result = printer.asset.to_dict() if printer.asset else {} + result['printer'] = printer.to_dict() + + return success_response(result) + + +@printers_asset_bp.route('', methods=['POST']) +@jwt_required() +def create_printer(): + """ + Create new printer (creates both Asset and Printer records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - printertypeid, vendorid, modelnumberid, hostname + - windowsname, sharename, iscsf, installpath, pin + - iscolor, isduplex, isnetwork + - mapx, mapy, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Get printer asset type + printer_type = AssetType.query.filter_by(assettype='printer').first() + if not printer_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Printer asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + assettypeid=printer_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the printer extension + printer = Printer( + assetid=asset.assetid, + printertypeid=data.get('printertypeid'), + vendorid=data.get('vendorid'), + modelnumberid=data.get('modelnumberid'), + hostname=data.get('hostname'), + windowsname=data.get('windowsname'), + sharename=data.get('sharename'), + iscsf=data.get('iscsf', False), + installpath=data.get('installpath'), + pin=data.get('pin'), + iscolor=data.get('iscolor', False), + isduplex=data.get('isduplex', False), + isnetwork=data.get('isnetwork', True) + ) + + db.session.add(printer) + + # Create communication record if IP provided + if data.get('ipaddress'): + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + comm = Communication( + assetid=asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress=data['ipaddress'], + isprimary=True + ) + db.session.add(comm) + + db.session.commit() + + result = asset.to_dict() + result['printer'] = printer.to_dict() + + return success_response(result, message='Printer created', http_code=201) + + +@printers_asset_bp.route('/', methods=['PUT']) +@jwt_required() +def update_printer(printer_id: int): + """Update printer (both Asset and Printer records).""" + printer = Printer.query.get(printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = printer.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Update asset fields (gauge lab / maintenance refs are equipment-only) + asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', + 'notes', 'isactive'] + for key in asset_fields: + if key in data: + setattr(asset, key, data[key]) + + # Update printer fields + printer_fields = ['printertypeid', 'vendorid', 'modelnumberid', 'hostname', + 'windowsname', 'sharename', 'iscsf', 'installpath', 'pin', + 'iscolor', 'isduplex', 'isnetwork'] + for key in printer_fields: + if key in data: + setattr(printer, key, data[key]) + + # Upsert the primary IP communication when an ipaddress is supplied, so a + # single PUT updates core, extension, and network in one call. + if 'ipaddress' in data: + ip = (data.get('ipaddress') or '').strip() + comm = Communication.query.filter_by( + assetid=asset.assetid, isprimary=True).first() + if ip: + if comm: + comm.ipaddress = ip + else: + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + db.session.add(Communication( + assetid=asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress=ip, + isprimary=True, + )) + elif comm: + comm.ipaddress = None + + db.session.commit() + + result = asset.to_dict() + result['printer'] = printer.to_dict() + + return success_response(result, message='Printer updated') + + +@printers_asset_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_printer(printer_id: int): + """Delete (soft delete) printer.""" + printer = Printer.query.get(printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + # Soft delete the asset + printer.asset.isactive = False + db.session.commit() + + return success_response(message='Printer deleted') + + +# ============================================================================= +# Supply Levels (Zabbix Integration) +# ============================================================================= + +@printers_asset_bp.route('//supplies', methods=['GET']) +@jwt_required(optional=True) +def get_printer_supplies(printer_id: int): + """Get supply levels from Zabbix (real-time lookup).""" + printer = Printer.query.get(printer_id) + + if not printer: + return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) + + # Get IP address from communications + comm = Communication.query.filter_by( + assetid=printer.assetid, + isprimary=True + ).first() + if not comm: + comm = Communication.query.filter_by(assetid=printer.assetid).first() + + if not comm or not comm.ipaddress: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') + + service = ZabbixService() + if not service.isconfigured or not service.isreachable: + # fail soft when zabbix off or down + return success_response({ + 'ipaddress': comm.ipaddress, + 'pingstatus': '-1', + 'supplies': [] + }) + + # vendor drives waste-cartridge rules; modelnumberid drives part lookup + vendor_name = printer.vendor.vendor if getattr(printer, 'vendor', None) else None + + raw_supplies = service.getsuppliesbyip(comm.ipaddress) or [] + supplies = [ + _annotate_supply(s, vendor_name, printer.modelnumberid) for s in raw_supplies + ] + + return success_response({ + 'ipaddress': comm.ipaddress, + 'pingstatus': service.getpingstatus(comm.ipaddress), + 'supplies': supplies + }) + + +# ============================================================================= +# Low Supplies +# ============================================================================= + +def _annotate_supply(supply, vendor_name, modelnumberid): + """Add status, remaining percent, and part numbers to a raw supply dict. + + Waste cartridge direction depends on vendor, so classification lives in + the supply_parts helper. Part numbers come from the modelsupplies table. + """ + level = supply.get('level', 0) + name = supply.get('name', 'Unknown') + supplytype = derivesupplytype(name) + color = derivecolor(name, supply.get('color')) + cls = classifysupply(level, name, vendor_name) + return { + 'name': name, + 'level': level, + 'color': color, + 'supplytype': supplytype, + 'status': cls['status'], + 'remaining': cls['remaining'], + 'iswaste': cls['iswaste'], + 'isdrum': cls['isdrum'], + 'partnumbers': lookupsupplies(modelnumberid, color, supplytype), + } + + +def _get_low_supplies_data(): + """Build low supplies data (cached for 5 minutes).""" + cached = cache.get('printers_low_supplies') + if cached is not None: + return cached + + service = ZabbixService() + if not service.isconfigured or not service.isreachable: + return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}} + + # active printers with an IP, with vendor and model for waste/part rules + rows = ( + db.session.query(Printer, Asset, Communication, Vendor, Model) + .join(Asset, Asset.assetid == Printer.assetid) + .join(Communication, Communication.assetid == Asset.assetid) + .outerjoin(Vendor, Vendor.vendorid == Printer.vendorid) + .outerjoin(Model, Model.modelnumberid == Printer.modelnumberid) + .filter(Asset.isactive == True) + .filter(Communication.ipaddress.isnot(None)) + .filter(Communication.ipaddress != '') + .all() + ) + + # dedupe by printer id (a printer may have several comms) + seen = set() + unique_printers = [] + for printer, asset, comm, vendor, model in rows: + if printer.printerid not in seen: + seen.add(printer.printerid) + unique_printers.append((printer, asset, comm, vendor, model)) + + results = [] + total_checked = 0 + + for printer, asset, comm, vendor, model in unique_printers: + supplies = service.getsuppliesbyip_cached(comm.ipaddress) + if supplies is None: + continue + + total_checked += 1 + + vendor_name = vendor.vendor if vendor else None + model_number = model.modelnumber if model else None + modelnumberid = model.modelnumberid if model else None + + annotated = [] + has_low = False + for s in supplies: + item = _annotate_supply(s, vendor_name, modelnumberid) + if item['status'] != 'ok': + has_low = True + annotated.append(item) + + if has_low: + # location name for the report row + location_name = None + if asset.locationid: + from shopdb.core.models import Location + loc = Location.query.get(asset.locationid) + if loc: + location_name = loc.locationname + + results.append({ + 'printerid': printer.printerid, + 'printername': asset.name or printer.hostname or '', + 'assetnumber': asset.assetnumber or '', + 'ipaddress': comm.ipaddress, + 'vendor': vendor_name, + 'model': model_number, + 'location': location_name, + 'supplies': annotated + }) + + low_count = 0 + critical_count = 0 + for p in results: + has_critical = any(s['status'] == 'critical' for s in p['supplies']) + has_low = any(s['status'] == 'low' for s in p['supplies']) + if has_critical: + critical_count += 1 + elif has_low: + low_count += 1 + + data = { + 'printers': results, + 'summary': { + 'total_checked': total_checked, + 'low': low_count, + 'critical': critical_count + } + } + + cache.set('printers_low_supplies', data, timeout=300) + return data + + +@printers_asset_bp.route('/lowsupplies', methods=['GET']) +@jwt_required(optional=True) +def low_supplies(): + """Get printers with low or critical supply levels.""" + data = _get_low_supplies_data() + return success_response(data) + + +@printers_asset_bp.route('/lookup', methods=['GET']) +@jwt_required(optional=True) +def printer_lookup(): + """Find a printer by IP or FQDN. Parity with the classic printerlookup.asp. + + Zabbix uses this to jump straight to a printer record. Query with + ?ip=x.x.x.x or ?fqdn=hostname; returns the matching printer id. + """ + ip = (request.args.get('ip') or '').strip() + fqdn = (request.args.get('fqdn') or '').strip() + lookup_value = ip or fqdn + + if not lookup_value: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Provide ip or fqdn' + ) + + # match the IP against any active printer communication + row = ( + db.session.query(Printer, Asset) + .join(Asset, Asset.assetid == Printer.assetid) + .join(Communication, Communication.assetid == Asset.assetid) + .filter(Asset.isactive == True) + .filter(Communication.ipaddress == lookup_value) + .first() + ) + + if not row: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer not found: {lookup_value}', + http_code=404 + ) + + printer, asset = row + return success_response({ + 'printerid': printer.printerid, + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'name': asset.name or printer.hostname, + }) + + +@printers_asset_bp.route('/supplies/refresh', methods=['POST']) +@jwt_required() +def refresh_supplies_cache(): + """Clear cached Zabbix supply data so the next read pulls fresh values. + + Backs the toner report Refresh button (parity with adminclearcache.asp + type=zabbix). + """ + ZabbixService().clearcache() + return success_response(message='Supply cache cleared') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@printers_asset_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get printer dashboard summary data.""" + # Total active printers + total = db.session.query(Printer).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by printer type + by_type = db.session.query( + PrinterType.printertype, + db.func.count(Printer.printerid) + ).join(Printer, Printer.printertypeid == PrinterType.printertypeid + ).join(Asset, Asset.assetid == Printer.assetid + ).filter(Asset.isactive == True + ).group_by(PrinterType.printertype + ).all() + + # Count by vendor + by_vendor = db.session.query( + Vendor.vendor, + db.func.count(Printer.printerid) + ).join(Printer, Printer.vendorid == Vendor.vendorid + ).join(Asset, Asset.assetid == Printer.assetid + ).filter(Asset.isactive == True + ).group_by(Vendor.vendor + ).all() + + # Get real low/critical supply counts (skip if Zabbix not reachable) + low_count = 0 + critical_count = 0 + service = ZabbixService() + if service.isconfigured and service.isreachable: + try: + supply_data = _get_low_supplies_data() + low_count = supply_data['summary']['low'] + critical_count = supply_data['summary']['critical'] + except Exception as e: + logger.warning(f"Could not fetch supply data for dashboard: {e}") + + return success_response({ + 'total': total, + 'totalprinters': total, + 'online': total, + 'lowsupplies': low_count, + 'criticalsupplies': critical_count, + 'bytype': [{'type': t, 'count': c} for t, c in by_type], + 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], + }) + + +# ============================================================================= +# Model Supplies (data-driven toner/drum/waste part numbers) +# ============================================================================= + +def _validate_supply_payload(data): + """Return an error message if the supply payload is invalid, else None.""" + if not data: + return 'No data provided' + if not data.get('partnumber'): + return 'partnumber is required' + supplytype = data.get('supplytype', 'toner') + if supplytype not in SUPPLY_TYPES: + return f"supplytype must be one of {', '.join(SUPPLY_TYPES)}" + color = data.get('color', 'none') + if color not in SUPPLY_COLORS: + return f"color must be one of {', '.join(SUPPLY_COLORS)}" + capacitytier = data.get('capacitytier', 'standard') + if capacitytier not in CAPACITY_TIERS: + return f"capacitytier must be one of {', '.join(CAPACITY_TIERS)}" + return None + + +@printers_asset_bp.route('/supplies/meta', methods=['GET']) +@jwt_required(optional=True) +def supplies_meta(): + """Allowed values for supply type, color, and capacity tier (for the UI).""" + return success_response({ + 'supplytypes': list(SUPPLY_TYPES), + 'colors': list(SUPPLY_COLORS), + 'capacitytiers': list(CAPACITY_TIERS), + }) + + +@printers_asset_bp.route('/models', methods=['GET']) +@jwt_required(optional=True) +def list_supply_models(): + """List models with a supply count, for the supply-management picker. + + Query parameters: + - search: filter by model number + - vendor_id: filter by vendor + - withsupplies: 'true' to only return models that already have supplies + """ + page, per_page = get_pagination_params(request) + + supplycount = db.func.count(ModelSupply.modelsupplyid).label('supplycount') + query = ( + db.session.query(Model, Vendor.vendor, supplycount) + .outerjoin(Vendor, Vendor.vendorid == Model.vendorid) + .outerjoin(ModelSupply, db.and_( + ModelSupply.modelnumberid == Model.modelnumberid, + ModelSupply.isactive == True, + )) + .group_by(Model.modelnumberid, Vendor.vendor) + ) + + # Toner/drum/waste only apply to printers, so restrict the picker to + # printer models: those attached to a printer asset, or those that already + # carry supply mappings. Keeps machine/controller models out of the list. + printer_model_ids = ( + db.session.query(Printer.modelnumberid) + .filter(Printer.modelnumberid.isnot(None)) + ) + supply_model_ids = db.session.query(ModelSupply.modelnumberid) + query = query.filter(db.or_( + Model.modelnumberid.in_(printer_model_ids), + Model.modelnumberid.in_(supply_model_ids), + )) + + if search := request.args.get('search'): + query = query.filter(Model.modelnumber.ilike(f'%{search}%')) + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): + query = query.filter(Model.vendorid == int(vendor_id)) + if request.args.get('withsupplies', '').lower() == 'true': + query = query.having(supplycount > 0) + + query = query.order_by(Model.modelnumber) + + total = query.count() + rows = query.limit(per_page).offset((page - 1) * per_page).all() + + data = [{ + 'modelnumberid': model.modelnumberid, + 'modelnumber': model.modelnumber, + 'vendor': vendor, + 'vendorid': model.vendorid, + 'supplycount': count, + } for model, vendor, count in rows] + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/models//supplies', methods=['GET']) +@jwt_required(optional=True) +def list_model_supplies(modelnumberid: int): + """List all supplies mapped to a model.""" + model = Model.query.get(modelnumberid) + if not model: + return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) + + supplies = ( + ModelSupply.query + .filter_by(modelnumberid=modelnumberid, isactive=True) + .order_by(ModelSupply.supplytype, ModelSupply.color, ModelSupply.capacitytier) + .all() + ) + return success_response({ + 'modelnumberid': modelnumberid, + 'modelnumber': model.modelnumber, + 'supplies': [s.to_dict() for s in supplies], + }) + + +@printers_asset_bp.route('/models//supplies', methods=['POST']) +@jwt_required() +def create_model_supply(modelnumberid: int): + """Add a supply to a model.""" + model = Model.query.get(modelnumberid) + if not model: + return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) + + data = request.get_json() + message = _validate_supply_payload(data) + if message: + return error_response(ErrorCodes.VALIDATION_ERROR, message) + + existing = ModelSupply.query.filter_by( + modelnumberid=modelnumberid, + partnumber=data['partnumber'], + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Part number '{data['partnumber']}' already mapped to this model", + http_code=409, + ) + + supply = ModelSupply( + modelnumberid=modelnumberid, + supplytype=data.get('supplytype', 'toner'), + color=data.get('color', 'none'), + capacitytier=data.get('capacitytier', 'standard'), + partnumber=data['partnumber'], + marketingname=data.get('marketingname'), + pageyield=data.get('pageyield'), + notes=data.get('notes'), + ) + db.session.add(supply) + db.session.commit() + + return success_response(supply.to_dict(), message='Supply added', http_code=201) + + +@printers_asset_bp.route('/supplies/', methods=['PUT']) +@jwt_required() +def update_model_supply(modelsupplyid: int): + """Update a model supply.""" + supply = ModelSupply.query.get(modelsupplyid) + if not supply: + return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # validate only the fields present + merged = { + 'partnumber': data.get('partnumber', supply.partnumber), + 'supplytype': data.get('supplytype', supply.supplytype), + 'color': data.get('color', supply.color), + 'capacitytier': data.get('capacitytier', supply.capacitytier), + } + message = _validate_supply_payload(merged) + if message: + return error_response(ErrorCodes.VALIDATION_ERROR, message) + + if 'partnumber' in data and data['partnumber'] != supply.partnumber: + clash = ModelSupply.query.filter_by( + modelnumberid=supply.modelnumberid, + partnumber=data['partnumber'], + ).first() + if clash: + return error_response( + ErrorCodes.CONFLICT, + f"Part number '{data['partnumber']}' already mapped to this model", + http_code=409, + ) + + for field in ('supplytype', 'color', 'capacitytier', 'partnumber', + 'marketingname', 'pageyield', 'notes'): + if field in data: + setattr(supply, field, data[field]) + + db.session.commit() + return success_response(supply.to_dict(), message='Supply updated') + + +@printers_asset_bp.route('/supplies/', methods=['DELETE']) +@jwt_required() +def delete_model_supply(modelsupplyid: int): + """Delete a model supply.""" + supply = ModelSupply.query.get(modelsupplyid) + if not supply: + return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) + + db.session.delete(supply) + db.session.commit() + return success_response(message='Supply deleted') diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py index 7455c33..2536518 100644 --- a/shopdb/core/api/assets.py +++ b/shopdb/core/api/assets.py @@ -1,921 +1,921 @@ -"""Assets API endpoints - unified asset queries.""" - -from flask import Blueprint, request -from flask_jwt_extended import jwt_required -from sqlalchemy.orm import joinedload, subqueryload - -from shopdb.extensions import db -from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -assets_bp = Blueprint('assets', __name__) - - -# ============================================================================= -# Asset Types -# ============================================================================= - -@assets_bp.route('/types', methods=['GET']) -@jwt_required(optional=True) -def list_asset_types(): - """List all asset types.""" - page, per_page = get_pagination_params(request) - - query = AssetType.query - - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(AssetType.isactive == True) - - query = query.order_by(AssetType.assettype) - - items, total = paginate_query(query, page, per_page) - data = [t.to_dict() for t in items] - - return paginated_response(data, page, per_page, total) - - -@assets_bp.route('/types/', methods=['GET']) -@jwt_required(optional=True) -def get_asset_type(type_id: int): - """Get a single asset type.""" - t = AssetType.query.get(type_id) - - if not t: - return error_response( - ErrorCodes.NOT_FOUND, - f'Asset type with ID {type_id} not found', - http_code=404 - ) - - return success_response(t.to_dict()) - - -@assets_bp.route('/types', methods=['POST']) -@jwt_required() -def create_asset_type(): - """Create a new asset type.""" - data = request.get_json() - - if not data or not data.get('assettype'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'assettype is required') - - if AssetType.query.filter_by(assettype=data['assettype']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Asset type '{data['assettype']}' already exists", - http_code=409 - ) - - t = AssetType( - assettype=data['assettype'], - pluginname=data.get('pluginname'), - tablename=data.get('tablename'), - description=data.get('description'), - icon=data.get('icon') - ) - - db.session.add(t) - db.session.commit() - - return success_response(t.to_dict(), message='Asset type created', http_code=201) - - -# ============================================================================= -# Asset Statuses -# ============================================================================= - -@assets_bp.route('/statuses', methods=['GET']) -@jwt_required(optional=True) -def list_asset_statuses(): - """List all asset statuses.""" - page, per_page = get_pagination_params(request) - - query = AssetStatus.query - - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(AssetStatus.isactive == True) - - query = query.order_by(AssetStatus.status) - - items, total = paginate_query(query, page, per_page) - data = [s.to_dict() for s in items] - - return paginated_response(data, page, per_page, total) - - -@assets_bp.route('/statuses/', methods=['GET']) -@jwt_required(optional=True) -def get_asset_status(status_id: int): - """Get a single asset status.""" - s = AssetStatus.query.get(status_id) - - if not s: - return error_response( - ErrorCodes.NOT_FOUND, - f'Asset status with ID {status_id} not found', - http_code=404 - ) - - return success_response(s.to_dict()) - - -@assets_bp.route('/statuses', methods=['POST']) -@jwt_required() -def create_asset_status(): - """Create a new asset status.""" - data = request.get_json() - - if not data or not data.get('status'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required') - - if AssetStatus.query.filter_by(status=data['status']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Asset status '{data['status']}' already exists", - http_code=409 - ) - - s = AssetStatus( - status=data['status'], - description=data.get('description'), - color=data.get('color') - ) - - db.session.add(s) - db.session.commit() - - return success_response(s.to_dict(), message='Asset status created', http_code=201) - - -@assets_bp.route('/statuses/', methods=['PUT']) -@jwt_required() -def update_asset_status(status_id: int): - """Update an asset status.""" - s = AssetStatus.query.get(status_id) - if not s: - return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', - http_code=404) - - data = request.get_json() or {} - - # Conflict check on rename - if 'status' in data and data['status'] != s.status: - if AssetStatus.query.filter_by(status=data['status']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Asset status '{data['status']}' already exists", - http_code=409 - ) - - for key in ('status', 'description', 'color', 'isactive'): - if key in data: - setattr(s, key, data[key]) - - db.session.commit() - return success_response(s.to_dict(), message='Asset status updated') - - -@assets_bp.route('/statuses/', methods=['DELETE']) -@jwt_required() -def delete_asset_status(status_id: int): - """Delete an asset status. Refused if any asset still uses it.""" - s = AssetStatus.query.get(status_id) - if not s: - return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', - http_code=404) - - inuse = Asset.query.filter_by(statusid=status_id).count() - if inuse: - return error_response( - ErrorCodes.CONFLICT, - f"Cannot delete: {inuse} asset(s) still use this status", - http_code=409 - ) - - db.session.delete(s) - db.session.commit() - return success_response(message='Asset status deleted') - - -# ============================================================================= -# Relationship Types -# ============================================================================= - -@assets_bp.route('/relationshiptypes', methods=['GET']) -@jwt_required(optional=True) -def list_relationship_types(): - """List all asset relationship types.""" - types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all() - return success_response([{ - 'relationshiptypeid': t.relationshiptypeid, - 'relationshiptype': t.relationshiptype, - 'description': t.description - } for t in types]) - - -@assets_bp.route('/relationshiptypes', methods=['POST']) -@jwt_required() -def create_relationship_type(): - """Create a new asset relationship type.""" - data = request.get_json() - if not data or not data.get('relationshiptype'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required') - - if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Relationship type '{data['relationshiptype']}' already exists", - http_code=409 - ) - - rel_type = RelationshipType( - relationshiptype=data['relationshiptype'], - description=data.get('description') - ) - db.session.add(rel_type) - db.session.commit() - - return success_response({ - 'relationshiptypeid': rel_type.relationshiptypeid, - 'relationshiptype': rel_type.relationshiptype, - 'description': rel_type.description - }, message='Relationship type created', http_code=201) - - -# ============================================================================= -# Assets -# ============================================================================= - -@assets_bp.route('', methods=['GET']) -@jwt_required(optional=True) -def list_assets(): - """ - List all assets with filtering and pagination. - - Query parameters: - - page: Page number (default: 1) - - per_page: Items per page (default: 20, max: 100) - - active: Filter by active status (default: true) - - search: Search by assetnumber or name - - type: Filter by asset type name (e.g., 'equipment', 'computer') - - type_id: Filter by asset type ID - - status_id: Filter by status ID - - location_id: Filter by location ID - - businessunit_id: Filter by business unit ID - - include_type_data: Include category-specific extension data (default: false) - """ - page, per_page = get_pagination_params(request) - - query = Asset.query - - # Active filter - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(Asset.isactive == True) - - # Search filter - if search := request.args.get('search'): - query = query.filter( - db.or_( - Asset.assetnumber.ilike(f'%{search}%'), - Asset.name.ilike(f'%{search}%'), - Asset.serialnumber.ilike(f'%{search}%') - ) - ) - - # Type filter by name - if type_name := request.args.get('type'): - query = query.join(AssetType).filter(AssetType.assettype == type_name) - - # Type filter by ID - if type_id := request.args.get('type_id'): - query = query.filter(Asset.assettypeid == int(type_id)) - - # Status filter - if status_id := request.args.get('status_id'): - query = query.filter(Asset.statusid == int(status_id)) - - # Location filter - if location_id := request.args.get('location_id'): - query = query.filter(Asset.locationid == int(location_id)) - - # Business unit filter - if bu_id := request.args.get('businessunit_id'): - query = query.filter(Asset.businessunitid == int(bu_id)) - - # Sorting - sort_by = request.args.get('sort', 'assetnumber') - sort_dir = request.args.get('dir', 'asc') - - sort_columns = { - 'assetnumber': Asset.assetnumber, - 'name': Asset.name, - 'createddate': Asset.createddate, - 'modifieddate': Asset.modifieddate, - } - - if sort_by in sort_columns: - col = sort_columns[sort_by] - query = query.order_by(col.desc() if sort_dir == 'desc' else col) - else: - query = query.order_by(Asset.assetnumber) - - items, total = paginate_query(query, page, per_page) - - # Include type data if requested - include_type_data = request.args.get('include_type_data', 'false').lower() == 'true' - data = [a.to_dict(include_type_data=include_type_data) for a in items] - - return paginated_response(data, page, per_page, total) - - -@assets_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def get_asset(asset_id: int): - """ - Get a single asset with full details. - - Query parameters: - - include_type_data: Include category-specific extension data (default: true) - """ - asset = Asset.query.get(asset_id) - - if not asset: - return error_response( - ErrorCodes.NOT_FOUND, - f'Asset with ID {asset_id} not found', - http_code=404 - ) - - include_type_data = request.args.get('include_type_data', 'true').lower() != 'false' - return success_response(asset.to_dict(include_type_data=include_type_data)) - - -@assets_bp.route('', methods=['POST']) -@jwt_required() -def create_asset(): - """Create a new asset.""" - data = request.get_json() - - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # Validate required fields - if not data.get('assetnumber'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') - if not data.get('assettypeid'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid is required') - - # Check for duplicate assetnumber - if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Asset with number '{data['assetnumber']}' already exists", - http_code=409 - ) - - # Validate foreign keys exist - if not AssetType.query.get(data['assettypeid']): - return error_response( - ErrorCodes.VALIDATION_ERROR, - f"Asset type with ID {data['assettypeid']} not found" - ) - - asset = Asset( - assetnumber=data['assetnumber'], - name=data.get('name'), - serialnumber=data.get('serialnumber'), - assettypeid=data['assettypeid'], - statusid=data.get('statusid', 1), - locationid=data.get('locationid'), - businessunitid=data.get('businessunitid'), - mapx=data.get('mapx'), - mapy=data.get('mapy'), - notes=data.get('notes') - ) - - db.session.add(asset) - db.session.commit() - - return success_response(asset.to_dict(), message='Asset created', http_code=201) - - -@assets_bp.route('/', methods=['PUT']) -@jwt_required() -def update_asset(asset_id: int): - """Update an asset.""" - asset = Asset.query.get(asset_id) - - if not asset: - return error_response( - ErrorCodes.NOT_FOUND, - f'Asset with ID {asset_id} not found', - http_code=404 - ) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # Check for conflicting assetnumber - if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: - if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Asset with number '{data['assetnumber']}' already exists", - http_code=409 - ) - - # Update allowed fields - allowed_fields = [ - 'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid', - 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive' - ] - - for key in allowed_fields: - if key in data: - setattr(asset, key, data[key]) - - db.session.commit() - return success_response(asset.to_dict(), message='Asset updated') - - -@assets_bp.route('/', methods=['DELETE']) -@jwt_required() -def delete_asset(asset_id: int): - """Delete (soft delete) an asset.""" - asset = Asset.query.get(asset_id) - - if not asset: - return error_response( - ErrorCodes.NOT_FOUND, - f'Asset with ID {asset_id} not found', - http_code=404 - ) - - asset.isactive = False - db.session.commit() - - return success_response(message='Asset deleted') - - -@assets_bp.route('/lookup/', methods=['GET']) -@jwt_required(optional=True) -def lookup_asset_by_number(assetnumber: str): - """ - Look up an asset by its asset number. - - Useful for finding the asset ID when you only have the machine/asset number. - """ - asset = Asset.query.filter_by(assetnumber=assetnumber, isactive=True).first() - - if not asset: - return error_response( - ErrorCodes.NOT_FOUND, - f'Asset with number {assetnumber} not found', - http_code=404 - ) - - return success_response(asset.to_dict(include_type_data=True)) - - -# ============================================================================= -# Asset Relationships -# ============================================================================= - -@assets_bp.route('//relationships', methods=['GET']) -@jwt_required(optional=True) -def get_asset_relationships(asset_id: int): - """ - Get all relationships for an asset. - - Returns both outgoing (source) and incoming (target) relationships. - """ - asset = Asset.query.get(asset_id) - - if not asset: - return error_response( - ErrorCodes.NOT_FOUND, - f'Asset with ID {asset_id} not found', - http_code=404 - ) - - # Get outgoing relationships (this asset is source) - outgoing = AssetRelationship.query.filter_by( - sourceassetid=asset_id - ).filter(AssetRelationship.isactive == True).all() - - # Get incoming relationships (this asset is target) - incoming = AssetRelationship.query.filter_by( - targetassetid=asset_id - ).filter(AssetRelationship.isactive == True).all() - - outgoing_data = [] - for rel in outgoing: - r = rel.to_dict() - r['targetasset'] = rel.targetasset.to_dict() if rel.targetasset else None - r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None - outgoing_data.append(r) - - incoming_data = [] - for rel in incoming: - r = rel.to_dict() - r['sourceasset'] = rel.sourceasset.to_dict() if rel.sourceasset else None - r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None - incoming_data.append(r) - - return success_response({ - 'outgoing': outgoing_data, - 'incoming': incoming_data - }) - - -@assets_bp.route('/relationships', methods=['POST']) -@jwt_required() -def create_asset_relationship(): - """Create a relationship between two assets.""" - data = request.get_json() - - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # Validate required fields - required = ['sourceassetid', 'targetassetid', 'relationshiptypeid'] - for field in required: - if not data.get(field): - return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required') - - source_id = data['sourceassetid'] - target_id = data['targetassetid'] - type_id = data['relationshiptypeid'] - - # Validate assets exist - if not Asset.query.get(source_id): - return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404) - if not Asset.query.get(target_id): - return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404) - if not RelationshipType.query.get(type_id): - return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404) - - # Check for duplicate relationship - existing = AssetRelationship.query.filter_by( - sourceassetid=source_id, - targetassetid=target_id, - relationshiptypeid=type_id - ).first() - - if existing: - return error_response( - ErrorCodes.CONFLICT, - 'This relationship already exists', - http_code=409 - ) - - rel = AssetRelationship( - sourceassetid=source_id, - targetassetid=target_id, - relationshiptypeid=type_id, - notes=data.get('notes') - ) - - db.session.add(rel) - db.session.commit() - - return success_response(rel.to_dict(), message='Relationship created', http_code=201) - - -@assets_bp.route('/relationships/', methods=['DELETE']) -@jwt_required() -def delete_asset_relationship(rel_id: int): - """Delete an asset relationship.""" - rel = AssetRelationship.query.get(rel_id) - - if not rel: - return error_response( - ErrorCodes.NOT_FOUND, - f'Relationship with ID {rel_id} not found', - http_code=404 - ) - - rel.isactive = False - db.session.commit() - - return success_response(message='Relationship deleted') - - -# ============================================================================= -# Asset Communications -# ============================================================================= - -# ============================================================================= -# Unified Asset Map -# ============================================================================= - -@assets_bp.route('/map', methods=['GET']) -@jwt_required(optional=True) -def get_assets_map(): - """ - Get all assets with map positions for unified floor map display. - - Returns assets with mapx/mapy coordinates, joined with type-specific data. - - Query parameters: - - 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, Communication - - # Eager-load all relationships to avoid N+1 queries. - # Core relationships via joinedload, extension tables via subqueryload - # with their nested relationships (vendor, model, type) also eager-loaded. - eager_options = [ - joinedload(Asset.assettype), - joinedload(Asset.status), - joinedload(Asset.location), - joinedload(Asset.businessunit), - ] - - # Eager-load plugin extension tables AND their relationships - try: - from plugins.equipment.models import Equipment - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.equipmenttype) - ) - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.vendor) - ) - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.model) - ) - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.controllervendor) - ) - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.controllermodel) - ) - except (ImportError, AttributeError): - pass - try: - from plugins.computers.models import Computer - eager_options.append( - subqueryload(Asset.computer) - .joinedload(Computer.computertype) - ) - eager_options.append( - subqueryload(Asset.computer) - .joinedload(Computer.operatingsystem) - ) - except (ImportError, AttributeError): - pass - try: - from plugins.network.models import NetworkDevice - eager_options.append( - subqueryload(Asset.network_device) - .joinedload(NetworkDevice.networkdevicetype) - ) - eager_options.append( - subqueryload(Asset.network_device) - .joinedload(NetworkDevice.vendor) - ) - except (ImportError, AttributeError): - pass - try: - from plugins.printers.models import Printer - eager_options.append( - subqueryload(Asset.printer) - .joinedload(Printer.printertype) - ) - eager_options.append( - subqueryload(Asset.printer) - .joinedload(Printer.vendor) - ) - eager_options.append( - subqueryload(Asset.printer) - .joinedload(Printer.model) - ) - except (ImportError, AttributeError): - pass - - query = Asset.query.options(*eager_options).filter( - Asset.isactive == True, - Asset.mapx.isnot(None), - Asset.mapy.isnot(None) - ) - - selected_assettype = request.args.get('assettype') - - # Filter by asset type name - 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': - 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': - 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': - 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': - 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'): - query = query.filter(Asset.businessunitid == int(bu_id)) - - # Filter by status - if status_id := request.args.get('statusid'): - query = query.filter(Asset.statusid == int(status_id)) - - # Filter by location - if location_id := request.args.get('locationid'): - query = query.filter(Asset.locationid == int(location_id)) - - # Search filter - if search := request.args.get('search'): - query = query.filter( - db.or_( - Asset.assetnumber.ilike(f'%{search}%'), - Asset.name.ilike(f'%{search}%'), - Asset.serialnumber.ilike(f'%{search}%') - ) - ) - - assets = query.all() - - # Batch-load primary IPs in a single query instead of N+1 per asset. - # Prefer isprimary=True IP, fall back to any IP (comtypeid=1). - asset_ids = [a.assetid for a in assets] - primary_ip_map = {} - if asset_ids: - ip_rows = db.session.query( - Communication.assetid, - Communication.ipaddress, - Communication.isprimary - ).filter( - Communication.assetid.in_(asset_ids), - Communication.comtypeid == 1, - Communication.isactive == True - ).order_by( - Communication.isprimary.desc() - ).all() - - for row in ip_rows: - # First match wins (isprimary=True sorted first) - if row.assetid not in primary_ip_map: - primary_ip_map[row.assetid] = row.ipaddress - - # Build response - all relationship data is already loaded, no extra queries - data = [] - for asset in assets: - item = { - 'assetid': asset.assetid, - 'assetnumber': asset.assetnumber, - 'name': asset.name, - 'displayname': asset.display_name, - 'serialnumber': asset.serialnumber, - '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, - 'statusid': asset.statusid, - 'statuscolor': asset.status.color if asset.status else None, - 'location': asset.location.locationname if asset.location else None, - 'locationid': asset.locationid, - 'businessunit': asset.businessunit.businessunit if asset.businessunit else None, - 'businessunitid': asset.businessunitid, - 'primaryip': primary_ip_map.get(asset.assetid), - } - - # Extension data is already eager-loaded via lazy='joined' backrefs - type_data = asset._get_extension_data() - if type_data: - item['typedata'] = type_data - - data.append(item) - - # Get filter options - these are small reference tables, no N+1 concern - asset_types = AssetType.query.filter(AssetType.isactive == True).all() - types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon} for t in asset_types] - - statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all() - status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses] - - business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all() - bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units] - - locations = Location.query.filter(Location.isactive == True).all() - loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations] - - # Get subtypes for filter dropdowns - subtypes = {} - - 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'] = [] - - 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'] = [] - - 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'] = [] - - 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), - 'filters': { - 'assettypes': types_data, - 'statuses': status_data, - 'businessunits': bu_data, - 'locations': loc_data, - 'subtypes': subtypes - } - }) - - -@assets_bp.route('//communications', methods=['GET']) -@jwt_required(optional=True) -def get_asset_communications(asset_id: int): - """Get all communications for an asset.""" - from shopdb.core.models import Communication - - asset = Asset.query.get(asset_id) - - if not asset: - return error_response( - ErrorCodes.NOT_FOUND, - f'Asset with ID {asset_id} not found', - http_code=404 - ) - - comms = Communication.query.filter_by( - assetid=asset_id, - isactive=True - ).all() - - data = [] - for comm in comms: - c = comm.to_dict() - c['comtype_name'] = comm.comtype.comtype if comm.comtype else None - data.append(c) - - return success_response(data) +"""Assets API endpoints - unified asset queries.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required +from sqlalchemy.orm import joinedload, subqueryload + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +assets_bp = Blueprint('assets', __name__) + + +# ============================================================================= +# Asset Types +# ============================================================================= + +@assets_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_asset_types(): + """List all asset types.""" + page, per_page = get_pagination_params(request) + + query = AssetType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AssetType.isactive == True) + + query = query.order_by(AssetType.assettype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_asset_type(type_id: int): + """Get a single asset type.""" + t = AssetType.query.get(type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@assets_bp.route('/types', methods=['POST']) +@jwt_required() +def create_asset_type(): + """Create a new asset type.""" + data = request.get_json() + + if not data or not data.get('assettype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettype is required') + + if AssetType.query.filter_by(assettype=data['assettype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset type '{data['assettype']}' already exists", + http_code=409 + ) + + t = AssetType( + assettype=data['assettype'], + pluginname=data.get('pluginname'), + tablename=data.get('tablename'), + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Asset type created', http_code=201) + + +# ============================================================================= +# Asset Statuses +# ============================================================================= + +@assets_bp.route('/statuses', methods=['GET']) +@jwt_required(optional=True) +def list_asset_statuses(): + """List all asset statuses.""" + page, per_page = get_pagination_params(request) + + query = AssetStatus.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AssetStatus.isactive == True) + + query = query.order_by(AssetStatus.status) + + items, total = paginate_query(query, page, per_page) + data = [s.to_dict() for s in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/statuses/', methods=['GET']) +@jwt_required(optional=True) +def get_asset_status(status_id: int): + """Get a single asset status.""" + s = AssetStatus.query.get(status_id) + + if not s: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset status with ID {status_id} not found', + http_code=404 + ) + + return success_response(s.to_dict()) + + +@assets_bp.route('/statuses', methods=['POST']) +@jwt_required() +def create_asset_status(): + """Create a new asset status.""" + data = request.get_json() + + if not data or not data.get('status'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required') + + if AssetStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset status '{data['status']}' already exists", + http_code=409 + ) + + s = AssetStatus( + status=data['status'], + description=data.get('description'), + color=data.get('color') + ) + + db.session.add(s) + db.session.commit() + + return success_response(s.to_dict(), message='Asset status created', http_code=201) + + +@assets_bp.route('/statuses/', methods=['PUT']) +@jwt_required() +def update_asset_status(status_id: int): + """Update an asset status.""" + s = AssetStatus.query.get(status_id) + if not s: + return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', + http_code=404) + + data = request.get_json() or {} + + # Conflict check on rename + if 'status' in data and data['status'] != s.status: + if AssetStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset status '{data['status']}' already exists", + http_code=409 + ) + + for key in ('status', 'description', 'color', 'isactive'): + if key in data: + setattr(s, key, data[key]) + + db.session.commit() + return success_response(s.to_dict(), message='Asset status updated') + + +@assets_bp.route('/statuses/', methods=['DELETE']) +@jwt_required() +def delete_asset_status(status_id: int): + """Delete an asset status. Refused if any asset still uses it.""" + s = AssetStatus.query.get(status_id) + if not s: + return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', + http_code=404) + + inuse = Asset.query.filter_by(statusid=status_id).count() + if inuse: + return error_response( + ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} asset(s) still use this status", + http_code=409 + ) + + db.session.delete(s) + db.session.commit() + return success_response(message='Asset status deleted') + + +# ============================================================================= +# Relationship Types +# ============================================================================= + +@assets_bp.route('/relationshiptypes', methods=['GET']) +@jwt_required(optional=True) +def list_relationship_types(): + """List all asset relationship types.""" + types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all() + return success_response([{ + 'relationshiptypeid': t.relationshiptypeid, + 'relationshiptype': t.relationshiptype, + 'description': t.description + } for t in types]) + + +@assets_bp.route('/relationshiptypes', methods=['POST']) +@jwt_required() +def create_relationship_type(): + """Create a new asset relationship type.""" + data = request.get_json() + if not data or not data.get('relationshiptype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required') + + if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Relationship type '{data['relationshiptype']}' already exists", + http_code=409 + ) + + rel_type = RelationshipType( + relationshiptype=data['relationshiptype'], + description=data.get('description') + ) + db.session.add(rel_type) + db.session.commit() + + return success_response({ + 'relationshiptypeid': rel_type.relationshiptypeid, + 'relationshiptype': rel_type.relationshiptype, + 'description': rel_type.description + }, message='Relationship type created', http_code=201) + + +# ============================================================================= +# Assets +# ============================================================================= + +@assets_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_assets(): + """ + List all assets with filtering and pagination. + + Query parameters: + - page: Page number (default: 1) + - per_page: Items per page (default: 20, max: 100) + - active: Filter by active status (default: true) + - search: Search by assetnumber or name + - type: Filter by asset type name (e.g., 'equipment', 'computer') + - type_id: Filter by asset type ID + - status_id: Filter by status ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - include_type_data: Include category-specific extension data (default: false) + """ + page, per_page = get_pagination_params(request) + + query = Asset.query + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + # Type filter by name + if type_name := request.args.get('type'): + query = query.join(AssetType).filter(AssetType.assettype == type_name) + + # Type filter by ID + if type_id := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Asset.assettypeid == int(type_id)) + + # Status filter + if status_id := request.args.get('statusid', request.args.get('status_id')): + query = query.filter(Asset.statusid == int(status_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'assetnumber') + sort_dir = request.args.get('dir', 'asc') + + sort_columns = { + 'assetnumber': Asset.assetnumber, + 'name': Asset.name, + 'createddate': Asset.createddate, + 'modifieddate': Asset.modifieddate, + } + + if sort_by in sort_columns: + col = sort_columns[sort_by] + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + else: + query = query.order_by(Asset.assetnumber) + + items, total = paginate_query(query, page, per_page) + + # Include type data if requested + include_type_data = request.args.get('include_type_data', 'false').lower() == 'true' + data = [a.to_dict(include_type_data=include_type_data) for a in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_asset(asset_id: int): + """ + Get a single asset with full details. + + Query parameters: + - include_type_data: Include category-specific extension data (default: true) + """ + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + include_type_data = request.args.get('include_type_data', 'true').lower() != 'false' + return success_response(asset.to_dict(include_type_data=include_type_data)) + + +@assets_bp.route('', methods=['POST']) +@jwt_required() +def create_asset(): + """Create a new asset.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + if not data.get('assettypeid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Validate foreign keys exist + if not AssetType.query.get(data['assettypeid']): + return error_response( + ErrorCodes.VALIDATION_ERROR, + f"Asset type with ID {data['assettypeid']} not found" + ) + + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + assettypeid=data['assettypeid'], + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.commit() + + return success_response(asset.to_dict(), message='Asset created', http_code=201) + + +@assets_bp.route('/', methods=['PUT']) +@jwt_required() +def update_asset(asset_id: int): + """Update an asset.""" + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Update allowed fields + allowed_fields = [ + 'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive' + ] + + for key in allowed_fields: + if key in data: + setattr(asset, key, data[key]) + + db.session.commit() + return success_response(asset.to_dict(), message='Asset updated') + + +@assets_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_asset(asset_id: int): + """Delete (soft delete) an asset.""" + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + asset.isactive = False + db.session.commit() + + return success_response(message='Asset deleted') + + +@assets_bp.route('/lookup/', methods=['GET']) +@jwt_required(optional=True) +def lookup_asset_by_number(assetnumber: str): + """ + Look up an asset by its asset number. + + Useful for finding the asset ID when you only have the machine/asset number. + """ + asset = Asset.query.filter_by(assetnumber=assetnumber, isactive=True).first() + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with number {assetnumber} not found', + http_code=404 + ) + + return success_response(asset.to_dict(include_type_data=True)) + + +# ============================================================================= +# Asset Relationships +# ============================================================================= + +@assets_bp.route('//relationships', methods=['GET']) +@jwt_required(optional=True) +def get_asset_relationships(asset_id: int): + """ + Get all relationships for an asset. + + Returns both outgoing (source) and incoming (target) relationships. + """ + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + # Get outgoing relationships (this asset is source) + outgoing = AssetRelationship.query.filter_by( + sourceassetid=asset_id + ).filter(AssetRelationship.isactive == True).all() + + # Get incoming relationships (this asset is target) + incoming = AssetRelationship.query.filter_by( + targetassetid=asset_id + ).filter(AssetRelationship.isactive == True).all() + + outgoing_data = [] + for rel in outgoing: + r = rel.to_dict() + r['targetasset'] = rel.targetasset.to_dict() if rel.targetasset else None + r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None + outgoing_data.append(r) + + incoming_data = [] + for rel in incoming: + r = rel.to_dict() + r['sourceasset'] = rel.sourceasset.to_dict() if rel.sourceasset else None + r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None + incoming_data.append(r) + + return success_response({ + 'outgoing': outgoing_data, + 'incoming': incoming_data + }) + + +@assets_bp.route('/relationships', methods=['POST']) +@jwt_required() +def create_asset_relationship(): + """Create a relationship between two assets.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + required = ['sourceassetid', 'targetassetid', 'relationshiptypeid'] + for field in required: + if not data.get(field): + return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required') + + source_id = data['sourceassetid'] + target_id = data['targetassetid'] + type_id = data['relationshiptypeid'] + + # Validate assets exist + if not Asset.query.get(source_id): + return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404) + if not Asset.query.get(target_id): + return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404) + if not RelationshipType.query.get(type_id): + return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404) + + # Check for duplicate relationship + existing = AssetRelationship.query.filter_by( + sourceassetid=source_id, + targetassetid=target_id, + relationshiptypeid=type_id + ).first() + + if existing: + return error_response( + ErrorCodes.CONFLICT, + 'This relationship already exists', + http_code=409 + ) + + rel = AssetRelationship( + sourceassetid=source_id, + targetassetid=target_id, + relationshiptypeid=type_id, + notes=data.get('notes') + ) + + db.session.add(rel) + db.session.commit() + + return success_response(rel.to_dict(), message='Relationship created', http_code=201) + + +@assets_bp.route('/relationships/', methods=['DELETE']) +@jwt_required() +def delete_asset_relationship(rel_id: int): + """Delete an asset relationship.""" + rel = AssetRelationship.query.get(rel_id) + + if not rel: + return error_response( + ErrorCodes.NOT_FOUND, + f'Relationship with ID {rel_id} not found', + http_code=404 + ) + + rel.isactive = False + db.session.commit() + + return success_response(message='Relationship deleted') + + +# ============================================================================= +# Asset Communications +# ============================================================================= + +# ============================================================================= +# Unified Asset Map +# ============================================================================= + +@assets_bp.route('/map', methods=['GET']) +@jwt_required(optional=True) +def get_assets_map(): + """ + Get all assets with map positions for unified floor map display. + + Returns assets with mapx/mapy coordinates, joined with type-specific data. + + Query parameters: + - 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, Communication + + # Eager-load all relationships to avoid N+1 queries. + # Core relationships via joinedload, extension tables via subqueryload + # with their nested relationships (vendor, model, type) also eager-loaded. + eager_options = [ + joinedload(Asset.assettype), + joinedload(Asset.status), + joinedload(Asset.location), + joinedload(Asset.businessunit), + ] + + # Eager-load plugin extension tables AND their relationships + try: + from plugins.equipment.models import Equipment + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.equipmenttype) + ) + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.vendor) + ) + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.model) + ) + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.controllervendor) + ) + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.controllermodel) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.computers.models import Computer + eager_options.append( + subqueryload(Asset.computer) + .joinedload(Computer.computertype) + ) + eager_options.append( + subqueryload(Asset.computer) + .joinedload(Computer.operatingsystem) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.network.models import NetworkDevice + eager_options.append( + subqueryload(Asset.network_device) + .joinedload(NetworkDevice.networkdevicetype) + ) + eager_options.append( + subqueryload(Asset.network_device) + .joinedload(NetworkDevice.vendor) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.printers.models import Printer + eager_options.append( + subqueryload(Asset.printer) + .joinedload(Printer.printertype) + ) + eager_options.append( + subqueryload(Asset.printer) + .joinedload(Printer.vendor) + ) + eager_options.append( + subqueryload(Asset.printer) + .joinedload(Printer.model) + ) + except (ImportError, AttributeError): + pass + + query = Asset.query.options(*eager_options).filter( + Asset.isactive == True, + Asset.mapx.isnot(None), + Asset.mapy.isnot(None) + ) + + selected_assettype = request.args.get('assettype') + + # Filter by asset type name + 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': + 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': + 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': + 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': + 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'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Filter by status + if status_id := request.args.get('statusid'): + query = query.filter(Asset.statusid == int(status_id)) + + # Filter by location + if location_id := request.args.get('locationid'): + query = query.filter(Asset.locationid == int(location_id)) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + assets = query.all() + + # Batch-load primary IPs in a single query instead of N+1 per asset. + # Prefer isprimary=True IP, fall back to any IP (comtypeid=1). + asset_ids = [a.assetid for a in assets] + primary_ip_map = {} + if asset_ids: + ip_rows = db.session.query( + Communication.assetid, + Communication.ipaddress, + Communication.isprimary + ).filter( + Communication.assetid.in_(asset_ids), + Communication.comtypeid == 1, + Communication.isactive == True + ).order_by( + Communication.isprimary.desc() + ).all() + + for row in ip_rows: + # First match wins (isprimary=True sorted first) + if row.assetid not in primary_ip_map: + primary_ip_map[row.assetid] = row.ipaddress + + # Build response - all relationship data is already loaded, no extra queries + data = [] + for asset in assets: + item = { + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'name': asset.name, + 'displayname': asset.display_name, + 'serialnumber': asset.serialnumber, + '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, + 'statusid': asset.statusid, + 'statuscolor': asset.status.color if asset.status else None, + 'location': asset.location.locationname if asset.location else None, + 'locationid': asset.locationid, + 'businessunit': asset.businessunit.businessunit if asset.businessunit else None, + 'businessunitid': asset.businessunitid, + 'primaryip': primary_ip_map.get(asset.assetid), + } + + # Extension data is already eager-loaded via lazy='joined' backrefs + type_data = asset._get_extension_data() + if type_data: + item['typedata'] = type_data + + data.append(item) + + # Get filter options - these are small reference tables, no N+1 concern + asset_types = AssetType.query.filter(AssetType.isactive == True).all() + types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon} for t in asset_types] + + statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all() + status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses] + + business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all() + bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units] + + locations = Location.query.filter(Location.isactive == True).all() + loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations] + + # Get subtypes for filter dropdowns + subtypes = {} + + 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'] = [] + + 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'] = [] + + 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'] = [] + + 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), + 'filters': { + 'assettypes': types_data, + 'statuses': status_data, + 'businessunits': bu_data, + 'locations': loc_data, + 'subtypes': subtypes + } + }) + + +@assets_bp.route('//communications', methods=['GET']) +@jwt_required(optional=True) +def get_asset_communications(asset_id: int): + """Get all communications for an asset.""" + from shopdb.core.models import Communication + + asset = Asset.query.get(asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + comms = Communication.query.filter_by( + assetid=asset_id, + isactive=True + ).all() + + data = [] + for comm in comms: + c = comm.to_dict() + c['comtype_name'] = comm.comtype.comtype if comm.comtype else None + data.append(c) + + return success_response(data)