"""Printers API routes - new Asset-based architecture.""" import logging from flask import Blueprint, request from flask_jwt_extended import jwt_required from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Printer, PrinterType, ModelSupply, PrinterDriver from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS from ..services import ( ZabbixService, classifysupply, derivesupplytype, derivecolor, lookupsupplies, ) logger = logging.getLogger(__name__) from shopdb.api import require_permission, require_role, apply_import_timestamps 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 = db.session.get(PrinterType, 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() @require_permission('printers.create') 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') existing = PrinterType.query.filter_by(printertype=data['printertype']).first() if existing: if not existing.isactive: existing.isactive = True for key in ('description', 'icon', 'color'): if data.get(key) is not None: setattr(existing, key, data[key]) db.session.commit() return success_response(existing.to_dict(), message='Reactivated existing type') 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'), color=data.get('color') ) db.session.add(t) db.session.commit() return success_response(t.to_dict(), message='Printer type created', http_code=201) @printers_asset_bp.route('/types/', methods=['PUT']) @jwt_required() @require_permission('printers.edit') def update_printer_type(type_id: int): """Update a printer type.""" t = db.session.get(PrinterType, type_id) if not t: return error_response(ErrorCodes.NOT_FOUND, f'Printer type with ID {type_id} not found', http_code=404) data = request.get_json() or {} if 'printertype' in data and data['printertype'] != t.printertype: if PrinterType.query.filter_by(printertype=data['printertype']).first(): return error_response(ErrorCodes.CONFLICT, f"Printer type '{data['printertype']}' already exists", http_code=409) for key in ['printertype', 'description', 'icon', 'color', 'isactive']: if key in data: setattr(t, key, data[key]) db.session.commit() return success_response(t.to_dict(), message='Printer type updated') @printers_asset_bp.route('/types/', methods=['DELETE']) @jwt_required() @require_permission('printers.delete') def delete_printer_type(type_id: int): """Delete a printer type. Refused if any printer still uses it.""" t = db.session.get(PrinterType, type_id) if not t: return error_response(ErrorCodes.NOT_FOUND, 'Printer type not found', http_code=404) inuse = Printer.query.filter_by(printertypeid=type_id).count() if inuse: return error_response(ErrorCodes.CONFLICT, f"Cannot delete: {inuse} printer(s) still use this type", http_code=409) db.session.delete(t) db.session.commit() return success_response(message='Printer type deleted') # ============================================================================= # Printer Drivers (named SMB / HTTP links to driver packages) # ============================================================================= @printers_asset_bp.route('/drivers', methods=['GET']) @jwt_required(optional=True) def list_drivers(): """List printer drivers. ?active=false includes inactive ones.""" query = PrinterDriver.query if request.args.get('active', 'true').lower() != 'false': query = query.filter_by(isactive=True) drivers = query.order_by(PrinterDriver.name).all() return success_response([d.to_dict() for d in drivers]) @printers_asset_bp.route('/drivers', methods=['POST']) @jwt_required() @require_permission('printers.create') def create_driver(): data = request.get_json() or {} if not (data.get('name') and data.get('location')): return error_response(ErrorCodes.VALIDATION_ERROR, 'name and location are required') d = PrinterDriver( name=data['name'], location=data['location'], description=data.get('description'), modelnumberid=data.get('modelnumberid') or None, isactive=data.get('isactive', True), ) db.session.add(d) db.session.commit() return success_response(d.to_dict(), message='Driver created', http_code=201) @printers_asset_bp.route('/drivers/', methods=['PUT']) @jwt_required() @require_permission('printers.edit') def update_driver(driver_id): d = db.session.get(PrinterDriver, driver_id) if not d: return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404) data = request.get_json() or {} for key in ('name', 'location', 'description', 'isactive'): if key in data: setattr(d, key, data[key]) if 'modelnumberid' in data: d.modelnumberid = data['modelnumberid'] or None db.session.commit() return success_response(d.to_dict(), message='Driver updated') @printers_asset_bp.route('/drivers/', methods=['DELETE']) @jwt_required() @require_permission('printers.delete') def delete_driver(driver_id): d = db.session.get(PrinterDriver, driver_id) if not d: return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404) db.session.delete(d) db.session.commit() return success_response(message='Driver deleted') # ============================================================================= # 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) # Exact-match natural-key lookup for idempotent import (asset number). if exactassetnumber := request.args.get('assetnumber'): query = query.filter(Asset.assetnumber == exactassetnumber) # 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('/install-list', methods=['GET']) @jwt_required(optional=True) def printer_install_list(): """Flat, unpaginated list of network printers for the printer installer. Shopfloor 2.0 PCs cannot run unsigned .bat maps, so the signed installer EXE pulls printer data + floor-map positions from here and renders the picker. Replaces the classic apiprinters.asp contract, adding mapx/mapy. Network printers only (USB-only printers are excluded). """ rows = [] query = db.session.query(Printer).join(Asset).filter(Asset.isactive == True) for printer in query.all(): asset = printer.asset if not asset: continue primary = Communication.query.filter_by( assetid=asset.assetid, isprimary=True).first() \ or Communication.query.filter_by(assetid=asset.assetid).first() ipaddress = primary.ipaddress if primary else None # Network printers only: must have a hostname or a non-USB IP. is_network = bool(printer.hostname) or (ipaddress and ipaddress != 'USB') if not is_network: continue data = printer.to_dict() rows.append({ 'printerid': printer.printerid, 'name': asset.name or asset.assetnumber, 'machinenumber': asset.assetnumber, 'windowsname': printer.windowsname, 'sharename': printer.sharename, 'hostname': printer.hostname, 'ipaddress': ipaddress, 'vendorname': data.get('vendorname'), 'modelnumber': data.get('modelname'), 'installpath': printer.installpath, 'iscsf': printer.iscsf, 'locationname': asset.location.locationname if asset.location else None, 'mapx': asset.mapx, 'mapy': asset.mapy, }) return success_response(rows) @printers_asset_bp.route('/pc-default', methods=['GET']) @jwt_required(optional=True) def pc_default_printer(): """Default printer for a PC, by machine (asset) number. Parity with classic apipcdefaultprinter.asp: the signed installer EXE preselects a PC's default-printer hotspot on the site-map wizard using the machine number persisted at PXE enrollment. The link is a `defaultprinter` asset relationship (PC asset -> printer asset), so this stays inside the contract surface (no cross-plugin model import). Returns {printerid, windowsname}, or {} when the machine is unknown or has no active default printer set. """ machine = (request.args.get('machine') or '').strip() if not machine: return success_response({}) pc = Asset.query.filter_by(assetnumber=machine, isactive=True).first() dp_type = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first() if not pc or not dp_type: return success_response({}) rel = AssetRelationship.query.filter_by( sourceassetid=pc.assetid, relationshiptypeid=dp_type.relationshiptypeid, isactive=True, ).first() if not rel: return success_response({}) printer = db.session.query(Printer).join(Asset).filter( Printer.assetid == rel.targetassetid, Asset.isactive == True, ).first() if not printer: return success_response({}) return success_response({ 'printerid': printer.printerid, 'windowsname': printer.windowsname, }) @printers_asset_bp.route('/', methods=['GET']) @jwt_required(optional=True) def get_printer(printer_id: int): """Get a single printer with full details.""" printer = db.session.get(Printer, 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] # Attach active drivers that match this printer's model if printer.modelnumberid: drivers = PrinterDriver.query.filter_by( modelnumberid=printer.modelnumberid, isactive=True ).order_by(PrinterDriver.name).all() result['drivers'] = [d.to_dict() for d in drivers] else: result['drivers'] = [] 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() @require_permission('printers.create') 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'), gaugelabreference=data.get('gaugelabreference'), maintenancereference=data.get('maintenancereference'), 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) # Preserve legacy timestamps in import mode (no-op otherwise) apply_import_timestamps(asset, data) 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() @require_permission('printers.edit') def update_printer(printer_id: int): """Update printer (both Asset and Printer records).""" printer = db.session.get(Printer, 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 (optional identifiers gated per-type in Settings) asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', 'maintenancereference', '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 apply_import_timestamps(asset, data) 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() @require_permission('printers.delete') def delete_printer(printer_id: int): """Delete (soft delete) printer.""" printer = db.session.get(Printer, 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 = db.session.get(Printer, 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.api import Location loc = db.session.get(Location, 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() @require_permission('printers.create') 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 = db.session.get(Model, 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() @require_permission('printers.create') def create_model_supply(modelnumberid: int): """Add a supply to a model.""" model = db.session.get(Model, 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() @require_permission('printers.edit') def update_model_supply(modelsupplyid: int): """Update a model supply.""" supply = db.session.get(ModelSupply, 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() @require_permission('printers.delete') def delete_model_supply(modelsupplyid: int): """Delete a model supply.""" supply = db.session.get(ModelSupply, 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')