Files
shopdb-flask/shopdb/core/api/machines.py
cproudlock 9c220a4194 Add USB, Notifications, Network plugins and reusable EmployeeSearch component
New Plugins:
- USB plugin: Device checkout/checkin with employee lookup, checkout history
- Notifications plugin: Announcements with types, scheduling, shopfloor display
- Network plugin: Network device management with subnets and VLANs
- Equipment and Computers plugins: Asset type separation

Frontend:
- EmployeeSearch component: Reusable employee lookup with autocomplete
- USB views: List, detail, checkout/checkin modals
- Notifications views: List, form with recognition mode
- Network views: Device list, detail, form
- Calendar view with FullCalendar integration
- Shopfloor and TV dashboard views
- Reports index page
- Map editor for asset positioning
- Light/dark mode fixes for map tooltips

Backend:
- Employee search API with external lookup service
- Collector API for PowerShell data collection
- Reports API endpoints
- Slides API for TV dashboard
- Fixed AppVersion model (removed BaseModel inheritance)
- Added checkout_name column to usbcheckouts table

Styling:
- Unified detail page styles
- Improved pagination (page numbers instead of prev/next)
- Dark/light mode theme improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:37:49 -05:00

620 lines
21 KiB
Python

"""
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('/<int:machine_id>', 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('/<int:machine_id>', 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('/<int:machine_id>', 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('/<int:machine_id>/communications', methods=['GET'])
@jwt_required()
@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('/<int:machine_id>/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('/<int:machine_id>/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('/<int:machine_id>/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/<int:relationship_id>', 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)