"""Machines plugin API endpoints.""" from flask import Blueprint, request from flask_jwt_extended import jwt_required from shopdb.api import db, Asset, AssetType, AuditLog, Vendor, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled from ..models import Machine, MachineType from shopdb.api import require_permission, apply_import_timestamps machines_bp = Blueprint('machines', __name__) # ============================================================================= # Machine Types # ============================================================================= @machines_bp.route('/types', methods=['GET']) @jwt_required(optional=True) def list_machine_types(): """List all machine types.""" page, per_page = get_pagination_params(request) query = MachineType.query if request.args.get('active', 'true').lower() != 'false': query = query.filter(MachineType.isactive == True) if search := request.args.get('search'): query = query.filter(MachineType.machinetype.ilike(f'%{search}%')) query = query.order_by(MachineType.machinetype) items, total = paginate_query(query, page, per_page) data = [t.to_dict() for t in items] return paginated_response(data, page, per_page, total) @machines_bp.route('/types/', methods=['GET']) @jwt_required(optional=True) def get_machine_type(type_id: int): """Get a single machine type.""" t = db.session.get(MachineType, type_id) if not t: return error_response( ErrorCodes.NOT_FOUND, f'Machine type with ID {type_id} not found', http_code=404 ) return success_response(t.to_dict()) @machines_bp.route('/types', methods=['POST']) @jwt_required() @require_permission('machines.create') def create_machine_type(): """Create a new machine type.""" data = request.get_json() if not data or not data.get('machinetype'): return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required') existing = MachineType.query.filter_by(machinetype=data['machinetype']).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"Machine type '{data['machinetype']}' already exists", http_code=409 ) t = MachineType( machinetype=data['machinetype'], 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='Machine type created', http_code=201) @machines_bp.route('/types/', methods=['PUT']) @jwt_required() @require_permission('machines.edit') def update_machine_type(type_id: int): """Update a machine type.""" t = db.session.get(MachineType, type_id) if not t: return error_response( ErrorCodes.NOT_FOUND, f'Machine type with ID {type_id} not found', http_code=404 ) data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') if 'machinetype' in data and data['machinetype'] != t.machinetype: if MachineType.query.filter_by(machinetype=data['machinetype']).first(): return error_response( ErrorCodes.CONFLICT, f"Machine type '{data['machinetype']}' already exists", http_code=409 ) for key in ['machinetype', 'description', 'icon', 'color', 'isactive']: if key in data: setattr(t, key, data[key]) db.session.commit() return success_response(t.to_dict(), message='Machine type updated') @machines_bp.route('/types/', methods=['DELETE']) @jwt_required() @require_permission('machines.delete') def delete_machine_type(type_id: int): """Delete a machine type. Refused if any asset still uses it.""" t = db.session.get(MachineType, type_id) if not t: return error_response(ErrorCodes.NOT_FOUND, 'Machine type not found', http_code=404) inuse = Machine.query.filter_by(machinetypeid=type_id).count() if inuse: return error_response(ErrorCodes.CONFLICT, f"Cannot delete: {inuse} asset(s) still use this type", http_code=409) db.session.delete(t) db.session.commit() return success_response(message='Machine type deleted') # ============================================================================= # Machine CRUD # ============================================================================= @machines_bp.route('', methods=['GET']) @jwt_required(optional=True) def list_machines(): """ List all machines with filtering and pagination. Query parameters: - page, per_page: Pagination - active: Filter by active status - search: Search by asset number or name - type_id: Filter by machine 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 Machine with Asset query = db.session.query(Machine).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. Covers what the list actually SHOWS - the asset fields plus # the type and vendor names, which are columns in the table. Searching a type # like 'Part Washer' used to return nothing. Outer joins so a machine with no # type or vendor still matches on its own fields. if search := request.args.get('search'): pattern = f'%{search}%' query = query.outerjoin( MachineType, Machine.machinetypeid == MachineType.machinetypeid ).outerjoin( Vendor, Machine.vendorid == Vendor.vendorid ).filter( db.or_( Asset.assetnumber.ilike(pattern), Asset.name.ilike(pattern), Asset.serialnumber.ilike(pattern), MachineType.machinetype.ilike(pattern), Vendor.vendor.ilike(pattern) ) ) # Machine type filter if type_id := request.args.get('typeid', request.args.get('type_id')): query = query.filter(Machine.machinetypeid == int(type_id)) # Vendor filter if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): query = query.filter(Machine.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', 'assetnumber') sort_dir = request.args.get('dir', 'asc') if sort_by == 'assetnumber': col = Asset.assetnumber elif sort_by == 'name': col = Asset.name else: col = Asset.assetnumber query = query.order_by(col.desc() if sort_dir == 'desc' else col) # Dualpath single-machine collapse: hide the secondary bay so a dual-bay # pair lists (and paginates) as one machine. Excluding before pagination # keeps totals honest. Gated on the site setting (default on). collapse = None if dualpath_single_machine_enabled(): collapse = resolve_dualpath_pairs() if collapse.secondaryassetids: query = query.filter(Asset.assetid.notin_(collapse.secondaryassetids)) items, total = paginate_query(query, page, per_page) # Resolve partner machineids for this page's primaries in one query. machineidbyasset = {} if collapse: partnerassetids = [ collapse.partnerbyasset[m.assetid]['assetid'] for m in items if m.assetid in collapse.partnerbyasset ] if partnerassetids: for partner in Machine.query.filter( Machine.assetid.in_(partnerassetids)).all(): machineidbyasset[partner.assetid] = partner.machineid # Build response with both asset and machine data data = [] for mach in items: item = mach.asset.to_dict() if mach.asset else {} item['machine'] = mach.to_dict() # annotate the visible bay with its hidden partner (for '2007 / 2008') partner = collapse.partnerbyasset.get(mach.assetid) if collapse else None item['dualpathpartner'] = { 'assetid': partner['assetid'], 'machineid': machineidbyasset.get(partner['assetid']), 'assetnumber': partner['assetnumber'], } if partner else None data.append(item) return paginated_response(data, page, per_page, total) def _dualpath_partner_for(assetid): """Resolve this machine's Dualpath sibling, or None. Always evaluated (independent of the collapse toggle) so the detail-page banner shows even when a site lists both bays. Returns {assetid, machineid, assetnumber}.""" partner = resolve_dualpath_pairs().partnerbyasset.get(assetid) if not partner: return None partnermach = Machine.query.filter_by(assetid=partner['assetid']).first() return { 'assetid': partner['assetid'], 'machineid': partnermach.machineid if partnermach else None, 'assetnumber': partner['assetnumber'], } @machines_bp.route('/', methods=['GET']) @jwt_required(optional=True) def get_machine(machine_id: int): """Get a single machine item with full details.""" mach = db.session.get(Machine, machine_id) if not mach: return error_response( ErrorCodes.NOT_FOUND, f'Machine with ID {machine_id} not found', http_code=404 ) result = mach.asset.to_dict() if mach.asset else {} result['machine'] = mach.to_dict() result['dualpathpartner'] = _dualpath_partner_for(mach.assetid) return success_response(result) @machines_bp.route('/by-asset/', methods=['GET']) @jwt_required(optional=True) def get_machine_by_asset(asset_id: int): """Get machine data by asset ID.""" mach = Machine.query.filter_by(assetid=asset_id).first() if not mach: return error_response( ErrorCodes.NOT_FOUND, f'Machine for asset {asset_id} not found', http_code=404 ) result = mach.asset.to_dict() if mach.asset else {} result['machine'] = mach.to_dict() result['dualpathpartner'] = _dualpath_partner_for(mach.assetid) return success_response(result) @machines_bp.route('', methods=['POST']) @jwt_required() @require_permission('machines.create') def create_machine(): """ Create new machine (creates both Asset and Machine records). Required fields: - assetnumber: Business identifier Optional fields: - name, serialnumber, statusid, locationid, businessunitid - machinetypeid, vendorid, modelnumberid - requiresmanualconfig, islocationonly - 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 machine asset type machine_type = AssetType.query.filter_by(assettype='machine').first() if not machine_type: return error_response( ErrorCodes.INTERNAL_ERROR, 'Machine 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'), gaugelabreference=data.get('gaugelabreference'), maintenancereference=data.get('maintenancereference'), serialnumber=data.get('serialnumber'), assettypeid=machine_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 machine extension mach = Machine( assetid=asset.assetid, machinetypeid=data.get('machinetypeid'), vendorid=data.get('vendorid'), modelnumberid=data.get('modelnumberid'), requiresmanualconfig=data.get('requiresmanualconfig', False), islocationonly=data.get('islocationonly', False), lastmaintenancedate=data.get('lastmaintenancedate'), nextmaintenancedate=data.get('nextmaintenancedate'), maintenanceintervaldays=data.get('maintenanceintervaldays'), controllervendorid=data.get('controllervendorid'), controllermodelid=data.get('controllermodelid') ) db.session.add(mach) db.session.flush() # Preserve legacy timestamps in import mode (no-op otherwise) apply_import_timestamps(asset, data) # Audit log AuditLog.log('created', 'Machine', entityid=mach.machineid, entityname=data['assetnumber']) db.session.commit() result = asset.to_dict() result['machine'] = mach.to_dict() return success_response(result, message='Machine created', http_code=201) @machines_bp.route('/', methods=['PUT']) @jwt_required() @require_permission('machines.edit') def update_machine(machine_id: int): """Update machine (both Asset and Machine records).""" mach = db.session.get(Machine, machine_id) if not mach: return error_response( ErrorCodes.NOT_FOUND, f'Machine with ID {machine_id} not found', http_code=404 ) data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') asset = mach.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 ) # Track changes for audit log changes = {} # Update asset fields asset_fields = ['assetnumber', 'name', 'gaugelabreference', 'maintenancereference', 'serialnumber', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] for key in asset_fields: if key in data: old_val = getattr(asset, key) new_val = data[key] if old_val != new_val: changes[key] = {'old': old_val, 'new': new_val} setattr(asset, key, data[key]) # Update machine fields machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid', 'requiresmanualconfig', 'islocationonly', 'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays', 'controllervendorid', 'controllermodelid'] for key in machine_fields: if key in data: old_val = getattr(mach, key) new_val = data[key] if old_val != new_val: changes[key] = {'old': old_val, 'new': new_val} setattr(mach, key, data[key]) # Audit log if there were changes if changes: AuditLog.log('updated', 'Machine', entityid=mach.machineid, entityname=asset.assetnumber, changes=changes) apply_import_timestamps(asset, data) db.session.commit() result = asset.to_dict() result['machine'] = mach.to_dict() return success_response(result, message='Machine updated') @machines_bp.route('/', methods=['DELETE']) @jwt_required() @require_permission('machines.delete') def delete_machine(machine_id: int): """Delete (soft delete) machine.""" mach = db.session.get(Machine, machine_id) if not mach: return error_response( ErrorCodes.NOT_FOUND, f'Machine with ID {machine_id} not found', http_code=404 ) # Soft delete the asset (machine extension will stay linked) mach.asset.isactive = False # Audit log AuditLog.log('deleted', 'Machine', entityid=mach.machineid, entityname=mach.asset.assetnumber) db.session.commit() return success_response(message='Machine deleted') # ============================================================================= # Dashboard # ============================================================================= @machines_bp.route('/dashboard/summary', methods=['GET']) @jwt_required(optional=True) def dashboard_summary(): """Get machine dashboard summary data.""" # Dualpath single-machine collapse: hide the secondary bay from counts so a # dual-bay pair counts once. Gated on the site setting (default on). secondaryassetids = set() if dualpath_single_machine_enabled(): secondaryassetids = resolve_dualpath_pairs().secondaryassetids # Total active machine count total_query = db.session.query(Machine).join(Asset).filter( Asset.isactive == True ) if secondaryassetids: total_query = total_query.filter(Asset.assetid.notin_(secondaryassetids)) total = total_query.count() # Count by machine type by_type_query = db.session.query( MachineType.machinetype, db.func.count(Machine.machineid) ).join(Machine, Machine.machinetypeid == MachineType.machinetypeid ).join(Asset, Asset.assetid == Machine.assetid ).filter(Asset.isactive == True) if secondaryassetids: by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids)) by_type = by_type_query.group_by(MachineType.machinetype).all() # Count by status from shopdb.api import AssetStatus by_status = db.session.query( AssetStatus.status, db.func.count(Machine.machineid) ).join(Asset, Asset.assetid == Machine.assetid ).join(AssetStatus, AssetStatus.statusid == Asset.statusid ).filter(Asset.isactive == True ).group_by(AssetStatus.status ).all() return success_response({ 'total': total, 'bytype': [{'type': t, 'count': c} for t, c in by_type], 'bystatus': [{'status': s, 'count': c} for s, c in by_status] })