""" Machines API endpoints. DEPRECATED: This API is deprecated and will be removed in a future version. Please migrate to the new asset-based APIs: - /api/assets - Unified asset queries - /api/equipment - Equipment CRUD - /api/computers - Computers CRUD - /api/network - Network devices CRUD - /api/printers - Printers CRUD """ import logging from functools import wraps from flask import Blueprint, request, g from flask_jwt_extended import jwt_required, current_user from shopdb.extensions import db from shopdb.core.models import Machine, MachineType from shopdb.core.models.relationship import MachineRelationship, RelationshipType from shopdb.utils.responses import ( success_response, error_response, paginated_response, ErrorCodes ) from shopdb.utils.pagination import get_pagination_params, paginate_query logger = logging.getLogger(__name__) machines_bp = Blueprint('machines', __name__) def add_deprecation_headers(f): """Decorator to add deprecation headers to responses.""" @wraps(f) def decorated_function(*args, **kwargs): response = f(*args, **kwargs) # Add deprecation headers if hasattr(response, 'headers'): response.headers['X-Deprecated'] = 'true' response.headers['X-Deprecated-Message'] = ( 'This endpoint is deprecated. ' 'Please migrate to /api/assets, /api/equipment, /api/computers, /api/network, or /api/printers.' ) response.headers['Sunset'] = '2026-12-31' # Target sunset date # Log deprecation warning (once per request) if not getattr(g, '_deprecation_logged', False): logger.warning( f"Deprecated /api/machines endpoint called: {request.method} {request.path}" ) g._deprecation_logged = True return response return decorated_function @machines_bp.route('', methods=['GET']) @jwt_required(optional=True) @add_deprecation_headers def list_machines(): """ List all machines with filtering and pagination. Query params: page: int (default 1) per_page: int (default 20, max 100) machinetype: int (filter by type ID) pctype: int (filter by PC type ID) businessunit: int (filter by business unit ID) status: int (filter by status ID) category: str (Equipment, PC, Network) search: str (search in machinenumber, alias, hostname) active: bool (default true) sort: str (field name, prefix with - for desc) """ page, per_page = get_pagination_params(request) # Build query query = Machine.query # Apply filters if request.args.get('active', 'true').lower() != 'false': query = query.filter(Machine.isactive == True) if machinetype_id := request.args.get('machinetype', type=int): query = query.filter(Machine.machinetypeid == machinetype_id) if pctype_id := request.args.get('pctype', type=int): query = query.filter(Machine.pctypeid == pctype_id) if businessunit_id := request.args.get('businessunit', type=int): query = query.filter(Machine.businessunitid == businessunit_id) if status_id := request.args.get('status', type=int): query = query.filter(Machine.statusid == status_id) if category := request.args.get('category'): query = query.join(MachineType).filter(MachineType.category == category) if search := request.args.get('search'): search_term = f'%{search}%' query = query.filter( db.or_( Machine.machinenumber.ilike(search_term), Machine.alias.ilike(search_term), Machine.hostname.ilike(search_term), Machine.serialnumber.ilike(search_term) ) ) # Filter for machines with map positions if request.args.get('hasmap', '').lower() == 'true': query = query.filter( Machine.mapleft.isnot(None), Machine.maptop.isnot(None) ) # Apply sorting sort_field = request.args.get('sort', 'machinenumber') desc = sort_field.startswith('-') if desc: sort_field = sort_field[1:] if hasattr(Machine, sort_field): order = getattr(Machine, sort_field) query = query.order_by(order.desc() if desc else order) # For map view, allow fetching all machines without pagination limit include_map_extras = request.args.get('hasmap', '').lower() == 'true' fetch_all = request.args.get('all', '').lower() == 'true' if include_map_extras and fetch_all: # Get all map machines without pagination items = query.all() total = len(items) else: # Normal pagination items, total = paginate_query(query, page, per_page) # Convert to dicts with relationships data = [] for m in items: d = m.to_dict() # Get machinetype from model (single source of truth) mt = m.derived_machinetype d['machinetype'] = mt.machinetype if mt else None d['machinetypeid'] = mt.machinetypeid if mt else None d['category'] = mt.category if mt else None d['status'] = m.status.status if m.status else None d['statusid'] = m.statusid d['businessunit'] = m.businessunit.businessunit if m.businessunit else None d['businessunitid'] = m.businessunitid d['vendor'] = m.vendor.vendor if m.vendor else None d['model'] = m.model.modelnumber if m.model else None d['pctype'] = m.pctype.pctype if m.pctype else None d['serialnumber'] = m.serialnumber d['isvnc'] = m.isvnc d['iswinrm'] = m.iswinrm # Include extra fields for map view if include_map_extras: # Get primary IP address from communications primary_comm = next( (c for c in m.communications if c.isprimary and c.ipaddress), None ) if not primary_comm: # Fall back to first communication with IP primary_comm = next( (c for c in m.communications if c.ipaddress), None ) d['ipaddress'] = primary_comm.ipaddress if primary_comm else None # Get connected PC (parent machine that is a PC) connected_pc = None for rel in m.parent_relationships: if rel.parent_machine and rel.parent_machine.is_pc: connected_pc = rel.parent_machine.machinenumber break d['connected_pc'] = connected_pc data.append(d) return paginated_response(data, page, per_page, total) @machines_bp.route('/', methods=['GET']) @jwt_required(optional=True) @add_deprecation_headers def get_machine(machine_id: int): """Get a single machine by ID.""" machine = Machine.query.get(machine_id) if not machine: return error_response( ErrorCodes.NOT_FOUND, f'Machine with ID {machine_id} not found', http_code=404 ) data = machine.to_dict() # Add related data - machinetype comes from model (single source of truth) mt = machine.derived_machinetype data['machinetype'] = mt.to_dict() if mt else None data['pctype'] = machine.pctype.to_dict() if machine.pctype else None data['status'] = machine.status.to_dict() if machine.status else None data['businessunit'] = machine.businessunit.to_dict() if machine.businessunit else None data['vendor'] = machine.vendor.to_dict() if machine.vendor else None data['model'] = machine.model.to_dict() if machine.model else None data['location'] = machine.location.to_dict() if machine.location else None data['operatingsystem'] = machine.operatingsystem.to_dict() if machine.operatingsystem else None # Add communications data['communications'] = [c.to_dict() for c in machine.communications.all()] return success_response(data) @machines_bp.route('', methods=['POST']) @jwt_required() @add_deprecation_headers def create_machine(): """Create a new machine.""" data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') if not data.get('machinenumber'): return error_response(ErrorCodes.VALIDATION_ERROR, 'machinenumber is required') if not data.get('modelnumberid'): return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumberid is required (determines machine type)') # Check for duplicate machinenumber if Machine.query.filter_by(machinenumber=data['machinenumber']).first(): return error_response( ErrorCodes.CONFLICT, f"Machine number '{data['machinenumber']}' already exists", http_code=409 ) # Create machine allowed_fields = [ 'machinenumber', 'alias', 'hostname', 'serialnumber', 'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid', 'vendorid', 'statusid', 'locationid', 'osid', 'mapleft', 'maptop', 'islocationonly', 'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor', 'requiresmanualconfig', 'notes' ] machine_data = {k: v for k, v in data.items() if k in allowed_fields} machine = Machine(**machine_data) machine.createdby = current_user.username db.session.add(machine) db.session.commit() return success_response( machine.to_dict(), message='Machine created successfully', http_code=201 ) @machines_bp.route('/', methods=['PUT']) @jwt_required() @add_deprecation_headers def update_machine(machine_id: int): """Update an existing machine.""" machine = Machine.query.get(machine_id) if not machine: 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') # Check for duplicate machinenumber if changed if 'machinenumber' in data and data['machinenumber'] != machine.machinenumber: existing = Machine.query.filter_by(machinenumber=data['machinenumber']).first() if existing: return error_response( ErrorCodes.CONFLICT, f"Machine number '{data['machinenumber']}' already exists", http_code=409 ) # Update allowed fields allowed_fields = [ 'machinenumber', 'alias', 'hostname', 'serialnumber', 'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid', 'vendorid', 'statusid', 'locationid', 'osid', 'mapleft', 'maptop', 'islocationonly', 'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor', 'requiresmanualconfig', 'notes', 'isactive' ] for key, value in data.items(): if key in allowed_fields: setattr(machine, key, value) machine.modifiedby = current_user.username db.session.commit() return success_response(machine.to_dict(), message='Machine updated successfully') @machines_bp.route('/', methods=['DELETE']) @jwt_required() @add_deprecation_headers def delete_machine(machine_id: int): """Soft delete a machine.""" machine = Machine.query.get(machine_id) if not machine: return error_response( ErrorCodes.NOT_FOUND, f'Machine with ID {machine_id} not found', http_code=404 ) machine.soft_delete(deleted_by=current_user.username) db.session.commit() return success_response(message='Machine deleted successfully') @machines_bp.route('//communications', methods=['GET']) @jwt_required(optional=True) @add_deprecation_headers def get_machine_communications(machine_id: int): """Get all communications for a machine.""" machine = Machine.query.get(machine_id) if not machine: return error_response( ErrorCodes.NOT_FOUND, f'Machine with ID {machine_id} not found', http_code=404 ) comms = [c.to_dict() for c in machine.communications.all()] return success_response(comms) @machines_bp.route('//communication', methods=['PUT']) @jwt_required() @add_deprecation_headers def update_machine_communication(machine_id: int): """Update machine communication (IP address).""" from shopdb.core.models.communication import Communication, CommunicationType machine = Machine.query.get(machine_id) if not machine: 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') # Get or create IP communication type ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() if not ip_comtype: ip_comtype = CommunicationType(comtype='IP', description='IP Network') db.session.add(ip_comtype) db.session.flush() # Find existing primary communication or create new one comms = list(machine.communications.all()) comm = next((c for c in comms if c.isprimary), None) if not comm: comm = next((c for c in comms if c.comtypeid == ip_comtype.comtypeid), None) if not comm: comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid) db.session.add(comm) # Update fields if 'ipaddress' in data: comm.ipaddress = data['ipaddress'] if 'isprimary' in data: comm.isprimary = data['isprimary'] if 'macaddress' in data: comm.macaddress = data['macaddress'] db.session.commit() return success_response({ 'communicationid': comm.communicationid, 'ipaddress': comm.ipaddress, 'isprimary': comm.isprimary, }, message='Communication updated') # ==================== Machine Relationships ==================== @machines_bp.route('//relationships', methods=['GET']) @jwt_required(optional=True) @add_deprecation_headers def get_machine_relationships(machine_id: int): """Get all relationships for a machine (both parent and child).""" machine = Machine.query.get(machine_id) if not machine: return error_response( ErrorCodes.NOT_FOUND, f'Machine with ID {machine_id} not found', http_code=404 ) relationships = [] my_category = machine.machinetype.category if machine.machinetype else None seen_ids = set() # Get all relationships involving this machine all_rels = list(machine.child_relationships) + list(machine.parent_relationships) for rel in all_rels: if rel.relationshipid in seen_ids: continue seen_ids.add(rel.relationshipid) # Determine the related machine (the one that isn't us) if rel.parentmachineid == machine.machineid: related = rel.child_machine else: related = rel.parent_machine related_category = related.machinetype.category if related and related.machinetype else None rel_type = rel.relationship_type.relationshiptype if rel.relationship_type else None # Determine direction based on relationship type and categories if rel_type == 'Controls': # PC controls Equipment - determine from categories if my_category == 'PC': direction = 'controls' else: direction = 'controlled_by' elif rel_type == 'Dualpath': direction = 'dualpath_partner' else: # For other types, use parent/child if rel.parentmachineid == machine.machineid: direction = 'controls' else: direction = 'controlled_by' relationships.append({ 'relationshipid': rel.relationshipid, 'direction': direction, 'relatedmachineid': related.machineid if related else None, 'relatedmachinenumber': related.machinenumber if related else None, 'relatedmachinealias': related.alias if related else None, 'relatedcategory': related_category, 'relationshiptype': rel_type, 'relationshiptypeid': rel.relationshiptypeid, 'notes': rel.notes }) return success_response(relationships) @machines_bp.route('//relationships', methods=['POST']) @jwt_required() @add_deprecation_headers def create_machine_relationship(machine_id: int): """Create a relationship for a machine.""" machine = Machine.query.get(machine_id) if not machine: 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') related_machine_id = data.get('relatedmachineid') relationship_type_id = data.get('relationshiptypeid') direction = data.get('direction', 'controlled_by') # 'controls' or 'controlled_by' if not related_machine_id: return error_response(ErrorCodes.VALIDATION_ERROR, 'relatedmachineid is required') if not relationship_type_id: return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptypeid is required') related_machine = Machine.query.get(related_machine_id) if not related_machine: return error_response( ErrorCodes.NOT_FOUND, f'Related machine with ID {related_machine_id} not found', http_code=404 ) # Determine parent/child based on direction if direction == 'controls': parent_id = machine_id child_id = related_machine_id else: # controlled_by parent_id = related_machine_id child_id = machine_id # Check if relationship already exists existing = MachineRelationship.query.filter_by( parentmachineid=parent_id, childmachineid=child_id, relationshiptypeid=relationship_type_id ).first() if existing: return error_response( ErrorCodes.CONFLICT, 'This relationship already exists', http_code=409 ) relationship = MachineRelationship( parentmachineid=parent_id, childmachineid=child_id, relationshiptypeid=relationship_type_id, notes=data.get('notes') ) db.session.add(relationship) db.session.commit() return success_response({ 'relationshipid': relationship.relationshipid, 'parentmachineid': relationship.parentmachineid, 'childmachineid': relationship.childmachineid, 'relationshiptypeid': relationship.relationshiptypeid }, message='Relationship created successfully', http_code=201) @machines_bp.route('/relationships/', methods=['DELETE']) @jwt_required() @add_deprecation_headers def delete_machine_relationship(relationship_id: int): """Delete a machine relationship.""" relationship = MachineRelationship.query.get(relationship_id) if not relationship: return error_response( ErrorCodes.NOT_FOUND, f'Relationship with ID {relationship_id} not found', http_code=404 ) db.session.delete(relationship) db.session.commit() return success_response(message='Relationship deleted successfully') @machines_bp.route('/relationshiptypes', methods=['GET']) @jwt_required(optional=True) @add_deprecation_headers def list_relationship_types(): """List all 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]) @machines_bp.route('/relationshiptypes', methods=['POST']) @jwt_required() @add_deprecation_headers def create_relationship_type(): """Create a new relationship type.""" data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') if not data.get('relationshiptype'): return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required') existing = RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first() if existing: 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 successfully', http_code=201)