"""Network plugin API endpoints.""" from flask import Blueprint, request from flask_jwt_extended import jwt_required from shopdb.extensions import db from shopdb.core.models import Asset, AssetType, Vendor, AuditLog 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 NetworkDevice, NetworkDeviceType, Subnet, VLAN network_bp = Blueprint('network', __name__) # ============================================================================= # Network Device Types # ============================================================================= @network_bp.route('/types', methods=['GET']) @jwt_required(optional=True) def list_network_device_types(): """List all network device types.""" page, per_page = get_pagination_params(request) query = NetworkDeviceType.query if request.args.get('active', 'true').lower() != 'false': query = query.filter(NetworkDeviceType.isactive == True) if search := request.args.get('search'): query = query.filter(NetworkDeviceType.networkdevicetype.ilike(f'%{search}%')) query = query.order_by(NetworkDeviceType.networkdevicetype) items, total = paginate_query(query, page, per_page) data = [t.to_dict() for t in items] return paginated_response(data, page, per_page, total) @network_bp.route('/types/', methods=['GET']) @jwt_required(optional=True) def get_network_device_type(type_id: int): """Get a single network device type.""" t = NetworkDeviceType.query.get(type_id) if not t: return error_response( ErrorCodes.NOT_FOUND, f'Network device type with ID {type_id} not found', http_code=404 ) return success_response(t.to_dict()) @network_bp.route('/types', methods=['POST']) @jwt_required() def create_network_device_type(): """Create a new network device type.""" data = request.get_json() if not data or not data.get('networkdevicetype'): return error_response(ErrorCodes.VALIDATION_ERROR, 'networkdevicetype is required') if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first(): return error_response( ErrorCodes.CONFLICT, f"Network device type '{data['networkdevicetype']}' already exists", http_code=409 ) t = NetworkDeviceType( networkdevicetype=data['networkdevicetype'], description=data.get('description'), icon=data.get('icon') ) db.session.add(t) db.session.commit() return success_response(t.to_dict(), message='Network device type created', http_code=201) @network_bp.route('/types/', methods=['PUT']) @jwt_required() def update_network_device_type(type_id: int): """Update a network device type.""" t = NetworkDeviceType.query.get(type_id) if not t: return error_response( ErrorCodes.NOT_FOUND, f'Network device 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 'networkdevicetype' in data and data['networkdevicetype'] != t.networkdevicetype: if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first(): return error_response( ErrorCodes.CONFLICT, f"Network device type '{data['networkdevicetype']}' already exists", http_code=409 ) for key in ['networkdevicetype', 'description', 'icon', 'isactive']: if key in data: setattr(t, key, data[key]) db.session.commit() return success_response(t.to_dict(), message='Network device type updated') # ============================================================================= # Network Devices CRUD # ============================================================================= @network_bp.route('', methods=['GET']) @jwt_required(optional=True) def list_network_devices(): """ List all network devices 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 network device type ID - vendor_id: Filter by vendor ID - location_id: Filter by location ID - businessunit_id: Filter by business unit ID - poe: Filter by PoE capability (true/false) - managed: Filter by managed status (true/false) """ page, per_page = get_pagination_params(request) # Join NetworkDevice with Asset query = db.session.query(NetworkDevice).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}%'), NetworkDevice.hostname.ilike(f'%{search}%') ) ) # Type filter if type_id := request.args.get('type_id'): query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id)) # Vendor filter if vendor_id := request.args.get('vendor_id'): query = query.filter(NetworkDevice.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)) # PoE filter if poe := request.args.get('poe'): query = query.filter(NetworkDevice.ispoe == (poe.lower() == 'true')) # Managed filter if managed := request.args.get('managed'): query = query.filter(NetworkDevice.ismanaged == (managed.lower() == 'true')) # Sorting sort_by = request.args.get('sort', 'hostname') sort_dir = request.args.get('dir', 'asc') if sort_by == 'hostname': col = NetworkDevice.hostname elif sort_by == 'assetnumber': col = Asset.assetnumber elif sort_by == 'name': col = Asset.name else: col = NetworkDevice.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 network device data data = [] for netdev in items: item = netdev.asset.to_dict() if netdev.asset else {} item['network_device'] = netdev.to_dict() data.append(item) return paginated_response(data, page, per_page, total) @network_bp.route('/', methods=['GET']) @jwt_required(optional=True) def get_network_device(device_id: int): """Get a single network device with full details.""" netdev = NetworkDevice.query.get(device_id) if not netdev: return error_response( ErrorCodes.NOT_FOUND, f'Network device with ID {device_id} not found', http_code=404 ) result = netdev.asset.to_dict() if netdev.asset else {} result['networkdevice'] = netdev.to_dict() return success_response(result) @network_bp.route('/by-asset/', methods=['GET']) @jwt_required(optional=True) def get_network_device_by_asset(asset_id: int): """Get network device data by asset ID.""" netdev = NetworkDevice.query.filter_by(assetid=asset_id).first() if not netdev: return error_response( ErrorCodes.NOT_FOUND, f'Network device for asset {asset_id} not found', http_code=404 ) result = netdev.asset.to_dict() if netdev.asset else {} result['networkdevice'] = netdev.to_dict() return success_response(result) @network_bp.route('/by-hostname/', methods=['GET']) @jwt_required(optional=True) def get_network_device_by_hostname(hostname: str): """Get network device by hostname.""" netdev = NetworkDevice.query.filter_by(hostname=hostname).first() if not netdev: return error_response( ErrorCodes.NOT_FOUND, f'Network device with hostname {hostname} not found', http_code=404 ) result = netdev.asset.to_dict() if netdev.asset else {} result['networkdevice'] = netdev.to_dict() return success_response(result) @network_bp.route('', methods=['POST']) @jwt_required() def create_network_device(): """ Create new network device (creates both Asset and NetworkDevice records). Required fields: - assetnumber: Business identifier Optional fields: - name, serialnumber, statusid, locationid, businessunitid - networkdevicetypeid, vendorid, hostname - firmwareversion, portcount, ispoe, ismanaged, rackunit - mapleft, maptop, 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 ) # Check for duplicate hostname if data.get('hostname'): if NetworkDevice.query.filter_by(hostname=data['hostname']).first(): return error_response( ErrorCodes.CONFLICT, f"Network device with hostname '{data['hostname']}' already exists", http_code=409 ) # Get network device asset type network_type = AssetType.query.filter_by(assettype='network_device').first() if not network_type: return error_response( ErrorCodes.INTERNAL_ERROR, 'Network device 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=network_type.assettypeid, statusid=data.get('statusid', 1), locationid=data.get('locationid'), businessunitid=data.get('businessunitid'), mapleft=data.get('mapleft'), maptop=data.get('maptop'), notes=data.get('notes') ) db.session.add(asset) db.session.flush() # Get the assetid # Create the network device extension netdev = NetworkDevice( assetid=asset.assetid, networkdevicetypeid=data.get('networkdevicetypeid'), vendorid=data.get('vendorid'), hostname=data.get('hostname'), firmwareversion=data.get('firmwareversion'), portcount=data.get('portcount'), ispoe=data.get('ispoe', False), ismanaged=data.get('ismanaged', False), rackunit=data.get('rackunit') ) db.session.add(netdev) db.session.flush() # Audit log AuditLog.log('created', 'NetworkDevice', entityid=netdev.networkdeviceid, entityname=data.get('hostname') or data['assetnumber']) db.session.commit() result = asset.to_dict() result['networkdevice'] = netdev.to_dict() return success_response(result, message='Network device created', http_code=201) @network_bp.route('/', methods=['PUT']) @jwt_required() def update_network_device(device_id: int): """Update network device (both Asset and NetworkDevice records).""" netdev = NetworkDevice.query.get(device_id) if not netdev: return error_response( ErrorCodes.NOT_FOUND, f'Network device with ID {device_id} not found', http_code=404 ) data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') asset = netdev.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 ) # Check for conflicting hostname if 'hostname' in data and data['hostname'] != netdev.hostname: existing = NetworkDevice.query.filter_by(hostname=data['hostname']).first() if existing and existing.networkdeviceid != device_id: return error_response( ErrorCodes.CONFLICT, f"Network device with hostname '{data['hostname']}' already exists", http_code=409 ) # Track changes for audit log changes = {} # Update asset fields asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', 'locationid', 'businessunitid', 'mapleft', 'maptop', '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 network device fields netdev_fields = ['networkdevicetypeid', 'vendorid', 'hostname', 'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit'] for key in netdev_fields: if key in data: old_val = getattr(netdev, key) new_val = data[key] if old_val != new_val: changes[key] = {'old': old_val, 'new': new_val} setattr(netdev, key, data[key]) # Audit log if there were changes if changes: AuditLog.log('updated', 'NetworkDevice', entityid=netdev.networkdeviceid, entityname=netdev.hostname or asset.assetnumber, changes=changes) db.session.commit() result = asset.to_dict() result['networkdevice'] = netdev.to_dict() return success_response(result, message='Network device updated') @network_bp.route('/', methods=['DELETE']) @jwt_required() def delete_network_device(device_id: int): """Delete (soft delete) network device.""" netdev = NetworkDevice.query.get(device_id) if not netdev: return error_response( ErrorCodes.NOT_FOUND, f'Network device with ID {device_id} not found', http_code=404 ) # Soft delete the asset netdev.asset.isactive = False # Audit log AuditLog.log('deleted', 'NetworkDevice', entityid=netdev.networkdeviceid, entityname=netdev.hostname or netdev.asset.assetnumber) db.session.commit() return success_response(message='Network device deleted') # ============================================================================= # Dashboard # ============================================================================= @network_bp.route('/dashboard/summary', methods=['GET']) @jwt_required(optional=True) def dashboard_summary(): """Get network device dashboard summary data.""" # Total active network devices total = db.session.query(NetworkDevice).join(Asset).filter( Asset.isactive == True ).count() # Count by device type by_type = db.session.query( NetworkDeviceType.networkdevicetype, db.func.count(NetworkDevice.networkdeviceid) ).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid ).join(Asset, Asset.assetid == NetworkDevice.assetid ).filter(Asset.isactive == True ).group_by(NetworkDeviceType.networkdevicetype ).all() # Count by vendor by_vendor = db.session.query( Vendor.vendor, db.func.count(NetworkDevice.networkdeviceid) ).join(NetworkDevice, NetworkDevice.vendorid == Vendor.vendorid ).join(Asset, Asset.assetid == NetworkDevice.assetid ).filter(Asset.isactive == True ).group_by(Vendor.vendor ).all() # Count PoE vs non-PoE poe_count = db.session.query(NetworkDevice).join(Asset).filter( Asset.isactive == True, NetworkDevice.ispoe == True ).count() return success_response({ 'total': total, 'bytype': [{'type': t, 'count': c} for t, c in by_type], 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], 'poe': poe_count, 'nonpoe': total - poe_count }) # ============================================================================= # VLANs # ============================================================================= @network_bp.route('/vlans', methods=['GET']) @jwt_required(optional=True) def list_vlans(): """List all VLANs with filtering and pagination.""" page, per_page = get_pagination_params(request) query = VLAN.query if request.args.get('active', 'true').lower() != 'false': query = query.filter(VLAN.isactive == True) # Search filter if search := request.args.get('search'): query = query.filter( db.or_( VLAN.name.ilike(f'%{search}%'), VLAN.description.ilike(f'%{search}%'), db.cast(VLAN.vlannumber, db.String).ilike(f'%{search}%') ) ) # Type filter if vlan_type := request.args.get('type'): query = query.filter(VLAN.vlantype == vlan_type) query = query.order_by(VLAN.vlannumber) items, total = paginate_query(query, page, per_page) data = [v.to_dict() for v in items] return paginated_response(data, page, per_page, total) @network_bp.route('/vlans/', methods=['GET']) @jwt_required(optional=True) def get_vlan(vlan_id: int): """Get a single VLAN with its subnets.""" vlan = VLAN.query.get(vlan_id) if not vlan: return error_response( ErrorCodes.NOT_FOUND, f'VLAN with ID {vlan_id} not found', http_code=404 ) result = vlan.to_dict() # Include associated subnets result['subnets'] = [s.to_dict() for s in vlan.subnets.filter_by(isactive=True).all()] return success_response(result) @network_bp.route('/vlans', methods=['POST']) @jwt_required() def create_vlan(): """Create a new VLAN.""" data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') if not data.get('vlannumber'): return error_response(ErrorCodes.VALIDATION_ERROR, 'vlannumber is required') if not data.get('name'): return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') # Check for duplicate VLAN number if VLAN.query.filter_by(vlannumber=data['vlannumber']).first(): return error_response( ErrorCodes.CONFLICT, f"VLAN {data['vlannumber']} already exists", http_code=409 ) vlan = VLAN( vlannumber=data['vlannumber'], name=data['name'], description=data.get('description'), vlantype=data.get('vlantype') ) db.session.add(vlan) db.session.flush() # Audit log AuditLog.log('created', 'VLAN', entityid=vlan.vlanid, entityname=f"VLAN {vlan.vlannumber} - {vlan.name}") db.session.commit() return success_response(vlan.to_dict(), message='VLAN created', http_code=201) @network_bp.route('/vlans/', methods=['PUT']) @jwt_required() def update_vlan(vlan_id: int): """Update a VLAN.""" vlan = VLAN.query.get(vlan_id) if not vlan: return error_response( ErrorCodes.NOT_FOUND, f'VLAN with ID {vlan_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 VLAN number if 'vlannumber' in data and data['vlannumber'] != vlan.vlannumber: if VLAN.query.filter_by(vlannumber=data['vlannumber']).first(): return error_response( ErrorCodes.CONFLICT, f"VLAN {data['vlannumber']} already exists", http_code=409 ) # Track changes for audit log changes = {} for key in ['vlannumber', 'name', 'description', 'vlantype', 'isactive']: if key in data: old_val = getattr(vlan, key) new_val = data[key] if old_val != new_val: changes[key] = {'old': old_val, 'new': new_val} setattr(vlan, key, data[key]) # Audit log if there were changes if changes: AuditLog.log('updated', 'VLAN', entityid=vlan.vlanid, entityname=f"VLAN {vlan.vlannumber} - {vlan.name}", changes=changes) db.session.commit() return success_response(vlan.to_dict(), message='VLAN updated') @network_bp.route('/vlans/', methods=['DELETE']) @jwt_required() def delete_vlan(vlan_id: int): """Delete (soft delete) a VLAN.""" vlan = VLAN.query.get(vlan_id) if not vlan: return error_response( ErrorCodes.NOT_FOUND, f'VLAN with ID {vlan_id} not found', http_code=404 ) # Check if VLAN has associated subnets if vlan.subnets.filter_by(isactive=True).count() > 0: return error_response( ErrorCodes.VALIDATION_ERROR, 'Cannot delete VLAN with associated subnets', http_code=400 ) vlan.isactive = False # Audit log AuditLog.log('deleted', 'VLAN', entityid=vlan.vlanid, entityname=f"VLAN {vlan.vlannumber} - {vlan.name}") db.session.commit() return success_response(message='VLAN deleted') # ============================================================================= # Subnets # ============================================================================= @network_bp.route('/subnets', methods=['GET']) @jwt_required(optional=True) def list_subnets(): """List all subnets with filtering and pagination.""" page, per_page = get_pagination_params(request) query = Subnet.query if request.args.get('active', 'true').lower() != 'false': query = query.filter(Subnet.isactive == True) # Search filter if search := request.args.get('search'): query = query.filter( db.or_( Subnet.cidr.ilike(f'%{search}%'), Subnet.name.ilike(f'%{search}%'), Subnet.description.ilike(f'%{search}%') ) ) # VLAN filter if vlan_id := request.args.get('vlanid'): query = query.filter(Subnet.vlanid == int(vlan_id)) # Location filter if location_id := request.args.get('locationid'): query = query.filter(Subnet.locationid == int(location_id)) # Type filter if subnet_type := request.args.get('type'): query = query.filter(Subnet.subnettype == subnet_type) query = query.order_by(Subnet.cidr) items, total = paginate_query(query, page, per_page) data = [s.to_dict() for s in items] return paginated_response(data, page, per_page, total) @network_bp.route('/subnets/', methods=['GET']) @jwt_required(optional=True) def get_subnet(subnet_id: int): """Get a single subnet.""" subnet = Subnet.query.get(subnet_id) if not subnet: return error_response( ErrorCodes.NOT_FOUND, f'Subnet with ID {subnet_id} not found', http_code=404 ) return success_response(subnet.to_dict()) @network_bp.route('/subnets', methods=['POST']) @jwt_required() def create_subnet(): """Create a new subnet.""" data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') if not data.get('cidr'): return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr is required') if not data.get('name'): return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') # Validate CIDR format (basic check) cidr = data['cidr'] if '/' not in cidr: return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)') # Check for duplicate CIDR if Subnet.query.filter_by(cidr=cidr).first(): return error_response( ErrorCodes.CONFLICT, f"Subnet {cidr} already exists", http_code=409 ) # Validate VLAN if provided if data.get('vlanid'): if not VLAN.query.get(data['vlanid']): return error_response( ErrorCodes.VALIDATION_ERROR, f"VLAN with ID {data['vlanid']} not found" ) subnet = Subnet( cidr=cidr, name=data['name'], description=data.get('description'), gatewayip=data.get('gatewayip'), subnetmask=data.get('subnetmask'), networkaddress=data.get('networkaddress'), broadcastaddress=data.get('broadcastaddress'), vlanid=data.get('vlanid'), subnettype=data.get('subnettype'), locationid=data.get('locationid'), dhcpenabled=data.get('dhcpenabled', True), dhcprangestart=data.get('dhcprangestart'), dhcprangeend=data.get('dhcprangeend'), dns1=data.get('dns1'), dns2=data.get('dns2') ) db.session.add(subnet) db.session.flush() # Audit log AuditLog.log('created', 'Subnet', entityid=subnet.subnetid, entityname=f"{subnet.cidr} - {subnet.name}") db.session.commit() return success_response(subnet.to_dict(), message='Subnet created', http_code=201) @network_bp.route('/subnets/', methods=['PUT']) @jwt_required() def update_subnet(subnet_id: int): """Update a subnet.""" subnet = Subnet.query.get(subnet_id) if not subnet: return error_response( ErrorCodes.NOT_FOUND, f'Subnet with ID {subnet_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 CIDR if 'cidr' in data and data['cidr'] != subnet.cidr: if Subnet.query.filter_by(cidr=data['cidr']).first(): return error_response( ErrorCodes.CONFLICT, f"Subnet {data['cidr']} already exists", http_code=409 ) allowed_fields = ['cidr', 'name', 'description', 'gatewayip', 'subnetmask', 'networkaddress', 'broadcastaddress', 'vlanid', 'subnettype', 'locationid', 'dhcpenabled', 'dhcprangestart', 'dhcprangeend', 'dns1', 'dns2', 'isactive'] # Track changes for audit log changes = {} for key in allowed_fields: if key in data: old_val = getattr(subnet, key) new_val = data[key] if old_val != new_val: changes[key] = {'old': old_val, 'new': new_val} setattr(subnet, key, data[key]) # Audit log if there were changes if changes: AuditLog.log('updated', 'Subnet', entityid=subnet.subnetid, entityname=f"{subnet.cidr} - {subnet.name}", changes=changes) db.session.commit() return success_response(subnet.to_dict(), message='Subnet updated') @network_bp.route('/subnets/', methods=['DELETE']) @jwt_required() def delete_subnet(subnet_id: int): """Delete (soft delete) a subnet.""" subnet = Subnet.query.get(subnet_id) if not subnet: return error_response( ErrorCodes.NOT_FOUND, f'Subnet with ID {subnet_id} not found', http_code=404 ) subnet.isactive = False # Audit log AuditLog.log('deleted', 'Subnet', entityid=subnet.subnetid, entityname=f"{subnet.cidr} - {subnet.name}") db.session.commit() return success_response(message='Subnet deleted')