5651 lines
277 KiB
Python
5651 lines
277 KiB
Python
from flask import render_template, request, redirect, url_for, send_from_directory, send_file, session, abort, jsonify
|
|
from db import init_db, hash_password, get_db_connection, verify_password, generate_api_key
|
|
from ipaddress import ip_network
|
|
from functools import wraps
|
|
import os
|
|
import csv
|
|
from io import StringIO, BytesIO
|
|
import logging
|
|
import mysql.connector
|
|
import requests
|
|
import subprocess
|
|
import shutil
|
|
from datetime import datetime
|
|
from werkzeug.utils import secure_filename
|
|
from cache import cache
|
|
import json
|
|
import re
|
|
from ipaddress import ip_address, IPv4Address, IPv6Address
|
|
from urllib.parse import urlparse
|
|
|
|
app = None
|
|
|
|
def login_required(f):
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
if 'logged_in' not in session:
|
|
return redirect(url_for('login'))
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
def has_permission(permission_name, user_id=None, conn=None):
|
|
"""Check if a user has a specific permission"""
|
|
if user_id is None:
|
|
user_id = session.get('user_id')
|
|
if not user_id:
|
|
return False
|
|
|
|
close_conn = False
|
|
if conn is None:
|
|
from flask import current_app
|
|
conn = get_db_connection(current_app)
|
|
close_conn = True
|
|
|
|
try:
|
|
cursor = conn.cursor()
|
|
# Get user's role
|
|
cursor.execute('SELECT role_id FROM User WHERE id = %s', (user_id,))
|
|
role_result = cursor.fetchone()
|
|
if not role_result or not role_result[0]:
|
|
return False
|
|
|
|
role_id = role_result[0]
|
|
|
|
# Check if role has the permission
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM RolePermission rp
|
|
JOIN Permission p ON rp.permission_id = p.id
|
|
WHERE rp.role_id = %s AND p.name = %s
|
|
''', (role_id, permission_name))
|
|
result = cursor.fetchone()
|
|
return result[0] > 0 if result else False
|
|
finally:
|
|
if close_conn:
|
|
conn.close()
|
|
|
|
def permission_required(permission_name):
|
|
"""Decorator to require a specific permission"""
|
|
def decorator(f):
|
|
@wraps(f)
|
|
@login_required
|
|
def decorated_function(*args, **kwargs):
|
|
if not has_permission(permission_name):
|
|
abort(403)
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
return decorator
|
|
|
|
def is_feature_enabled(feature_key, conn=None):
|
|
"""Check if a feature flag is enabled"""
|
|
close_conn = False
|
|
if conn is None:
|
|
from flask import current_app
|
|
conn = get_db_connection(current_app)
|
|
close_conn = True
|
|
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT enabled FROM FeatureFlags WHERE feature_key = %s', (feature_key,))
|
|
result = cursor.fetchone()
|
|
# Default to True if feature flag doesn't exist (backward compatibility)
|
|
return result[0] if result else True
|
|
finally:
|
|
if close_conn:
|
|
conn.close()
|
|
|
|
def get_user_from_api_key(api_key):
|
|
"""Get user from API key"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name, email, role_id FROM User WHERE api_key = %s', (api_key,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
return {
|
|
'id': result[0],
|
|
'name': result[1],
|
|
'email': result[2],
|
|
'role_id': result[3]
|
|
}
|
|
return None
|
|
|
|
def api_auth_required(f):
|
|
"""Decorator for API authentication using API key"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
api_key = None
|
|
# Check for API key in header
|
|
if 'X-API-Key' in request.headers:
|
|
api_key = request.headers['X-API-Key']
|
|
# Check for API key in query parameter
|
|
elif 'api_key' in request.args:
|
|
api_key = request.args.get('api_key')
|
|
# Check for API key in Authorization header (Bearer token)
|
|
elif 'Authorization' in request.headers:
|
|
auth_header = request.headers['Authorization']
|
|
if auth_header.startswith('Bearer '):
|
|
api_key = auth_header[7:]
|
|
|
|
if not api_key:
|
|
return jsonify({'error': 'API key required'}), 401
|
|
|
|
user = get_user_from_api_key(api_key)
|
|
if not user:
|
|
return jsonify({'error': 'Invalid API key'}), 401
|
|
|
|
# Store user info in request context
|
|
request.api_user = user
|
|
|
|
# Execute the function
|
|
response = f(*args, **kwargs)
|
|
|
|
# Log API usage to audit log
|
|
try:
|
|
api_path = request.path
|
|
http_method = request.method
|
|
user_name = user.get('name', 'Unknown')
|
|
|
|
# Get response status code if available
|
|
status_code = None
|
|
if hasattr(response, 'status_code'):
|
|
status_code = response.status_code
|
|
elif isinstance(response, tuple) and len(response) > 1:
|
|
status_code = response[1]
|
|
|
|
# Build details string with status if available
|
|
if status_code:
|
|
details = f"API call: {http_method} {api_path} (Status: {status_code})"
|
|
else:
|
|
details = f"API call: {http_method} {api_path}"
|
|
|
|
add_audit_log(
|
|
user_id=user['id'],
|
|
action='api_usage',
|
|
details=details,
|
|
subnet_id=None
|
|
)
|
|
except Exception as e:
|
|
# Don't fail the request if logging fails
|
|
logging.error(f"Failed to log API usage: {e}")
|
|
|
|
return response
|
|
return decorated_function
|
|
|
|
def api_permission_required(permission_name):
|
|
"""Decorator to require a specific permission for API endpoints"""
|
|
def decorator(f):
|
|
@wraps(f)
|
|
@api_auth_required
|
|
def decorated_function(*args, **kwargs):
|
|
if not has_permission(permission_name, user_id=request.api_user['id']):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
return decorator
|
|
|
|
def add_audit_log(user_id, action, details=None, subnet_id=None, conn=None):
|
|
import datetime
|
|
close_conn = False
|
|
if conn is None:
|
|
from flask import current_app
|
|
conn = get_db_connection(current_app)
|
|
close_conn = True
|
|
cursor = conn.cursor()
|
|
utc_now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
|
|
cursor.execute('''INSERT INTO AuditLog (user_id, action, details, subnet_id, timestamp) VALUES (%s, %s, %s, %s, %s)''',
|
|
(user_id, action, details, subnet_id, utc_now))
|
|
if close_conn:
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def invalidate_cache_for_device(device_id):
|
|
"""Invalidate all cache entries related to a device"""
|
|
cache.invalidate_device(device_id)
|
|
cache.clear('devices')
|
|
|
|
def get_ip_history_from_audit_logs(device_id=None, ip_address=None, conn=None):
|
|
"""
|
|
Extract IP assignment history from audit logs.
|
|
Returns a list of history entries sorted by timestamp (newest first).
|
|
Each entry contains: ip, action, device_name, subnet_name, subnet_cidr, user_name, timestamp
|
|
"""
|
|
import re
|
|
close_conn = False
|
|
if conn is None:
|
|
from flask import current_app
|
|
conn = get_db_connection(current_app)
|
|
close_conn = True
|
|
|
|
try:
|
|
cursor = conn.cursor(dictionary=True)
|
|
|
|
# Get device name if filtering by device_id
|
|
device_name = None
|
|
if device_id:
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device_result = cursor.fetchone()
|
|
if device_result:
|
|
device_name = device_result['name']
|
|
else:
|
|
# Device doesn't exist, return empty history
|
|
return []
|
|
|
|
# Build query to get relevant audit log entries
|
|
query = '''
|
|
SELECT al.id, al.action, al.details, al.timestamp,
|
|
COALESCE(u.name, 'Deleted User') as user_name,
|
|
s.name as subnet_name, s.cidr as subnet_cidr
|
|
FROM AuditLog al
|
|
LEFT JOIN User u ON al.user_id = u.id
|
|
LEFT JOIN Subnet s ON al.subnet_id = s.id
|
|
WHERE (al.action = 'device_add_ip' OR al.action = 'device_delete_ip')
|
|
'''
|
|
params = []
|
|
|
|
if ip_address:
|
|
query += ' AND al.details LIKE %s'
|
|
params.append(f'%IP {ip_address}%')
|
|
|
|
query += ' ORDER BY al.timestamp DESC'
|
|
|
|
cursor.execute(query, params)
|
|
logs = cursor.fetchall()
|
|
|
|
history = []
|
|
# Pattern to extract IP, subnet info, and device name from audit log details
|
|
# Format: "Assigned IP 192.168.1.1 (SubnetName 192.168.1.0/24) to device DeviceName"
|
|
# Format: "Removed IP 192.168.1.1 (SubnetName 192.168.1.0/24) from device DeviceName"
|
|
ip_pattern = r'IP\s+([\d\.]+)'
|
|
device_pattern = r'(?:to|from)\s+device\s+([^\s]+)'
|
|
|
|
for log in logs:
|
|
details = log['details'] or ''
|
|
|
|
# Extract IP address
|
|
ip_match = re.search(ip_pattern, details)
|
|
if not ip_match:
|
|
continue
|
|
|
|
extracted_ip = ip_match.group(1)
|
|
|
|
# If filtering by specific IP, skip if it doesn't match
|
|
if ip_address and extracted_ip != ip_address:
|
|
continue
|
|
|
|
# Extract device name
|
|
device_match = re.search(device_pattern, details)
|
|
extracted_device_name = device_match.group(1) if device_match else 'Unknown'
|
|
|
|
# If filtering by device_id, verify device name matches
|
|
if device_id and device_name:
|
|
if extracted_device_name != device_name:
|
|
continue
|
|
|
|
history.append({
|
|
'ip': extracted_ip,
|
|
'action': 'assigned' if log['action'] == 'device_add_ip' else 'removed',
|
|
'device_name': extracted_device_name,
|
|
'subnet_name': log['subnet_name'] or 'Unknown',
|
|
'subnet_cidr': log['subnet_cidr'] or '',
|
|
'user_name': log['user_name'],
|
|
'timestamp': log['timestamp']
|
|
})
|
|
|
|
return history
|
|
finally:
|
|
if close_conn:
|
|
conn.close()
|
|
|
|
def invalidate_cache_for_subnet(subnet_id):
|
|
"""Invalidate all cache entries related to a subnet"""
|
|
cache.invalidate_subnet(subnet_id)
|
|
cache.clear('index')
|
|
cache.clear('admin')
|
|
|
|
def validate_custom_field_value(field_def, value):
|
|
"""
|
|
Validate a custom field value against its field definition.
|
|
Returns (is_valid, error_message)
|
|
"""
|
|
if value is None or value == '':
|
|
if field_def.get('required', False):
|
|
return False, f"{field_def.get('name', 'Field')} is required"
|
|
return True, None
|
|
|
|
field_type = field_def.get('field_type', 'text')
|
|
validation_rules = field_def.get('validation_rules')
|
|
|
|
# Parse validation rules if it's a JSON string
|
|
if isinstance(validation_rules, str):
|
|
try:
|
|
validation_rules = json.loads(validation_rules)
|
|
except json.JSONDecodeError:
|
|
validation_rules = {}
|
|
elif validation_rules is None:
|
|
validation_rules = {}
|
|
|
|
# Type-specific validation
|
|
if field_type == 'ip_address':
|
|
try:
|
|
ip_address(value)
|
|
except ValueError:
|
|
return False, f"Invalid IP address format: {value}"
|
|
|
|
elif field_type == 'email':
|
|
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
if not re.match(email_pattern, value):
|
|
return False, f"Invalid email format: {value}"
|
|
|
|
elif field_type == 'url':
|
|
try:
|
|
result = urlparse(value)
|
|
if not all([result.scheme, result.netloc]):
|
|
return False, f"Invalid URL format: {value}"
|
|
except Exception:
|
|
return False, f"Invalid URL format: {value}"
|
|
|
|
elif field_type == 'date':
|
|
try:
|
|
datetime.strptime(value, '%Y-%m-%d')
|
|
except ValueError:
|
|
return False, f"Invalid date format. Expected YYYY-MM-DD: {value}"
|
|
|
|
elif field_type == 'datetime':
|
|
try:
|
|
datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
except ValueError:
|
|
return False, f"Invalid datetime format. Expected ISO format: {value}"
|
|
|
|
elif field_type == 'number':
|
|
try:
|
|
int(value)
|
|
except ValueError:
|
|
return False, f"Invalid integer: {value}"
|
|
if 'min_value' in validation_rules:
|
|
if int(value) < validation_rules['min_value']:
|
|
return False, f"Value must be at least {validation_rules['min_value']}"
|
|
if 'max_value' in validation_rules:
|
|
if int(value) > validation_rules['max_value']:
|
|
return False, f"Value must be at most {validation_rules['max_value']}"
|
|
|
|
elif field_type == 'decimal':
|
|
try:
|
|
float(value)
|
|
except ValueError:
|
|
return False, f"Invalid decimal number: {value}"
|
|
if 'min_value' in validation_rules:
|
|
if float(value) < validation_rules['min_value']:
|
|
return False, f"Value must be at least {validation_rules['min_value']}"
|
|
if 'max_value' in validation_rules:
|
|
if float(value) > validation_rules['max_value']:
|
|
return False, f"Value must be at most {validation_rules['max_value']}"
|
|
|
|
elif field_type == 'boolean':
|
|
if value not in [True, False, 'true', 'false', '1', '0', 1, 0]:
|
|
return False, f"Invalid boolean value: {value}"
|
|
|
|
elif field_type == 'select':
|
|
if 'select_options' in validation_rules:
|
|
if value not in validation_rules['select_options']:
|
|
return False, f"Value must be one of: {', '.join(validation_rules['select_options'])}"
|
|
|
|
# Text length validation (applies to text, textarea, and string-based types)
|
|
if field_type in ['text', 'textarea', 'ip_address', 'email', 'url']:
|
|
if 'min_length' in validation_rules:
|
|
if len(str(value)) < validation_rules['min_length']:
|
|
return False, f"Value must be at least {validation_rules['min_length']} characters"
|
|
if 'max_length' in validation_rules:
|
|
if len(str(value)) > validation_rules['max_length']:
|
|
return False, f"Value must be at most {validation_rules['max_length']} characters"
|
|
|
|
# Regex pattern validation
|
|
if 'regex_pattern' in validation_rules:
|
|
try:
|
|
if not re.match(validation_rules['regex_pattern'], str(value)):
|
|
return False, f"Value does not match required pattern"
|
|
except re.error:
|
|
# Invalid regex pattern, skip validation
|
|
pass
|
|
|
|
return True, None
|
|
|
|
def parse_custom_field_value(field_type, raw_value):
|
|
"""Parse and normalize a custom field value based on its type"""
|
|
if raw_value is None or raw_value == '':
|
|
return None
|
|
|
|
if field_type == 'number':
|
|
try:
|
|
return int(raw_value)
|
|
except ValueError:
|
|
return None
|
|
elif field_type == 'decimal':
|
|
try:
|
|
return float(raw_value)
|
|
except ValueError:
|
|
return None
|
|
elif field_type == 'boolean':
|
|
if isinstance(raw_value, bool):
|
|
return raw_value
|
|
if str(raw_value).lower() in ['true', '1', 'yes']:
|
|
return True
|
|
if str(raw_value).lower() in ['false', '0', 'no']:
|
|
return False
|
|
return None
|
|
elif field_type in ['date', 'datetime']:
|
|
# Return as string, validation ensures format
|
|
return str(raw_value)
|
|
else:
|
|
# text, textarea, ip_address, email, url, select
|
|
return str(raw_value)
|
|
|
|
def validate_vlan_id(vlan_id_str):
|
|
"""
|
|
Validate VLAN ID. Must be integer between 1-4094 (standard VLAN range).
|
|
Returns (is_valid, error_message, vlan_id_int)
|
|
"""
|
|
if vlan_id_str is None or vlan_id_str == '':
|
|
return True, None, None
|
|
|
|
try:
|
|
vlan_id = int(vlan_id_str)
|
|
if vlan_id < 1 or vlan_id > 4094:
|
|
return False, "VLAN ID must be between 1 and 4094", None
|
|
return True, None, vlan_id
|
|
except ValueError:
|
|
return False, "VLAN ID must be a valid integer", None
|
|
|
|
def get_custom_fields_for_entity(entity_type, entity_id, conn=None):
|
|
"""
|
|
Retrieve custom field definitions with their values for an entity.
|
|
Returns list of dicts with field definition and current value.
|
|
"""
|
|
close_conn = False
|
|
if conn is None:
|
|
from flask import current_app
|
|
conn = get_db_connection(current_app)
|
|
close_conn = True
|
|
|
|
try:
|
|
cursor = conn.cursor(dictionary=True)
|
|
|
|
# Get field definitions for this entity type
|
|
cursor.execute('''
|
|
SELECT id, entity_type, name, field_key, field_type, required,
|
|
default_value, help_text, display_order, validation_rules, searchable
|
|
FROM CustomFieldDefinition
|
|
WHERE entity_type = %s
|
|
ORDER BY display_order, name
|
|
''', (entity_type,))
|
|
field_defs = cursor.fetchall()
|
|
|
|
# Get current values from entity table
|
|
table_name = 'Device' if entity_type == 'device' else 'Subnet'
|
|
cursor.execute(f'SELECT custom_fields FROM {table_name} WHERE id = %s', (entity_id,))
|
|
result = cursor.fetchone()
|
|
|
|
current_values = {}
|
|
if result and result.get('custom_fields'):
|
|
try:
|
|
current_values = json.loads(result['custom_fields'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
current_values = {}
|
|
|
|
# Merge definitions with values
|
|
fields_with_values = []
|
|
for field_def in field_defs:
|
|
field_key = field_def['field_key']
|
|
current_value = current_values.get(field_key)
|
|
|
|
# Use default value if no current value
|
|
if current_value is None and field_def.get('default_value'):
|
|
current_value = field_def['default_value']
|
|
|
|
# Parse validation_rules if it's a JSON string
|
|
validation_rules = field_def.get('validation_rules')
|
|
if isinstance(validation_rules, str):
|
|
try:
|
|
validation_rules = json.loads(validation_rules)
|
|
except (json.JSONDecodeError, TypeError):
|
|
validation_rules = {}
|
|
elif validation_rules is None:
|
|
validation_rules = {}
|
|
|
|
field_def['current_value'] = current_value
|
|
field_def['validation_rules'] = validation_rules
|
|
fields_with_values.append(field_def)
|
|
|
|
return fields_with_values
|
|
finally:
|
|
if close_conn:
|
|
conn.close()
|
|
|
|
def prewarm_cache(app):
|
|
"""Pre-warm cache in background by loading all data"""
|
|
import threading
|
|
import time
|
|
|
|
def _prewarm():
|
|
"""Background function to pre-warm cache"""
|
|
# Wait a bit for app to fully initialize
|
|
time.sleep(2)
|
|
|
|
try:
|
|
with app.app_context():
|
|
from flask import current_app
|
|
conn = get_db_connection(current_app)
|
|
try:
|
|
cursor = conn.cursor()
|
|
|
|
# Pre-warm index page (all subnets with utilization)
|
|
logging.info("Pre-warming cache: Loading all subnets for index page...")
|
|
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
|
|
subnets = cursor.fetchall()
|
|
sites_subnets = {}
|
|
for subnet in subnets:
|
|
site = subnet[3] or 'Unassigned'
|
|
if site not in sites_subnets:
|
|
sites_subnets[site] = []
|
|
|
|
subnet_id = subnet[0]
|
|
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
total_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
WHERE ip.subnet_id = %s AND ip.id IN (SELECT ip_id FROM DeviceIPAddress)
|
|
''', (subnet_id,))
|
|
assigned_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND ip.id NOT IN (SELECT ip_id FROM DeviceIPAddress)
|
|
''', (subnet_id,))
|
|
dhcp_ips = cursor.fetchone()[0]
|
|
|
|
used_ips = assigned_ips + dhcp_ips
|
|
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
|
|
|
|
sites_subnets[site].append({
|
|
'id': subnet[0],
|
|
'name': subnet[1],
|
|
'cidr': subnet[2],
|
|
'utilization': round(utilization_percent, 1)
|
|
})
|
|
cache.set('index', sites_subnets, ttl=10800)
|
|
logging.info(f"Pre-warmed index cache with {len(subnets)} subnets")
|
|
|
|
# Pre-warm admin page
|
|
logging.info("Pre-warming cache: Loading admin page data...")
|
|
cursor.execute('SELECT id, name, cidr, site FROM Subnet ORDER BY site, name')
|
|
subnet_rows = cursor.fetchall()
|
|
admin_subnets = []
|
|
for row in subnet_rows:
|
|
subnet_id = row[0]
|
|
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
total_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
WHERE ip.subnet_id = %s AND ip.id IN (SELECT ip_id FROM DeviceIPAddress)
|
|
''', (subnet_id,))
|
|
assigned_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND ip.id NOT IN (SELECT ip_id FROM DeviceIPAddress)
|
|
''', (subnet_id,))
|
|
dhcp_ips = cursor.fetchone()[0]
|
|
|
|
available_ips = total_ips - assigned_ips - dhcp_ips
|
|
used_ips = assigned_ips + dhcp_ips
|
|
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
|
|
|
|
admin_subnets.append({
|
|
'id': row[0],
|
|
'name': row[1],
|
|
'cidr': row[2],
|
|
'site': row[3] or 'Unassigned',
|
|
'utilization': {
|
|
'percent': round(utilization_percent, 1),
|
|
'assigned': assigned_ips,
|
|
'used': used_ips,
|
|
'total': total_ips
|
|
}
|
|
})
|
|
# Cache with same structure as admin route expects
|
|
admin_result = {
|
|
'subnets': admin_subnets,
|
|
'can_add_subnet': True, # Will be checked at render time
|
|
'can_edit_subnet': True, # Will be checked at render time
|
|
'can_delete_subnet': True # Will be checked at render time
|
|
}
|
|
cache.set('admin', admin_result, ttl=10800)
|
|
logging.info(f"Pre-warmed admin cache with {len(admin_subnets)} subnets")
|
|
|
|
# Pre-warm all subnet detail pages
|
|
logging.info("Pre-warming cache: Loading all subnet detail pages...")
|
|
for subnet in subnets:
|
|
subnet_id = subnet[0]
|
|
try:
|
|
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet_row = cursor.fetchone()
|
|
if subnet_row:
|
|
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
ip_addresses = cursor.fetchall()
|
|
|
|
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
total_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
WHERE ip.subnet_id = %s AND ip.id IN (SELECT ip_id FROM DeviceIPAddress)
|
|
''', (subnet_id,))
|
|
assigned_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND ip.id NOT IN (SELECT ip_id FROM DeviceIPAddress)
|
|
''', (subnet_id,))
|
|
dhcp_ips = cursor.fetchone()[0]
|
|
|
|
available_ips = total_ips - assigned_ips - dhcp_ips
|
|
used_ips = assigned_ips + dhcp_ips
|
|
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
|
|
|
|
utilization_stats = {
|
|
'total': total_ips,
|
|
'assigned': assigned_ips,
|
|
'dhcp': dhcp_ips,
|
|
'available': available_ips,
|
|
'percent': round(utilization_percent, 1)
|
|
}
|
|
|
|
cursor.execute('SELECT id, name, description FROM Device')
|
|
devices = cursor.fetchall()
|
|
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
|
|
ip_addresses_with_device = []
|
|
for ip in ip_addresses:
|
|
ip_id = ip[0]
|
|
ip_address = ip[1]
|
|
hostname = ip[2]
|
|
ip_notes = ip[3] if len(ip) > 3 else None
|
|
device_id = None
|
|
device_description = None
|
|
if hostname:
|
|
match = device_name_map.get(hostname.lower())
|
|
if match:
|
|
device_id, device_description = match
|
|
ip_addresses_with_device.append((ip_id, ip_address, hostname, device_id, device_description, ip_notes))
|
|
|
|
subnet_dict = {'id': subnet_row[0], 'name': subnet_row[1], 'cidr': subnet_row[2]}
|
|
result = {
|
|
'subnet': subnet_dict,
|
|
'ip_addresses': ip_addresses_with_device,
|
|
'utilization': utilization_stats
|
|
}
|
|
cache.set(f'subnet:{subnet_id}', result, ttl=10800)
|
|
except Exception as e:
|
|
logging.error(f"Error pre-warming subnet {subnet_id}: {e}")
|
|
logging.info(f"Pre-warmed {len(subnets)} subnet detail pages")
|
|
|
|
# Pre-warm all device detail pages
|
|
logging.info("Pre-warming cache: Loading all device detail pages...")
|
|
cursor.execute('SELECT id FROM Device')
|
|
device_ids = [row[0] for row in cursor.fetchall()]
|
|
for device_id in device_ids:
|
|
try:
|
|
cursor.execute('SELECT id, name, description, device_type_id FROM Device WHERE id = %s', (device_id,))
|
|
device = cursor.fetchone()
|
|
if device:
|
|
cursor.execute('SELECT id, name FROM DeviceType ORDER BY name')
|
|
device_types = cursor.fetchall()
|
|
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
|
|
subnets = [dict(id=row[0], name=row[1], cidr=row[2], site=row[3]) for row in cursor.fetchall()]
|
|
cursor.execute('''SELECT DeviceIPAddress.id as device_ip_id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id WHERE DeviceIPAddress.device_id = %s''', (device_id,))
|
|
device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
|
|
|
|
# Get tags for device (only if tags feature is enabled)
|
|
tags_enabled = is_feature_enabled('device_tags', conn=conn)
|
|
device_tags = []
|
|
all_tags = []
|
|
if tags_enabled:
|
|
cursor.execute('''
|
|
SELECT t.id, t.name, t.color
|
|
FROM DeviceTag dt
|
|
JOIN Tag t ON dt.tag_id = t.id
|
|
WHERE dt.device_id = %s
|
|
ORDER BY t.name
|
|
''', (device_id,))
|
|
device_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
|
|
|
|
cursor.execute('SELECT id, name, color FROM Tag ORDER BY name')
|
|
all_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
|
|
|
|
available_ips_by_subnet = {}
|
|
for subnet in subnets:
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip FROM IPAddress ip
|
|
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
WHERE ip.subnet_id = %s AND dia.ip_id IS NULL
|
|
''', (subnet['id'],))
|
|
ips = [{'id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
|
|
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet['id'],))
|
|
dhcp_row = cursor.fetchone()
|
|
if dhcp_row:
|
|
start_ip, end_ip, excluded_ips = dhcp_row
|
|
excluded_list = [ip for ip in (excluded_ips or '').replace(' ', '').split(',') if ip]
|
|
in_range = False
|
|
filtered_ips = []
|
|
for ip_obj in ips:
|
|
ip = ip_obj['ip']
|
|
if ip == start_ip:
|
|
in_range = True
|
|
if ip in excluded_list or not (in_range and ip not in excluded_list):
|
|
filtered_ips.append(ip_obj)
|
|
if ip == end_ip:
|
|
in_range = False
|
|
ips = filtered_ips
|
|
available_ips_by_subnet[subnet['id']] = ips
|
|
|
|
result_data = {
|
|
'device': {'id': device[0], 'name': device[1], 'description': device[2], 'device_type_id': device[3]},
|
|
'subnets': subnets,
|
|
'device_ips': device_ips,
|
|
'available_ips_by_subnet': available_ips_by_subnet,
|
|
'device_types': device_types,
|
|
'device_tags': device_tags,
|
|
'all_tags': all_tags,
|
|
'can_assign_device_tag': True, # Will be checked at render time
|
|
'can_remove_device_tag': True # Will be checked at render time
|
|
}
|
|
cache.set(f'device:{device_id}', result_data, ttl=10800)
|
|
except Exception as e:
|
|
logging.error(f"Error pre-warming device {device_id}: {e}")
|
|
logging.info(f"Pre-warmed {len(device_ids)} device detail pages")
|
|
|
|
logging.info("Cache pre-warming completed successfully")
|
|
except Exception as e:
|
|
logging.error(f"Error during cache pre-warming: {e}")
|
|
finally:
|
|
conn.close()
|
|
except Exception as e:
|
|
logging.error(f"Error in cache pre-warming thread: {e}")
|
|
|
|
# Start pre-warming in background thread
|
|
thread = threading.Thread(target=_prewarm, daemon=True)
|
|
thread.start()
|
|
logging.info("Started background cache pre-warming thread")
|
|
|
|
def register_routes(app, limiter=None):
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
# Helper function to apply rate limiting if limiter is available
|
|
def rate_limit(limit_str):
|
|
"""Apply rate limiting decorator if limiter is available"""
|
|
if limiter:
|
|
return limiter.limit(limit_str)
|
|
else:
|
|
# Return a no-op decorator if limiter is not available
|
|
def noop_decorator(f):
|
|
return f
|
|
return noop_decorator
|
|
|
|
@app.route('/login', methods=['GET', 'POST'])
|
|
def login():
|
|
# If already logged in, redirect to index
|
|
if session.get('logged_in'):
|
|
return redirect(url_for('index'))
|
|
|
|
error = None
|
|
if request.method == 'POST':
|
|
email = request.form['email']
|
|
password = request.form['password']
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, password FROM User WHERE email = %s', (email,))
|
|
user = cursor.fetchone()
|
|
if user and verify_password(password, user[1]):
|
|
user_id = user[0]
|
|
# Check if user's role requires 2FA
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT u.totp_enabled, u.two_fa_setup_complete, r.require_2fa
|
|
FROM User u
|
|
LEFT JOIN Role r ON u.role_id = r.id
|
|
WHERE u.id = %s
|
|
''', (user_id,))
|
|
result = cursor.fetchone()
|
|
totp_enabled = result[0] if result else False
|
|
setup_complete = result[1] if result else False
|
|
role_requires_2fa = result[2] if result else False
|
|
|
|
# If role requires 2FA but user hasn't set it up, redirect to setup
|
|
if role_requires_2fa and not setup_complete:
|
|
session['pending_user_id'] = user_id
|
|
session['pending_email'] = email
|
|
return redirect(url_for('setup_2fa'))
|
|
|
|
# If 2FA is enabled, require verification
|
|
if totp_enabled:
|
|
session['pending_user_id'] = user_id
|
|
session['pending_email'] = email
|
|
return redirect(url_for('verify_2fa'))
|
|
|
|
# Normal login - no 2FA required
|
|
session['logged_in'] = True
|
|
session['user_id'] = user_id
|
|
session.modified = True # Ensure session is saved
|
|
logging.info(f"User {email} logged in successfully.")
|
|
return redirect(url_for('index'))
|
|
else:
|
|
logging.info(f"Failed login attempt for email: {email}")
|
|
error = 'Invalid email or password.'
|
|
return render_with_user('login.html', error=error)
|
|
|
|
@app.route('/logout')
|
|
def logout():
|
|
user_name = get_current_user_name()
|
|
logging.info(f"User {user_name} logged out.")
|
|
session.clear()
|
|
return redirect(url_for('login'))
|
|
|
|
@app.route('/setup-2fa', methods=['GET', 'POST'])
|
|
def setup_2fa():
|
|
from totp_utils import generate_totp_secret, get_totp_uri, generate_qr_code, verify_totp, generate_backup_codes, format_backup_codes
|
|
from flask import current_app
|
|
import json
|
|
|
|
# If already logged in, redirect to index
|
|
if session.get('logged_in'):
|
|
return redirect(url_for('index'))
|
|
|
|
pending_user_id = session.get('pending_user_id')
|
|
if not pending_user_id:
|
|
return redirect(url_for('login'))
|
|
|
|
if request.method == 'POST':
|
|
action = request.form.get('action')
|
|
|
|
if action == 'generate':
|
|
# Generate new TOTP secret
|
|
secret = generate_totp_secret()
|
|
session['temp_totp_secret'] = secret
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT email FROM User WHERE id = %s', (pending_user_id,))
|
|
email = cursor.fetchone()[0]
|
|
|
|
totp_uri = get_totp_uri(secret, email)
|
|
qr_code = generate_qr_code(totp_uri)
|
|
return render_with_user('setup_2fa.html', secret=secret, qr_code=qr_code, email=email, step='verify')
|
|
|
|
elif action == 'verify':
|
|
code = request.form.get('code', '').strip()
|
|
secret = session.get('temp_totp_secret')
|
|
|
|
if not secret:
|
|
return render_with_user('setup_2fa.html', error='Session expired. Please start over.', step='generate')
|
|
|
|
if verify_totp(secret, code):
|
|
# Save TOTP secret and generate backup codes
|
|
backup_codes = generate_backup_codes()
|
|
backup_codes_json = json.dumps(backup_codes)
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
UPDATE User
|
|
SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s, two_fa_setup_complete = TRUE
|
|
WHERE id = %s
|
|
''', (secret, backup_codes_json, pending_user_id))
|
|
|
|
session.pop('temp_totp_secret', None)
|
|
session['logged_in'] = True
|
|
session['user_id'] = pending_user_id
|
|
session.pop('pending_user_id', None)
|
|
session.pop('pending_email', None)
|
|
session.modified = True # Ensure session is saved
|
|
|
|
formatted_codes = format_backup_codes(backup_codes)
|
|
logging.info(f"User {pending_user_id} enabled 2FA successfully.")
|
|
return render_with_user('setup_2fa.html', backup_codes=formatted_codes, step='backup_codes')
|
|
else:
|
|
return render_with_user('setup_2fa.html', error='Invalid code. Please try again.', secret=secret, step='verify')
|
|
|
|
return render_with_user('setup_2fa.html', step='generate')
|
|
|
|
@app.route('/verify-2fa', methods=['GET', 'POST'])
|
|
def verify_2fa():
|
|
from totp_utils import verify_totp, verify_backup_code
|
|
from flask import current_app
|
|
|
|
# If already logged in, redirect to index
|
|
if session.get('logged_in'):
|
|
return redirect(url_for('index'))
|
|
|
|
pending_user_id = session.get('pending_user_id')
|
|
if not pending_user_id:
|
|
return redirect(url_for('login'))
|
|
|
|
if request.method == 'POST':
|
|
code = request.form.get('code', '').strip()
|
|
use_backup = request.form.get('use_backup') == 'true'
|
|
|
|
if not code:
|
|
return render_with_user('verify_2fa.html', error='Please enter a verification code.')
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT totp_secret, backup_codes FROM User WHERE id = %s', (pending_user_id,))
|
|
result = cursor.fetchone()
|
|
if not result:
|
|
return render_with_user('verify_2fa.html', error='User not found.')
|
|
|
|
totp_secret, backup_codes_json = result
|
|
|
|
# CRITICAL: Ensure TOTP secret exists before attempting verification
|
|
if not totp_secret:
|
|
return render_with_user('verify_2fa.html', error='2FA is not properly configured for this account.')
|
|
|
|
if use_backup:
|
|
# Verify backup code
|
|
if not backup_codes_json:
|
|
return render_with_user('verify_2fa.html', error='No backup codes available.')
|
|
|
|
valid, updated_codes = verify_backup_code(backup_codes_json, code)
|
|
if valid:
|
|
# Update backup codes in database
|
|
cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s',
|
|
(updated_codes, pending_user_id))
|
|
conn.commit()
|
|
session['logged_in'] = True
|
|
session['user_id'] = pending_user_id
|
|
session.pop('pending_user_id', None)
|
|
session.pop('pending_email', None)
|
|
session.modified = True # Ensure session is saved
|
|
logging.info(f"User {pending_user_id} logged in with backup code.")
|
|
return redirect(url_for('index'))
|
|
else:
|
|
return render_with_user('verify_2fa.html', error='Invalid backup code.')
|
|
else:
|
|
# Verify TOTP code - ensure code is exactly 6 digits
|
|
if len(code) != 6 or not code.isdigit():
|
|
return render_with_user('verify_2fa.html', error='Invalid code format. Please enter a 6-digit code.')
|
|
|
|
if verify_totp(totp_secret, code):
|
|
session['logged_in'] = True
|
|
session['user_id'] = pending_user_id
|
|
session.pop('pending_user_id', None)
|
|
session.pop('pending_email', None)
|
|
session.modified = True # Ensure session is saved
|
|
logging.info(f"User {pending_user_id} logged in with 2FA.")
|
|
return redirect(url_for('index'))
|
|
else:
|
|
return render_with_user('verify_2fa.html', error='Invalid code. Please try again.')
|
|
|
|
return render_with_user('verify_2fa.html')
|
|
|
|
@app.route('/')
|
|
@permission_required('view_index')
|
|
def index():
|
|
cache_key = 'index'
|
|
cached_result = cache.get(cache_key)
|
|
if cached_result is not None:
|
|
return render_with_user('index.html', sites_subnets=cached_result)
|
|
|
|
from flask import current_app
|
|
conn = get_db_connection(current_app)
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
|
|
subnets = cursor.fetchall()
|
|
sites_subnets = {}
|
|
for subnet in subnets:
|
|
site = subnet[3] or 'Unassigned'
|
|
if site not in sites_subnets:
|
|
sites_subnets[site] = []
|
|
|
|
# Calculate utilization for each subnet
|
|
subnet_id = subnet[0]
|
|
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
total_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
INNER JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
WHERE ip.subnet_id = %s
|
|
''', (subnet_id,))
|
|
assigned_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND dia.ip_id IS NULL
|
|
''', (subnet_id,))
|
|
dhcp_ips = cursor.fetchone()[0]
|
|
|
|
used_ips = assigned_ips + dhcp_ips
|
|
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
|
|
|
|
sites_subnets[site].append({
|
|
'id': subnet[0],
|
|
'name': subnet[1],
|
|
'cidr': subnet[2],
|
|
'utilization': round(utilization_percent, 1)
|
|
})
|
|
# Cache for 3 hours
|
|
cache.set(cache_key, sites_subnets, ttl=10800)
|
|
return render_with_user('index.html', sites_subnets=sites_subnets)
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.route('/devices')
|
|
@permission_required('view_devices')
|
|
def devices():
|
|
from flask import current_app
|
|
tag_filter = request.args.get('tag')
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
tags_enabled = is_feature_enabled('device_tags', conn=conn)
|
|
|
|
cursor = conn.cursor()
|
|
|
|
# Base device query
|
|
if tag_filter and tags_enabled:
|
|
cursor.execute('''
|
|
SELECT DISTINCT d.id, d.name, dt.icon_class
|
|
FROM Device d
|
|
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
|
|
JOIN DeviceTag dtag ON d.id = dtag.device_id
|
|
JOIN Tag t ON dtag.tag_id = t.id
|
|
WHERE t.name = %s
|
|
ORDER BY d.name
|
|
''', (tag_filter,))
|
|
else:
|
|
cursor.execute('''SELECT Device.id, Device.name, DeviceType.icon_class FROM Device LEFT JOIN DeviceType ON Device.device_type_id = DeviceType.id ORDER BY Device.name''')
|
|
|
|
devices = cursor.fetchall()
|
|
|
|
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
|
|
subnets = cursor.fetchall()
|
|
cursor.execute('SELECT DeviceIPAddress.device_id, IPAddress.id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id')
|
|
device_ips = {}
|
|
for row in cursor.fetchall():
|
|
device_ips.setdefault(row[0], []).append((row[1], row[2]))
|
|
|
|
# Get tags for each device (only if tags feature is enabled)
|
|
device_tags = {}
|
|
all_tag_names = []
|
|
if tags_enabled:
|
|
for device in devices:
|
|
cursor.execute('''
|
|
SELECT t.id, t.name, t.color
|
|
FROM DeviceTag dt
|
|
JOIN Tag t ON dt.tag_id = t.id
|
|
WHERE dt.device_id = %s
|
|
ORDER BY t.name
|
|
''', (device[0],))
|
|
device_tags[device[0]] = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
|
|
|
|
# Get all available tags for filtering
|
|
cursor.execute('SELECT DISTINCT name FROM Tag ORDER BY name')
|
|
all_tag_names = [row[0] for row in cursor.fetchall()]
|
|
|
|
# Optimize: Get device sites in a single query instead of N+1
|
|
sites_devices = {}
|
|
device_sites = {}
|
|
if devices:
|
|
device_ids = [device[0] for device in devices]
|
|
placeholders = ','.join(['%s'] * len(device_ids))
|
|
cursor.execute(f'''
|
|
SELECT DISTINCT DeviceIPAddress.device_id, Subnet.site
|
|
FROM DeviceIPAddress
|
|
JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id
|
|
JOIN Subnet ON IPAddress.subnet_id = Subnet.id
|
|
WHERE DeviceIPAddress.device_id IN ({placeholders})
|
|
''', tuple(device_ids))
|
|
for row in cursor.fetchall():
|
|
device_sites[row[0]] = row[1] or 'Unassigned'
|
|
|
|
for device in devices:
|
|
site = device_sites.get(device[0], 'Unassigned')
|
|
if site not in sites_devices:
|
|
sites_devices[site] = []
|
|
sites_devices[site].append({'id': device[0], 'name': device[1], 'icon_class': device[2]})
|
|
|
|
return render_with_user('devices.html', sites_devices=sites_devices, device_ips=device_ips,
|
|
device_tags=device_tags, all_tag_names=all_tag_names,
|
|
current_tag_filter=tag_filter)
|
|
|
|
@app.route('/add_device', methods=['GET', 'POST'])
|
|
@permission_required('add_device')
|
|
def add_device():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name FROM DeviceType ORDER BY name')
|
|
device_types = cursor.fetchall()
|
|
if request.method == 'POST':
|
|
name = request.form['device_name']
|
|
device_type_id = int(request.form['device_type'])
|
|
user_name = get_current_user_name()
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT INTO Device (name, device_type_id) VALUES (%s, %s)', (name, device_type_id))
|
|
conn.commit()
|
|
# Invalidate cache
|
|
cache.clear('devices')
|
|
cache.clear('device_list')
|
|
logging.info(f"User {user_name} added device '{name}' (type {device_type_id}).")
|
|
return redirect(url_for('devices'))
|
|
return render_with_user('add_device.html', device_types=device_types)
|
|
|
|
@app.route('/device/<int:device_id>')
|
|
@permission_required('view_device')
|
|
def device(device_id):
|
|
cache_key = f'device:{device_id}'
|
|
cached_result = cache.get(cache_key)
|
|
if cached_result is not None:
|
|
# Verify device still exists before using cached result
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM Device WHERE id = %s', (device_id,))
|
|
if not cursor.fetchone():
|
|
# Device was deleted, clear cache and redirect
|
|
cache.delete(cache_key)
|
|
return redirect(url_for('devices'))
|
|
# Get custom fields for device (not cached)
|
|
custom_fields = get_custom_fields_for_entity('device', device_id, conn=conn)
|
|
cached_result['custom_fields'] = custom_fields
|
|
cached_result['can_edit_device'] = has_permission('edit_device')
|
|
return render_with_user('device.html', **cached_result)
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name, description, device_type_id FROM Device WHERE id = %s', (device_id,))
|
|
device = cursor.fetchone()
|
|
if not device:
|
|
# Device doesn't exist, redirect to devices page
|
|
return redirect(url_for('devices'))
|
|
|
|
cursor.execute('SELECT id, name FROM DeviceType ORDER BY name')
|
|
device_types = cursor.fetchall()
|
|
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
|
|
subnets = [dict(id=row[0], name=row[1], cidr=row[2], site=row[3]) for row in cursor.fetchall()]
|
|
cursor.execute('''SELECT DeviceIPAddress.id as device_ip_id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id WHERE DeviceIPAddress.device_id = %s''', (device_id,))
|
|
device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
|
|
|
|
# Get device tags (only if tags feature is enabled)
|
|
tags_enabled = is_feature_enabled('device_tags', conn=conn)
|
|
device_tags = []
|
|
all_tags = []
|
|
if tags_enabled:
|
|
cursor.execute('''
|
|
SELECT t.id, t.name, t.color
|
|
FROM DeviceTag dt
|
|
JOIN Tag t ON dt.tag_id = t.id
|
|
WHERE dt.device_id = %s
|
|
ORDER BY t.name
|
|
''', (device_id,))
|
|
device_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
|
|
|
|
# Get all available tags
|
|
cursor.execute('SELECT id, name, color FROM Tag ORDER BY name')
|
|
all_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
|
|
available_ips_by_subnet = {}
|
|
for subnet in subnets:
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip FROM IPAddress ip
|
|
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
WHERE ip.subnet_id = %s AND dia.ip_id IS NULL
|
|
''', (subnet['id'],))
|
|
ips = [{'id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
|
|
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet['id'],))
|
|
dhcp_row = cursor.fetchone()
|
|
if dhcp_row:
|
|
start_ip, end_ip, excluded_ips = dhcp_row
|
|
excluded_list = [ip for ip in (excluded_ips or '').replace(' ', '').split(',') if ip]
|
|
in_range = False
|
|
filtered_ips = []
|
|
for ip_obj in ips:
|
|
ip = ip_obj['ip']
|
|
if ip == start_ip:
|
|
in_range = True
|
|
if ip in excluded_list or not (in_range and ip not in excluded_list):
|
|
filtered_ips.append(ip_obj)
|
|
if ip == end_ip:
|
|
in_range = False
|
|
ips = filtered_ips
|
|
available_ips_by_subnet[subnet['id']] = ips
|
|
|
|
# Get custom fields for device
|
|
custom_fields = get_custom_fields_for_entity('device', device_id, conn=conn)
|
|
|
|
# Get IP history for this device
|
|
ip_history = get_ip_history_from_audit_logs(device_id=device_id, conn=conn)
|
|
|
|
return render_with_user('device.html',
|
|
device={'id': device[0], 'name': device[1], 'description': device[2], 'device_type_id': device[3]},
|
|
subnets=subnets, device_ips=device_ips, available_ips_by_subnet=available_ips_by_subnet,
|
|
device_types=device_types, device_tags=device_tags, all_tags=all_tags,
|
|
can_assign_device_tag=has_permission('assign_device_tag'),
|
|
can_remove_device_tag=has_permission('remove_device_tag'),
|
|
ip_history=ip_history,
|
|
custom_fields=custom_fields,
|
|
can_edit_device=has_permission('edit_device'))
|
|
|
|
@app.route('/api/device/<int:device_id>/ip_history')
|
|
@permission_required('view_device')
|
|
def device_ip_history(device_id):
|
|
"""Get IP history for a device as JSON"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
ip_history = get_ip_history_from_audit_logs(device_id=device_id, conn=conn)
|
|
return jsonify({'history': ip_history})
|
|
|
|
@app.route('/api/ip/<ip_address>/history')
|
|
@permission_required('view_subnet')
|
|
def ip_address_history(ip_address):
|
|
"""Get IP history for a specific IP address as JSON"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
ip_history = get_ip_history_from_audit_logs(ip_address=ip_address, conn=conn)
|
|
return jsonify({'history': ip_history, 'ip': ip_address})
|
|
|
|
@app.route('/update_device_type', methods=['POST'])
|
|
@permission_required('edit_device')
|
|
def update_device_type():
|
|
device_id = request.form['device_id']
|
|
device_type_id = request.form['device_type_id']
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('UPDATE Device SET device_type_id = %s WHERE id = %s', (device_type_id, device_id))
|
|
conn.commit()
|
|
# Invalidate cache
|
|
invalidate_cache_for_device(device_id)
|
|
logging.info(f"User {user_name} updated device {device_id} to type {device_type_id}.")
|
|
return redirect(url_for('device', device_id=device_id))
|
|
|
|
@app.route('/device/<int:device_id>/add_ip', methods=['POST'])
|
|
@permission_required('add_device_ip')
|
|
def device_add_ip(device_id):
|
|
subnet_id = request.form['subnet_id']
|
|
ip_id = request.form['ip_id']
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, ip FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
all_ip_rows = cursor.fetchall()
|
|
cursor.execute('SELECT ip_id FROM DeviceIPAddress')
|
|
assigned_ip_ids = [row[0] for row in cursor.fetchall()]
|
|
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
dhcp_row = cursor.fetchone()
|
|
if dhcp_row:
|
|
start_ip, end_ip, excluded_ips = dhcp_row
|
|
excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x]
|
|
cursor.execute('SELECT ip, hostname FROM IPAddress WHERE id = %s', (ip_id,))
|
|
ip_row = cursor.fetchone()
|
|
if not ip_row:
|
|
raise Exception("The selected IP address is no longer available. Please refresh and try again.")
|
|
ip = ip_row[0]
|
|
hostname = ip_row[1]
|
|
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
dhcp_row = cursor.fetchone()
|
|
if dhcp_row:
|
|
start_ip, end_ip, excluded_ips = dhcp_row
|
|
excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x]
|
|
if ip not in excluded_list:
|
|
cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
all_ips = [row[0] for row in cursor.fetchall()]
|
|
in_range = False
|
|
reserved_for_dhcp = False
|
|
for candidate_ip in all_ips:
|
|
if candidate_ip == start_ip:
|
|
in_range = True
|
|
if in_range and candidate_ip == ip:
|
|
reserved_for_dhcp = True
|
|
break
|
|
if candidate_ip == end_ip:
|
|
in_range = False
|
|
if reserved_for_dhcp:
|
|
raise Exception("This IP is reserved for DHCP and cannot be assigned to a device.")
|
|
cursor.execute('INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (%s, %s)', (device_id, ip_id))
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device_name = cursor.fetchone()[0]
|
|
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE id = %s', (device_name, ip_id))
|
|
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,))
|
|
ip, subnet_id_val = cursor.fetchone()
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id_val,))
|
|
subnet_name, subnet_cidr = cursor.fetchone()
|
|
details = f"Assigned IP {ip} ({subnet_name} {subnet_cidr}) to device {device_name}"
|
|
add_audit_log(session['user_id'], 'device_add_ip', details, subnet_id_val, conn=conn)
|
|
conn.commit()
|
|
# Invalidate cache
|
|
invalidate_cache_for_device(device_id)
|
|
cache.invalidate_subnet(subnet_id_val)
|
|
logging.info(f"User {user_name} assigned IP {ip} to device {device_id}.")
|
|
return redirect(url_for('device', device_id=device_id))
|
|
|
|
@app.route('/device/<int:device_id>/delete_ip', methods=['POST'])
|
|
@permission_required('remove_device_ip')
|
|
def device_delete_ip(device_id):
|
|
device_ip_id = request.form['device_ip_id']
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT ip_id FROM DeviceIPAddress WHERE id = %s', (device_ip_id,))
|
|
ip_id = cursor.fetchone()[0]
|
|
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,))
|
|
ip, subnet_id_val = cursor.fetchone()
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id_val,))
|
|
subnet_name, subnet_cidr = cursor.fetchone()
|
|
cursor.execute('SELECT device_id FROM DeviceIPAddress WHERE id = %s', (device_ip_id,))
|
|
device_id_val = cursor.fetchone()[0]
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id_val,))
|
|
device_name = cursor.fetchone()[0]
|
|
details = f"Removed IP {ip} ({subnet_name} {subnet_cidr}) from device {device_name}"
|
|
add_audit_log(session['user_id'], 'device_delete_ip', details, subnet_id_val, conn=conn)
|
|
cursor.execute('DELETE FROM DeviceIPAddress WHERE id = %s', (device_ip_id,))
|
|
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE id = %s', (ip_id,))
|
|
conn.commit()
|
|
# Invalidate cache
|
|
invalidate_cache_for_device(device_id)
|
|
cache.invalidate_subnet(subnet_id_val)
|
|
logging.info(f"User {user_name} removed IP {ip} from device {device_id}.")
|
|
return redirect(url_for('device', device_id=device_id))
|
|
|
|
@app.route('/device/<int:device_id>/assign_tag', methods=['POST'])
|
|
@permission_required('assign_device_tag')
|
|
def device_assign_tag(device_id):
|
|
tag_id = request.form['tag_id']
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device = cursor.fetchone()
|
|
if not device:
|
|
return redirect(url_for('devices'))
|
|
device_name = device[0]
|
|
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag = cursor.fetchone()
|
|
if not tag:
|
|
return redirect(url_for('device', device_id=device_id))
|
|
tag_name = tag[0]
|
|
|
|
cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
|
|
if cursor.fetchone():
|
|
return redirect(url_for('device', device_id=device_id)) # Already assigned
|
|
|
|
cursor.execute('INSERT INTO DeviceTag (device_id, tag_id) VALUES (%s, %s)', (device_id, tag_id))
|
|
add_audit_log(session['user_id'], 'assign_device_tag', f"Assigned tag '{tag_name}' to device '{device_name}'", conn=conn)
|
|
conn.commit()
|
|
# Invalidate cache
|
|
invalidate_cache_for_device(device_id)
|
|
cache.clear('devices')
|
|
return redirect(url_for('device', device_id=device_id))
|
|
|
|
@app.route('/device/<int:device_id>/remove_tag', methods=['POST'])
|
|
@permission_required('remove_device_tag')
|
|
def device_remove_tag(device_id):
|
|
tag_id = request.form['tag_id']
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device = cursor.fetchone()
|
|
if not device:
|
|
return redirect(url_for('devices'))
|
|
device_name = device[0]
|
|
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag = cursor.fetchone()
|
|
if not tag:
|
|
return redirect(url_for('device', device_id=device_id))
|
|
tag_name = tag[0]
|
|
|
|
cursor.execute('DELETE FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
|
|
add_audit_log(session['user_id'], 'remove_device_tag', f"Removed tag '{tag_name}' from device '{device_name}'", conn=conn)
|
|
conn.commit()
|
|
# Invalidate cache
|
|
invalidate_cache_for_device(device_id)
|
|
cache.clear('devices')
|
|
return redirect(url_for('device', device_id=device_id))
|
|
|
|
@app.route('/delete_device', methods=['POST'])
|
|
@permission_required('delete_device')
|
|
def delete_device():
|
|
device_id = request.form['device_id']
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
subnet_ids_to_invalidate = set()
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device_row = cursor.fetchone()
|
|
if device_row:
|
|
device_name = device_row[0]
|
|
add_audit_log(session['user_id'], 'delete_device', f"Deleted device {device_name}", conn=conn)
|
|
# Get subnet IDs for all IPs assigned to this device before deleting
|
|
cursor.execute('''
|
|
SELECT DISTINCT ip.subnet_id
|
|
FROM DeviceIPAddress dia
|
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
|
WHERE dia.device_id = %s
|
|
''', (device_id,))
|
|
subnet_ids_to_invalidate = {row[0] for row in cursor.fetchall()}
|
|
|
|
cursor.execute('SELECT ip_id FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
|
|
ip_ids = [row[0] for row in cursor.fetchall()]
|
|
if ip_ids:
|
|
cursor.executemany('UPDATE IPAddress SET hostname = NULL WHERE id = %s', [(ip_id,) for ip_id in ip_ids])
|
|
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
|
|
cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,))
|
|
conn.commit()
|
|
# Invalidate cache
|
|
invalidate_cache_for_device(device_id)
|
|
cache.clear('devices')
|
|
# Invalidate subnet caches for all subnets that had IPs assigned to this device
|
|
for subnet_id in subnet_ids_to_invalidate:
|
|
cache.invalidate_subnet(subnet_id)
|
|
logging.info(f"User {user_name} deleted device '{device_name}'.")
|
|
return redirect(url_for('devices'))
|
|
|
|
@app.route('/subnet/<int:subnet_id>')
|
|
@permission_required('view_subnet')
|
|
def subnet(subnet_id):
|
|
cache_key = f'subnet:{subnet_id}'
|
|
cached_result = cache.get(cache_key)
|
|
if cached_result is not None:
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
custom_fields = get_custom_fields_for_entity('subnet', subnet_id, conn=conn)
|
|
# Ensure VLAN fields are in cached subnet dict
|
|
subnet_dict = cached_result['subnet']
|
|
if 'vlan_id' not in subnet_dict:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
|
|
vlan_row = cursor.fetchone()
|
|
if vlan_row:
|
|
subnet_dict['vlan_id'] = vlan_row[0]
|
|
subnet_dict['vlan_description'] = vlan_row[1]
|
|
subnet_dict['vlan_notes'] = vlan_row[2]
|
|
# Check if IP address notes feature is enabled
|
|
ip_notes_enabled = is_feature_enabled('ip_address_notes', conn=conn)
|
|
return render_with_user('subnet.html', subnet=subnet_dict,
|
|
ip_addresses=cached_result['ip_addresses'],
|
|
utilization=cached_result['utilization'],
|
|
custom_fields=custom_fields,
|
|
can_edit_subnet=has_permission('edit_subnet'),
|
|
ip_notes_enabled=ip_notes_enabled)
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name, cidr, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
ip_addresses = cursor.fetchall()
|
|
|
|
# Calculate utilization stats
|
|
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
total_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
WHERE ip.subnet_id = %s AND ip.id IN (SELECT ip_id FROM DeviceIPAddress)
|
|
''', (subnet_id,))
|
|
assigned_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND ip.id NOT IN (SELECT ip_id FROM DeviceIPAddress)
|
|
''', (subnet_id,))
|
|
dhcp_ips = cursor.fetchone()[0]
|
|
|
|
available_ips = total_ips - assigned_ips - dhcp_ips
|
|
used_ips = assigned_ips + dhcp_ips
|
|
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
|
|
|
|
utilization_stats = {
|
|
'total': total_ips,
|
|
'assigned': assigned_ips,
|
|
'dhcp': dhcp_ips,
|
|
'available': available_ips,
|
|
'percent': round(utilization_percent, 1)
|
|
}
|
|
|
|
# Get custom fields for subnet
|
|
custom_fields = get_custom_fields_for_entity('subnet', subnet_id, conn=conn)
|
|
|
|
cursor.execute('SELECT id, name, description FROM Device')
|
|
devices = cursor.fetchall()
|
|
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
|
|
ip_addresses_with_device = []
|
|
for ip in ip_addresses:
|
|
ip_id = ip[0]
|
|
ip_address = ip[1]
|
|
hostname = ip[2]
|
|
ip_notes = ip[3] if len(ip) > 3 else None
|
|
device_id = None
|
|
device_description = None
|
|
if hostname:
|
|
match = device_name_map.get(hostname.lower())
|
|
if match:
|
|
device_id, device_description = match
|
|
ip_addresses_with_device.append((ip_id, ip_address, hostname, device_id, device_description, ip_notes))
|
|
|
|
subnet_dict = {
|
|
'id': subnet[0],
|
|
'name': subnet[1],
|
|
'cidr': subnet[2],
|
|
'vlan_id': subnet[3] if len(subnet) > 3 else None,
|
|
'vlan_description': subnet[4] if len(subnet) > 4 else None,
|
|
'vlan_notes': subnet[5] if len(subnet) > 5 else None
|
|
}
|
|
result = {
|
|
'subnet': subnet_dict,
|
|
'ip_addresses': ip_addresses_with_device,
|
|
'utilization': utilization_stats
|
|
}
|
|
# Cache for 3 hours
|
|
cache.set(cache_key, result, ttl=10800)
|
|
|
|
# Check if IP address notes feature is enabled
|
|
ip_notes_enabled = is_feature_enabled('ip_address_notes', conn=conn)
|
|
|
|
return render_with_user('subnet.html', subnet=subnet_dict,
|
|
ip_addresses=ip_addresses_with_device,
|
|
utilization=utilization_stats,
|
|
custom_fields=custom_fields,
|
|
can_edit_subnet=has_permission('edit_subnet'),
|
|
ip_notes_enabled=ip_notes_enabled)
|
|
|
|
@app.route('/add_subnet', methods=['POST'])
|
|
@permission_required('add_subnet')
|
|
def add_subnet():
|
|
name = request.form['name']
|
|
cidr = request.form['cidr']
|
|
site = request.form['site']
|
|
vlan_id_str = request.form.get('vlan_id', '').strip()
|
|
vlan_description = request.form.get('vlan_description', '').strip()
|
|
vlan_notes = request.form.get('vlan_notes', '').strip()
|
|
user_name = get_current_user_name()
|
|
|
|
# Validate VLAN ID if provided
|
|
if vlan_id_str:
|
|
is_valid, error_msg, vlan_id = validate_vlan_id(vlan_id_str)
|
|
if not is_valid:
|
|
return render_with_user('admin.html', subnets=[], error=error_msg)
|
|
else:
|
|
vlan_id = None
|
|
|
|
try:
|
|
network = ip_network(cidr, strict=False)
|
|
if network.prefixlen < 24:
|
|
return render_with_user('admin.html', subnets=[], error='Subnet must be /24 or smaller (e.g., /24, /25, ... /32)')
|
|
except Exception as e:
|
|
return render_with_user('admin.html', subnets=[], error='Invalid CIDR format.')
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT INTO Subnet (name, cidr, site, vlan_id, vlan_description, vlan_notes) VALUES (%s, %s, %s, %s, %s, %s)',
|
|
(name, cidr, site, vlan_id, vlan_description if vlan_description else None, vlan_notes if vlan_notes else None))
|
|
subnet_id = cursor.lastrowid
|
|
ip_rows = [(str(ip), subnet_id) for ip in network.hosts()]
|
|
cursor.executemany('INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)', ip_rows)
|
|
vlan_info = f" (VLAN {vlan_id})" if vlan_id else ""
|
|
add_audit_log(session['user_id'], 'add_subnet', f"Added subnet {name} ({cidr}){vlan_info}", subnet_id, conn=conn)
|
|
conn.commit()
|
|
# Invalidate cache
|
|
cache.clear('index')
|
|
cache.clear('admin')
|
|
cache.clear('subnet_list')
|
|
# Note: subnet_id is new, so no need to invalidate specific subnet cache
|
|
logging.info(f"User {user_name} added subnet '{name}' ({cidr}) at site '{site}'.")
|
|
return redirect(url_for('admin'))
|
|
|
|
@app.route('/edit_subnet', methods=['POST'])
|
|
@permission_required('edit_subnet')
|
|
def edit_subnet():
|
|
subnet_id = request.form['subnet_id']
|
|
name = request.form['name']
|
|
cidr = request.form['cidr']
|
|
site = request.form['site']
|
|
vlan_id_str = request.form.get('vlan_id', '').strip()
|
|
vlan_description = request.form.get('vlan_description', '').strip()
|
|
vlan_notes = request.form.get('vlan_notes', '').strip()
|
|
user_name = get_current_user_name()
|
|
|
|
# Validate VLAN ID if provided
|
|
if vlan_id_str:
|
|
is_valid, error_msg, vlan_id = validate_vlan_id(vlan_id_str)
|
|
if not is_valid:
|
|
return render_with_user('admin.html', subnets=[], error=error_msg)
|
|
else:
|
|
vlan_id = None
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
old_subnet = cursor.fetchone()
|
|
if old_subnet:
|
|
old_name, old_cidr = old_subnet
|
|
cursor.execute('UPDATE Subnet SET name = %s, cidr = %s, site = %s, vlan_id = %s, vlan_description = %s, vlan_notes = %s WHERE id = %s',
|
|
(name, cidr, site, vlan_id, vlan_description if vlan_description else None, vlan_notes if vlan_notes else None, subnet_id))
|
|
vlan_info = f" (VLAN {vlan_id})" if vlan_id else ""
|
|
add_audit_log(session['user_id'], 'edit_subnet', f"Edited subnet from {old_name} ({old_cidr}) to {name} ({cidr}) at site {site}{vlan_info}", subnet_id, conn=conn)
|
|
conn.commit()
|
|
# Invalidate cache
|
|
invalidate_cache_for_subnet(subnet_id)
|
|
logging.info(f"User {user_name} edited subnet {subnet_id}.")
|
|
return redirect(url_for('admin'))
|
|
|
|
@app.route('/delete_subnet', methods=['POST'])
|
|
@permission_required('delete_subnet')
|
|
def delete_subnet():
|
|
subnet_id = request.form['subnet_id']
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
add_audit_log(session['user_id'], 'delete_subnet', f"Deleted subnet {subnet[0]} ({subnet[1]})", subnet_id, conn=conn)
|
|
cursor.execute('SELECT id FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
ip_ids = [row[0] for row in cursor.fetchall()]
|
|
if ip_ids:
|
|
cursor.executemany('DELETE FROM DeviceIPAddress WHERE ip_id = %s', [(ip_id,) for ip_id in ip_ids])
|
|
# Set subnet_id to NULL in audit logs (foreign key will handle this, but doing it explicitly for clarity)
|
|
cursor.execute('UPDATE AuditLog SET subnet_id=NULL WHERE subnet_id = %s', (subnet_id,))
|
|
cursor.execute('DELETE FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
cursor.execute('DELETE FROM Subnet WHERE id = %s', (subnet_id,))
|
|
conn.commit()
|
|
# Invalidate cache
|
|
invalidate_cache_for_subnet(subnet_id)
|
|
logging.info(f"User {user_name} deleted subnet {subnet_id}.")
|
|
return redirect(url_for('admin'))
|
|
|
|
@app.route('/admin', methods=['GET', 'POST'])
|
|
@permission_required('view_admin')
|
|
def admin():
|
|
cache_key = 'admin'
|
|
cached_result = cache.get(cache_key)
|
|
|
|
# Check if cached data has VLAN fields (for backward compatibility)
|
|
if cached_result is not None:
|
|
# Verify cached subnets have VLAN fields, if not, refresh cache
|
|
if cached_result.get('subnets') and len(cached_result['subnets']) > 0:
|
|
sample_subnet = cached_result['subnets'][0]
|
|
if 'vlan_id' not in sample_subnet:
|
|
# Cache is stale, clear it and regenerate
|
|
cache.clear(cache_key)
|
|
cached_result = None
|
|
|
|
if cached_result is not None:
|
|
# Always fetch feature flags fresh (they might have changed)
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT feature_key, enabled, description FROM FeatureFlags ORDER BY feature_key')
|
|
feature_flags = []
|
|
for row in cursor.fetchall():
|
|
feature_flags.append({
|
|
'key': row[0],
|
|
'enabled': bool(row[1]),
|
|
'description': row[2]
|
|
})
|
|
cached_result['feature_flags'] = feature_flags
|
|
return render_with_user('admin.html', **cached_result)
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet ORDER BY site, name')
|
|
subnet_rows = cursor.fetchall()
|
|
subnets = []
|
|
for row in subnet_rows:
|
|
subnet_id = row[0]
|
|
# Calculate utilization for each subnet
|
|
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
total_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
INNER JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
WHERE ip.subnet_id = %s
|
|
''', (subnet_id,))
|
|
assigned_ips = cursor.fetchone()[0]
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM IPAddress ip
|
|
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND dia.ip_id IS NULL
|
|
''', (subnet_id,))
|
|
dhcp_ips = cursor.fetchone()[0]
|
|
|
|
available_ips = total_ips - assigned_ips - dhcp_ips
|
|
used_ips = assigned_ips + dhcp_ips
|
|
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
|
|
|
|
subnets.append({
|
|
'id': row[0],
|
|
'name': row[1],
|
|
'cidr': row[2],
|
|
'site': row[3] or 'Unassigned',
|
|
'vlan_id': row[4] if len(row) > 4 and row[4] is not None else None,
|
|
'vlan_description': row[5] if len(row) > 5 and row[5] is not None else None,
|
|
'vlan_notes': row[6] if len(row) > 6 and row[6] is not None else None,
|
|
'utilization': {
|
|
'percent': round(utilization_percent, 1),
|
|
'assigned': assigned_ips,
|
|
'used': used_ips,
|
|
'total': total_ips
|
|
}
|
|
})
|
|
|
|
# Get feature flags (inside the connection context)
|
|
cursor.execute('SELECT feature_key, enabled, description FROM FeatureFlags ORDER BY feature_key')
|
|
feature_flags = []
|
|
for row in cursor.fetchall():
|
|
feature_flags.append({
|
|
'key': row[0],
|
|
'enabled': bool(row[1]),
|
|
'description': row[2]
|
|
})
|
|
|
|
result_data = {
|
|
'subnets': subnets,
|
|
'can_add_subnet': has_permission('add_subnet'),
|
|
'can_edit_subnet': has_permission('edit_subnet'),
|
|
'can_delete_subnet': has_permission('delete_subnet'),
|
|
'feature_flags': feature_flags
|
|
}
|
|
# Cache for 3 hours
|
|
cache.set(cache_key, result_data, ttl=10800)
|
|
|
|
return render_with_user('admin.html', **result_data)
|
|
|
|
@app.route('/admin/feature_flags', methods=['POST'])
|
|
@permission_required('manage_users')
|
|
def update_feature_flags():
|
|
"""Update feature flags"""
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
# Get all feature flags
|
|
cursor.execute('SELECT feature_key FROM FeatureFlags')
|
|
all_features = [row[0] for row in cursor.fetchall()]
|
|
|
|
# Update each feature flag based on form data
|
|
for feature_key in all_features:
|
|
enabled = request.form.get(f'feature_{feature_key}') == 'on'
|
|
cursor.execute('UPDATE FeatureFlags SET enabled = %s WHERE feature_key = %s',
|
|
(enabled, feature_key))
|
|
logging.info(f"User {user_name} {'enabled' if enabled else 'disabled'} feature '{feature_key}'")
|
|
|
|
conn.commit()
|
|
|
|
# Clear admin cache to refresh feature flags
|
|
cache.clear('admin')
|
|
|
|
return redirect(url_for('admin'))
|
|
|
|
@app.route('/api-docs')
|
|
@permission_required('view_admin')
|
|
def api_docs():
|
|
# Get current user's API key
|
|
from flask import current_app
|
|
api_key = None
|
|
if 'user_id' in session:
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT api_key FROM User WHERE id = %s', (session['user_id'],))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
api_key = result[0]
|
|
return render_with_user('api_docs.html', api_key=api_key)
|
|
|
|
@app.route('/account', methods=['GET'])
|
|
@login_required
|
|
def account_settings():
|
|
from totp_utils import format_backup_codes
|
|
from flask import current_app
|
|
import json
|
|
|
|
user_id = session.get('user_id')
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT u.totp_enabled, u.backup_codes, r.require_2fa
|
|
FROM User u
|
|
LEFT JOIN Role r ON u.role_id = r.id
|
|
WHERE u.id = %s
|
|
''', (user_id,))
|
|
result = cursor.fetchone()
|
|
totp_enabled = result[0] if result else False
|
|
backup_codes_json = result[1] if result else None
|
|
role_requires_2fa = result[2] if result else False
|
|
|
|
backup_codes = None
|
|
if backup_codes_json:
|
|
try:
|
|
codes = json.loads(backup_codes_json)
|
|
backup_codes = format_backup_codes(codes)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
return render_with_user('account_settings.html',
|
|
totp_enabled=totp_enabled,
|
|
backup_codes=backup_codes,
|
|
role_requires_2fa=role_requires_2fa)
|
|
|
|
@app.route('/account/enable-2fa', methods=['GET', 'POST'])
|
|
@login_required
|
|
def enable_2fa():
|
|
from totp_utils import generate_totp_secret, get_totp_uri, generate_qr_code, verify_totp, generate_backup_codes, format_backup_codes
|
|
from flask import current_app
|
|
import json
|
|
|
|
user_id = session.get('user_id')
|
|
|
|
if request.method == 'POST':
|
|
action = request.form.get('action')
|
|
|
|
if action == 'generate':
|
|
secret = generate_totp_secret()
|
|
session['temp_totp_secret'] = secret
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT email FROM User WHERE id = %s', (user_id,))
|
|
email = cursor.fetchone()[0]
|
|
|
|
totp_uri = get_totp_uri(secret, email)
|
|
qr_code = generate_qr_code(totp_uri)
|
|
return render_with_user('enable_2fa.html', secret=secret, qr_code=qr_code, email=email, step='verify')
|
|
|
|
elif action == 'verify':
|
|
code = request.form.get('code', '').strip()
|
|
secret = session.get('temp_totp_secret')
|
|
|
|
if not secret:
|
|
return render_with_user('enable_2fa.html', error='Session expired. Please start over.', step='generate')
|
|
|
|
if verify_totp(secret, code):
|
|
backup_codes = generate_backup_codes()
|
|
backup_codes_json = json.dumps(backup_codes)
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
UPDATE User
|
|
SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s, two_fa_setup_complete = TRUE
|
|
WHERE id = %s
|
|
''', (secret, backup_codes_json, user_id))
|
|
|
|
session.pop('temp_totp_secret', None)
|
|
formatted_codes = format_backup_codes(backup_codes)
|
|
logging.info(f"User {user_id} enabled 2FA.")
|
|
return render_with_user('enable_2fa.html', backup_codes=formatted_codes, step='backup_codes')
|
|
else:
|
|
return render_with_user('enable_2fa.html', error='Invalid code. Please try again.', secret=secret, step='verify')
|
|
|
|
return render_with_user('enable_2fa.html', step='generate')
|
|
|
|
@app.route('/account/disable-2fa', methods=['POST'])
|
|
@login_required
|
|
def disable_2fa():
|
|
from flask import current_app
|
|
|
|
user_id = session.get('user_id')
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
UPDATE User
|
|
SET totp_secret = NULL, totp_enabled = FALSE, backup_codes = NULL, two_fa_setup_complete = FALSE
|
|
WHERE id = %s
|
|
''', (user_id,))
|
|
|
|
logging.info(f"User {user_id} disabled 2FA.")
|
|
return redirect(url_for('account_settings', success='2FA has been disabled.'))
|
|
|
|
@app.route('/account/regenerate-backup-codes', methods=['POST'])
|
|
@login_required
|
|
def regenerate_backup_codes():
|
|
from totp_utils import generate_backup_codes, format_backup_codes
|
|
from flask import current_app
|
|
import json
|
|
|
|
user_id = session.get('user_id')
|
|
backup_codes = generate_backup_codes()
|
|
backup_codes_json = json.dumps(backup_codes)
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s', (backup_codes_json, user_id))
|
|
|
|
formatted_codes = format_backup_codes(backup_codes)
|
|
logging.info(f"User {user_id} regenerated backup codes.")
|
|
return render_with_user('regenerate_backup_codes.html', backup_codes=formatted_codes)
|
|
|
|
@app.route('/account/change-password', methods=['POST'])
|
|
@login_required
|
|
def change_password():
|
|
from flask import current_app
|
|
|
|
user_id = session.get('user_id')
|
|
current_password = request.form.get('current_password')
|
|
new_password = request.form.get('new_password')
|
|
confirm_password = request.form.get('confirm_password')
|
|
|
|
if new_password != confirm_password:
|
|
return redirect(url_for('account_settings', error='New passwords do not match.'))
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT password FROM User WHERE id = %s', (user_id,))
|
|
result = cursor.fetchone()
|
|
if not result or not verify_password(current_password, result[0]):
|
|
return redirect(url_for('account_settings', error='Current password is incorrect.'))
|
|
|
|
hashed_password = hash_password(new_password)
|
|
cursor.execute('UPDATE User SET password = %s WHERE id = %s', (hashed_password, user_id))
|
|
|
|
logging.info(f"User {user_id} changed password.")
|
|
return redirect(url_for('account_settings', success='Password changed successfully.'))
|
|
|
|
@app.route('/users', methods=['GET', 'POST'])
|
|
@permission_required('view_users')
|
|
def users():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
error = None
|
|
if request.method == 'POST':
|
|
action = request.form['action']
|
|
user_name = get_current_user_name()
|
|
|
|
# User management actions
|
|
if action == 'add_user':
|
|
if not has_permission('manage_users', conn=conn):
|
|
error = 'You do not have permission to add users.'
|
|
else:
|
|
name = request.form['name']
|
|
email = request.form['email']
|
|
password = hash_password(request.form['password'])
|
|
role_id = request.form.get('role_id')
|
|
if role_id:
|
|
api_key = generate_api_key()
|
|
cursor.execute('INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)', (name, email, password, role_id, api_key))
|
|
else:
|
|
api_key = generate_api_key()
|
|
cursor.execute('INSERT INTO User (name, email, password, api_key) VALUES (%s, %s, %s, %s)', (name, email, password, api_key))
|
|
logging.info(f"User {user_name} added user '{name}' ({email}).")
|
|
conn.commit()
|
|
elif action == 'edit_user':
|
|
if not has_permission('manage_users', conn=conn):
|
|
error = 'You do not have permission to edit users.'
|
|
else:
|
|
user_id = request.form['user_id']
|
|
name = request.form['name']
|
|
email = request.form['email']
|
|
password = request.form.get('password', '')
|
|
role_id = request.form.get('role_id')
|
|
if password:
|
|
password = hash_password(password)
|
|
if role_id:
|
|
cursor.execute('UPDATE User SET name=%s, email=%s, password=%s, role_id=%s WHERE id=%s', (name, email, password, role_id, user_id))
|
|
else:
|
|
cursor.execute('UPDATE User SET name=%s, email=%s, password=%s WHERE id=%s', (name, email, password, user_id))
|
|
else:
|
|
if role_id:
|
|
cursor.execute('UPDATE User SET name=%s, email=%s, role_id=%s WHERE id=%s', (name, email, role_id, user_id))
|
|
else:
|
|
cursor.execute('UPDATE User SET name=%s, email=%s WHERE id=%s', (name, email, user_id))
|
|
logging.info(f"User {user_name} edited user {user_id}.")
|
|
conn.commit()
|
|
elif action == 'delete_user':
|
|
if not has_permission('manage_users', conn=conn):
|
|
error = 'You do not have permission to delete users.'
|
|
else:
|
|
user_id = request.form['user_id']
|
|
cursor.execute('UPDATE User SET name=%s WHERE id=%s', ('Deleted User', user_id))
|
|
cursor.execute('UPDATE AuditLog SET user_id=NULL WHERE user_id=%s', (user_id,))
|
|
cursor.execute('DELETE FROM User WHERE id=%s', (user_id,))
|
|
logging.info(f"User {user_name} deleted user {user_id}.")
|
|
conn.commit()
|
|
|
|
# Role management actions
|
|
elif action == 'add_role':
|
|
if not has_permission('manage_roles', conn=conn):
|
|
error = 'You do not have permission to add roles.'
|
|
else:
|
|
role_name = request.form['role_name'].strip()
|
|
role_description = request.form.get('role_description', '').strip()
|
|
require_2fa = request.form.get('require_2fa') == 'on'
|
|
if not role_name:
|
|
error = 'Role name is required.'
|
|
else:
|
|
try:
|
|
cursor.execute('INSERT INTO Role (name, description, require_2fa) VALUES (%s, %s, %s)', (role_name, role_description, require_2fa))
|
|
role_id = cursor.lastrowid
|
|
# Get selected permissions
|
|
permission_ids = request.form.getlist('permissions')
|
|
for perm_id in permission_ids:
|
|
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)', (role_id, perm_id))
|
|
conn.commit()
|
|
logging.info(f"User {user_name} added role '{role_name}'.")
|
|
except mysql.connector.IntegrityError as e:
|
|
if e.errno == 1062: # Duplicate entry
|
|
error = f"Role '{role_name}' already exists."
|
|
else:
|
|
raise
|
|
elif action == 'edit_role':
|
|
if not has_permission('manage_roles', conn=conn):
|
|
error = 'You do not have permission to edit roles.'
|
|
else:
|
|
role_id = request.form['role_id']
|
|
role_name = request.form['role_name'].strip()
|
|
role_description = request.form.get('role_description', '').strip()
|
|
require_2fa = request.form.get('require_2fa') == 'on'
|
|
if not role_name:
|
|
error = 'Role name is required.'
|
|
else:
|
|
try:
|
|
cursor.execute('UPDATE Role SET name=%s, description=%s, require_2fa=%s WHERE id=%s', (role_name, role_description, require_2fa, role_id))
|
|
# Update permissions
|
|
cursor.execute('DELETE FROM RolePermission WHERE role_id=%s', (role_id,))
|
|
permission_ids = request.form.getlist('permissions')
|
|
for perm_id in permission_ids:
|
|
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)', (role_id, perm_id))
|
|
conn.commit()
|
|
logging.info(f"User {user_name} edited role {role_id}.")
|
|
except mysql.connector.IntegrityError as e:
|
|
if e.errno == 1062: # Duplicate entry
|
|
error = f"Role '{role_name}' already exists."
|
|
else:
|
|
raise
|
|
elif action == 'delete_role':
|
|
if not has_permission('manage_roles', conn=conn):
|
|
error = 'You do not have permission to delete roles.'
|
|
else:
|
|
role_id = request.form['role_id']
|
|
# Check if any users are using this role
|
|
cursor.execute('SELECT COUNT(*) FROM User WHERE role_id = %s', (role_id,))
|
|
user_count = cursor.fetchone()[0]
|
|
if user_count > 0:
|
|
cursor.execute('SELECT name FROM Role WHERE id = %s', (role_id,))
|
|
role_name = cursor.fetchone()[0]
|
|
error = f"Cannot delete role '{role_name}' because {user_count} user(s) are using it."
|
|
else:
|
|
cursor.execute('SELECT name FROM Role WHERE id = %s', (role_id,))
|
|
role_name = cursor.fetchone()[0]
|
|
cursor.execute('DELETE FROM Role WHERE id = %s', (role_id,))
|
|
conn.commit()
|
|
logging.info(f"User {user_name} deleted role '{role_name}'.")
|
|
elif action == 'regenerate_api_key':
|
|
if not has_permission('manage_users', conn=conn):
|
|
error = 'You do not have permission to regenerate API keys.'
|
|
else:
|
|
user_id = request.form['user_id']
|
|
new_api_key = generate_api_key()
|
|
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (new_api_key, user_id))
|
|
conn.commit()
|
|
logging.info(f"User {user_name} regenerated API key for user {user_id}.")
|
|
|
|
# Get users with their roles and API keys
|
|
cursor.execute('''
|
|
SELECT u.id, u.name, u.email, r.id as role_id, r.name as role_name, u.api_key
|
|
FROM User u
|
|
LEFT JOIN Role r ON u.role_id = r.id
|
|
ORDER BY u.name
|
|
''')
|
|
users = cursor.fetchall()
|
|
|
|
# Get all roles
|
|
cursor.execute('SELECT id, name, description, require_2fa FROM Role ORDER BY name')
|
|
roles = cursor.fetchall()
|
|
|
|
# Get all permissions grouped by category
|
|
cursor.execute('SELECT id, name, description, category FROM Permission ORDER BY category, name')
|
|
permissions = cursor.fetchall()
|
|
|
|
# Get permissions for each role
|
|
role_permissions = {}
|
|
for role in roles:
|
|
role_id = role[0]
|
|
cursor.execute('''
|
|
SELECT permission_id FROM RolePermission WHERE role_id = %s
|
|
''', (role_id,))
|
|
role_permissions[role_id] = [row[0] for row in cursor.fetchall()]
|
|
|
|
return render_with_user('users.html', users=users, roles=roles, permissions=permissions, role_permissions=role_permissions, error=error,
|
|
can_manage_users=has_permission('manage_users'), can_manage_roles=has_permission('manage_roles'))
|
|
|
|
@app.route('/tags', methods=['GET', 'POST'])
|
|
@permission_required('view_tags')
|
|
def tags():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor()
|
|
error = None
|
|
|
|
if request.method == 'POST':
|
|
action = request.form['action']
|
|
|
|
if action == 'add_tag':
|
|
if not has_permission('add_tag', conn=conn):
|
|
error = 'You do not have permission to add tags.'
|
|
else:
|
|
name = request.form['name'].strip()
|
|
color = request.form.get('color', '#6B7280')
|
|
description = request.form.get('description', '').strip()
|
|
|
|
if not name:
|
|
error = 'Tag name is required.'
|
|
else:
|
|
try:
|
|
cursor.execute('INSERT INTO Tag (name, color, description) VALUES (%s, %s, %s)',
|
|
(name, color, description))
|
|
add_audit_log(session['user_id'], 'add_tag', f"Added tag '{name}'", conn=conn)
|
|
conn.commit()
|
|
# Invalidate device caches since they contain tags
|
|
cache.clear('device:')
|
|
cache.clear('devices')
|
|
except mysql.connector.IntegrityError:
|
|
error = 'Tag name already exists.'
|
|
|
|
elif action == 'edit_tag':
|
|
if not has_permission('edit_tag', conn=conn):
|
|
error = 'You do not have permission to edit tags.'
|
|
else:
|
|
tag_id = request.form['tag_id']
|
|
name = request.form['name'].strip()
|
|
color = request.form.get('color', '#6B7280')
|
|
description = request.form.get('description', '').strip()
|
|
|
|
if not name:
|
|
error = 'Tag name is required.'
|
|
else:
|
|
try:
|
|
cursor.execute('UPDATE Tag SET name = %s, color = %s, description = %s WHERE id = %s',
|
|
(name, color, description, tag_id))
|
|
add_audit_log(session['user_id'], 'edit_tag', f"Updated tag '{name}'", conn=conn)
|
|
conn.commit()
|
|
# Invalidate device caches since they contain tags
|
|
cache.clear('device:')
|
|
cache.clear('devices')
|
|
except mysql.connector.IntegrityError:
|
|
error = 'Tag name already exists.'
|
|
|
|
elif action == 'delete_tag':
|
|
if not has_permission('delete_tag', conn=conn):
|
|
error = 'You do not have permission to delete tags.'
|
|
else:
|
|
tag_id = request.form['tag_id']
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag_name = cursor.fetchone()[0]
|
|
cursor.execute('DELETE FROM Tag WHERE id = %s', (tag_id,))
|
|
add_audit_log(session['user_id'], 'delete_tag', f"Deleted tag '{tag_name}'", conn=conn)
|
|
conn.commit()
|
|
# Invalidate device caches since they contain tags
|
|
cache.clear('device:')
|
|
cache.clear('devices')
|
|
|
|
# Get all tags with device counts
|
|
cursor.execute('''
|
|
SELECT t.id, t.name, t.color, t.description, t.created_at,
|
|
COUNT(dt.device_id) as device_count
|
|
FROM Tag t
|
|
LEFT JOIN DeviceTag dt ON t.id = dt.tag_id
|
|
GROUP BY t.id, t.name, t.color, t.description, t.created_at
|
|
ORDER BY t.name
|
|
''')
|
|
tags = [dict(id=row[0], name=row[1], color=row[2], description=row[3],
|
|
created_at=row[4], device_count=row[5]) for row in cursor.fetchall()]
|
|
|
|
return render_with_user('tags.html', tags=tags, error=error,
|
|
can_add_tag=has_permission('add_tag'),
|
|
can_edit_tag=has_permission('edit_tag'),
|
|
can_delete_tag=has_permission('delete_tag'))
|
|
|
|
@app.route('/custom_fields', methods=['GET', 'POST'])
|
|
@permission_required('view_custom_fields')
|
|
def custom_fields():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
error = None
|
|
|
|
if request.method == 'POST':
|
|
action = request.form.get('action')
|
|
|
|
if action == 'add_field':
|
|
if not has_permission('manage_custom_fields', conn=conn):
|
|
error = 'You do not have permission to add custom fields.'
|
|
else:
|
|
entity_type = request.form.get('entity_type', '').strip()
|
|
# Debug logging
|
|
logging.info(f"Received entity_type: '{entity_type}' (type: {type(entity_type)})")
|
|
logging.info(f"Form data keys: {list(request.form.keys())}")
|
|
if not entity_type or entity_type not in ['device', 'subnet']:
|
|
# Try to get from form data directly
|
|
entity_type_raw = request.form.get('entity_type')
|
|
logging.error(f"Invalid entity_type received: '{entity_type}' (raw: '{entity_type_raw}')")
|
|
# Show more helpful error
|
|
error = f'Invalid entity type: "{entity_type}". Must be "device" or "subnet". Please try again.'
|
|
else:
|
|
name = request.form['name'].strip()
|
|
field_key = request.form.get('field_key', '').strip()
|
|
field_type = request.form['field_type']
|
|
required = 'required' in request.form
|
|
default_value = request.form.get('default_value', '').strip()
|
|
help_text = request.form.get('help_text', '').strip()
|
|
display_order = int(request.form.get('display_order', 0))
|
|
searchable = 'searchable' in request.form
|
|
|
|
# Generate field_key from name if not provided
|
|
if not field_key:
|
|
field_key = re.sub(r'[^a-z0-9_]+', '_', name.lower()).strip('_')
|
|
|
|
# Build validation_rules JSON
|
|
validation_rules = {}
|
|
if field_type in ['text', 'textarea']:
|
|
if request.form.get('min_length'):
|
|
validation_rules['min_length'] = int(request.form['min_length'])
|
|
if request.form.get('max_length'):
|
|
validation_rules['max_length'] = int(request.form['max_length'])
|
|
if request.form.get('regex_pattern'):
|
|
validation_rules['regex_pattern'] = request.form['regex_pattern']
|
|
elif field_type in ['number', 'decimal']:
|
|
if request.form.get('min_value'):
|
|
validation_rules['min_value'] = float(request.form['min_value'])
|
|
if request.form.get('max_value'):
|
|
validation_rules['max_value'] = float(request.form['max_value'])
|
|
elif field_type == 'select':
|
|
options = request.form.get('select_options', '').strip()
|
|
if options:
|
|
validation_rules['select_options'] = [opt.strip() for opt in options.split(',') if opt.strip()]
|
|
|
|
validation_rules_json = json.dumps(validation_rules) if validation_rules else None
|
|
|
|
if not name:
|
|
error = 'Field name is required.'
|
|
elif not field_key:
|
|
error = 'Field key is required.'
|
|
else:
|
|
try:
|
|
cursor.execute('''
|
|
INSERT INTO CustomFieldDefinition
|
|
(entity_type, name, field_key, field_type, required, default_value,
|
|
help_text, display_order, validation_rules, searchable)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
''', (entity_type, name, field_key, field_type, required, default_value,
|
|
help_text, display_order, validation_rules_json, searchable))
|
|
add_audit_log(session['user_id'], 'add_custom_field',
|
|
f"Added custom field '{name}' for {entity_type}", conn=conn)
|
|
conn.commit()
|
|
# Redirect to preserve tab state
|
|
return redirect(url_for('custom_fields', tab=entity_type))
|
|
except mysql.connector.IntegrityError:
|
|
error = f'Field key "{field_key}" already exists.'
|
|
|
|
elif action == 'edit_field':
|
|
if not has_permission('manage_custom_fields', conn=conn):
|
|
error = 'You do not have permission to edit custom fields.'
|
|
else:
|
|
field_id = request.form['field_id']
|
|
name = request.form['name'].strip()
|
|
field_type = request.form['field_type']
|
|
required = 'required' in request.form
|
|
default_value = request.form.get('default_value', '').strip()
|
|
help_text = request.form.get('help_text', '').strip()
|
|
display_order = int(request.form.get('display_order', 0))
|
|
searchable = 'searchable' in request.form
|
|
|
|
# Build validation_rules JSON
|
|
validation_rules = {}
|
|
if field_type in ['text', 'textarea']:
|
|
if request.form.get('min_length'):
|
|
validation_rules['min_length'] = int(request.form['min_length'])
|
|
if request.form.get('max_length'):
|
|
validation_rules['max_length'] = int(request.form['max_length'])
|
|
if request.form.get('regex_pattern'):
|
|
validation_rules['regex_pattern'] = request.form['regex_pattern']
|
|
elif field_type in ['number', 'decimal']:
|
|
if request.form.get('min_value'):
|
|
validation_rules['min_value'] = float(request.form['min_value'])
|
|
if request.form.get('max_value'):
|
|
validation_rules['max_value'] = float(request.form['max_value'])
|
|
elif field_type == 'select':
|
|
options = request.form.get('select_options', '').strip()
|
|
if options:
|
|
validation_rules['select_options'] = [opt.strip() for opt in options.split(',') if opt.strip()]
|
|
|
|
validation_rules_json = json.dumps(validation_rules) if validation_rules else None
|
|
|
|
if not name:
|
|
error = 'Field name is required.'
|
|
else:
|
|
# Get entity_type of the field being edited
|
|
cursor.execute('SELECT entity_type FROM CustomFieldDefinition WHERE id = %s', (field_id,))
|
|
field_row = cursor.fetchone()
|
|
entity_type = field_row['entity_type'] if field_row else 'device'
|
|
|
|
cursor.execute('''
|
|
UPDATE CustomFieldDefinition
|
|
SET name = %s, field_type = %s, required = %s, default_value = %s,
|
|
help_text = %s, display_order = %s, validation_rules = %s, searchable = %s
|
|
WHERE id = %s
|
|
''', (name, field_type, required, default_value, help_text,
|
|
display_order, validation_rules_json, searchable, field_id))
|
|
add_audit_log(session['user_id'], 'edit_custom_field',
|
|
f"Updated custom field '{name}'", conn=conn)
|
|
conn.commit()
|
|
# Redirect to preserve tab state
|
|
return redirect(url_for('custom_fields', tab=entity_type))
|
|
|
|
elif action == 'delete_field':
|
|
if not has_permission('manage_custom_fields', conn=conn):
|
|
error = 'You do not have permission to delete custom fields.'
|
|
else:
|
|
field_id = request.form['field_id']
|
|
cursor.execute('SELECT name, entity_type FROM CustomFieldDefinition WHERE id = %s', (field_id,))
|
|
field = cursor.fetchone()
|
|
if field:
|
|
field_name = field['name']
|
|
entity_type = field['entity_type']
|
|
cursor.execute('DELETE FROM CustomFieldDefinition WHERE id = %s', (field_id,))
|
|
add_audit_log(session['user_id'], 'delete_custom_field',
|
|
f"Deleted custom field '{field_name}'", conn=conn)
|
|
conn.commit()
|
|
# Redirect to preserve tab state
|
|
return redirect(url_for('custom_fields', tab=entity_type))
|
|
|
|
elif action == 'reorder':
|
|
if not has_permission('manage_custom_fields', conn=conn):
|
|
error = 'You do not have permission to reorder custom fields.'
|
|
else:
|
|
entity_type = request.form['entity_type']
|
|
field_orders = json.loads(request.form['field_orders'])
|
|
for field_id, order in field_orders.items():
|
|
cursor.execute('UPDATE CustomFieldDefinition SET display_order = %s WHERE id = %s AND entity_type = %s',
|
|
(order, field_id, entity_type))
|
|
conn.commit()
|
|
# Redirect to preserve tab state
|
|
return redirect(url_for('custom_fields', tab=entity_type))
|
|
# Redirect to preserve tab state
|
|
return redirect(url_for('custom_fields', tab=entity_type))
|
|
|
|
# Get all custom fields grouped by entity type
|
|
cursor.execute('''
|
|
SELECT id, entity_type, name, field_key, field_type, required,
|
|
default_value, help_text, display_order, validation_rules, searchable
|
|
FROM CustomFieldDefinition
|
|
ORDER BY entity_type, display_order, name
|
|
''')
|
|
all_fields = cursor.fetchall()
|
|
|
|
# Parse validation_rules JSON strings to objects
|
|
for field in all_fields:
|
|
if field['validation_rules']:
|
|
try:
|
|
field['validation_rules'] = json.loads(field['validation_rules'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
field['validation_rules'] = {}
|
|
else:
|
|
field['validation_rules'] = {}
|
|
|
|
device_fields = [f for f in all_fields if f['entity_type'] == 'device']
|
|
subnet_fields = [f for f in all_fields if f['entity_type'] == 'subnet']
|
|
|
|
# Get active tab from query parameter
|
|
active_tab = request.args.get('tab', 'device')
|
|
if active_tab not in ['device', 'subnet']:
|
|
active_tab = 'device'
|
|
|
|
return render_with_user('custom_fields.html',
|
|
device_fields=device_fields,
|
|
subnet_fields=subnet_fields,
|
|
error=error,
|
|
can_manage=has_permission('manage_custom_fields'),
|
|
active_tab=active_tab)
|
|
|
|
@app.route('/custom_fields/<entity_type>')
|
|
@permission_required('view_custom_fields')
|
|
def custom_fields_by_type(entity_type):
|
|
"""Get custom fields for a specific entity type"""
|
|
if entity_type not in ['device', 'subnet']:
|
|
abort(404)
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('''
|
|
SELECT id, entity_type, name, field_key, field_type, required,
|
|
default_value, help_text, display_order, validation_rules, searchable
|
|
FROM CustomFieldDefinition
|
|
WHERE entity_type = %s
|
|
ORDER BY display_order, name
|
|
''', (entity_type,))
|
|
fields = cursor.fetchall()
|
|
|
|
return jsonify({'fields': fields})
|
|
|
|
@app.route('/audit')
|
|
@permission_required('view_audit')
|
|
def audit():
|
|
PER_PAGE = 25
|
|
page = int(request.args.get('page', 1))
|
|
offset = (page - 1) * PER_PAGE
|
|
|
|
# Get filter parameters
|
|
user_ids = request.args.getlist('user_ids') # Multiple users
|
|
subnet_id = request.args.get('subnet_id')
|
|
action = request.args.get('action')
|
|
device_name = request.args.get('device_name')
|
|
date_from = request.args.get('date_from')
|
|
date_to = request.args.get('date_to')
|
|
search_query = request.args.get('search', '').strip()
|
|
|
|
query = '''SELECT AuditLog.id, COALESCE(User.name, 'Deleted User'), AuditLog.action, AuditLog.details, Subnet.name, AuditLog.timestamp FROM AuditLog LEFT JOIN User ON AuditLog.user_id = User.id LEFT JOIN Subnet ON AuditLog.subnet_id = Subnet.id WHERE 1=1'''
|
|
params = []
|
|
|
|
# Multiple user filtering
|
|
if user_ids:
|
|
placeholders = ','.join(['%s'] * len(user_ids))
|
|
query += f' AND AuditLog.user_id IN ({placeholders})'
|
|
params.extend(user_ids)
|
|
|
|
if subnet_id:
|
|
query += ' AND AuditLog.subnet_id = %s'
|
|
params.append(subnet_id)
|
|
|
|
if action:
|
|
query += ' AND AuditLog.action = %s'
|
|
params.append(action)
|
|
|
|
if device_name:
|
|
query += ' AND AuditLog.details LIKE %s'
|
|
params.append(f'%{device_name}%')
|
|
|
|
# Date range filtering
|
|
if date_from:
|
|
query += ' AND AuditLog.timestamp >= %s'
|
|
params.append(date_from)
|
|
|
|
if date_to:
|
|
query += ' AND AuditLog.timestamp <= %s'
|
|
params.append(date_to + ' 23:59:59')
|
|
|
|
# Search query (searches in details, user name, action, subnet name)
|
|
if search_query:
|
|
query += ' AND (AuditLog.details LIKE %s OR COALESCE(User.name, \'\') LIKE %s OR AuditLog.action LIKE %s OR COALESCE(Subnet.name, \'\') LIKE %s)'
|
|
search_pattern = f'%{search_query}%'
|
|
params.extend([search_pattern, search_pattern, search_pattern, search_pattern])
|
|
|
|
count_query = 'SELECT COUNT(*) FROM (' + query + ') AS count_subquery'
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(buffered=True)
|
|
cursor.execute(count_query, params)
|
|
total_logs = cursor.fetchone()[0]
|
|
query += ' ORDER BY AuditLog.timestamp DESC LIMIT %s OFFSET %s'
|
|
cursor.execute(query, params + [PER_PAGE, offset])
|
|
logs = cursor.fetchall()
|
|
cursor.execute('SELECT id, name FROM User ORDER BY name')
|
|
users = cursor.fetchall()
|
|
cursor.execute('SELECT id, name FROM Subnet ORDER BY name')
|
|
subnets = cursor.fetchall()
|
|
cursor.execute('SELECT DISTINCT action FROM AuditLog ORDER BY action')
|
|
actions = [row[0] for row in cursor.fetchall()]
|
|
cursor.execute('SELECT name FROM Device ORDER BY name')
|
|
devices = cursor.fetchall()
|
|
query_args = request.args.to_dict()
|
|
total_pages = (total_logs + PER_PAGE - 1) // PER_PAGE
|
|
return render_with_user('audit.html', logs=logs, users=users, subnets=subnets, actions=actions, devices=devices, page=page, total_pages=total_pages, query_args=query_args, selected_user_ids=user_ids, date_from=date_from, date_to=date_to, search_query=search_query)
|
|
|
|
@app.route('/audit/export_csv')
|
|
@permission_required('view_audit')
|
|
def export_audit_csv():
|
|
"""Export audit logs to CSV with current filters applied"""
|
|
# Get filter parameters (same as audit route)
|
|
user_ids = request.args.getlist('user_ids')
|
|
subnet_id = request.args.get('subnet_id')
|
|
action = request.args.get('action')
|
|
device_name = request.args.get('device_name')
|
|
date_from = request.args.get('date_from')
|
|
date_to = request.args.get('date_to')
|
|
search_query = request.args.get('search', '').strip()
|
|
|
|
query = '''SELECT COALESCE(User.name, 'Deleted User'), AuditLog.action, AuditLog.details, COALESCE(Subnet.name, 'N/A'), AuditLog.timestamp FROM AuditLog LEFT JOIN User ON AuditLog.user_id = User.id LEFT JOIN Subnet ON AuditLog.subnet_id = Subnet.id WHERE 1=1'''
|
|
params = []
|
|
|
|
# Apply same filters as audit route
|
|
if user_ids:
|
|
placeholders = ','.join(['%s'] * len(user_ids))
|
|
query += f' AND AuditLog.user_id IN ({placeholders})'
|
|
params.extend(user_ids)
|
|
|
|
if subnet_id:
|
|
query += ' AND AuditLog.subnet_id = %s'
|
|
params.append(subnet_id)
|
|
|
|
if action:
|
|
query += ' AND AuditLog.action = %s'
|
|
params.append(action)
|
|
|
|
if device_name:
|
|
query += ' AND AuditLog.details LIKE %s'
|
|
params.append(f'%{device_name}%')
|
|
|
|
if date_from:
|
|
query += ' AND AuditLog.timestamp >= %s'
|
|
params.append(date_from)
|
|
|
|
if date_to:
|
|
query += ' AND AuditLog.timestamp <= %s'
|
|
params.append(date_to + ' 23:59:59')
|
|
|
|
if search_query:
|
|
query += ' AND (AuditLog.details LIKE %s OR COALESCE(User.name, \'\') LIKE %s OR AuditLog.action LIKE %s OR COALESCE(Subnet.name, \'\') LIKE %s)'
|
|
search_pattern = f'%{search_query}%'
|
|
params.extend([search_pattern, search_pattern, search_pattern, search_pattern])
|
|
|
|
query += ' ORDER BY AuditLog.timestamp DESC'
|
|
|
|
with get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(query, params)
|
|
logs = cursor.fetchall()
|
|
|
|
# Create CSV
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(['User', 'Action', 'Details', 'Subnet', 'Timestamp'])
|
|
|
|
for log in logs:
|
|
writer.writerow(log)
|
|
|
|
csv_bytes = output.getvalue().encode('utf-8')
|
|
output_bytes = BytesIO(csv_bytes)
|
|
|
|
from datetime import datetime
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
filename = f'audit_logs_{timestamp}.csv'
|
|
|
|
return send_file(
|
|
output_bytes,
|
|
mimetype='text/csv',
|
|
as_attachment=True,
|
|
download_name=filename
|
|
)
|
|
|
|
@app.route('/check_update')
|
|
@login_required
|
|
def check_update():
|
|
"""Check for available updates from GitHub (cached for 3 hours)"""
|
|
cache_key = 'check_update'
|
|
|
|
# Check cache first
|
|
cached_result = cache.get(cache_key)
|
|
if cached_result is not None:
|
|
return jsonify(cached_result)
|
|
|
|
try:
|
|
# Get current version
|
|
version_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'VERSION')
|
|
current_version = 'unknown'
|
|
if os.path.exists(version_file):
|
|
with open(version_file, 'r') as f:
|
|
current_version = f.read().strip()
|
|
|
|
# Fetch latest release from GitHub
|
|
response = requests.get('https://api.github.com/repos/JDB-NET/ipam/releases/latest', timeout=5)
|
|
if response.status_code != 200:
|
|
return jsonify({'error': 'Failed to fetch release information'}), 500
|
|
|
|
release_data = response.json()
|
|
latest_version = release_data.get('tag_name', '').lstrip('v')
|
|
|
|
# Compare versions using semantic versioning
|
|
result = {'update_available': False}
|
|
if latest_version and latest_version != current_version:
|
|
# Simple semantic version comparison
|
|
def version_tuple(v):
|
|
"""Convert version string to tuple for comparison"""
|
|
parts = v.split('.')
|
|
return tuple(int(x) if x.isdigit() else 0 for x in parts[:3])
|
|
|
|
try:
|
|
current_tuple = version_tuple(current_version)
|
|
latest_tuple = version_tuple(latest_version)
|
|
# Only show update if latest is actually newer
|
|
if latest_tuple > current_tuple:
|
|
result = {
|
|
'update_available': True,
|
|
'current_version': current_version,
|
|
'latest_version': latest_version,
|
|
'release_url': release_data.get('html_url', '')
|
|
}
|
|
except (ValueError, AttributeError):
|
|
# Fallback to string comparison if parsing fails
|
|
if latest_version != current_version:
|
|
result = {
|
|
'update_available': True,
|
|
'current_version': current_version,
|
|
'latest_version': latest_version,
|
|
'release_url': release_data.get('html_url', '')
|
|
}
|
|
|
|
# Cache result for 3 hours (10800 seconds)
|
|
cache.set(cache_key, result, ttl=10800)
|
|
return jsonify(result)
|
|
|
|
except requests.RequestException as e:
|
|
logging.error(f"Error checking for updates: {e}")
|
|
return jsonify({'error': 'Failed to check for updates'}), 500
|
|
except Exception as e:
|
|
logging.error(f"Unexpected error checking for updates: {e}")
|
|
return jsonify({'error': 'Failed to check for updates'}), 500
|
|
|
|
@app.route('/backup')
|
|
@permission_required('view_admin')
|
|
def backup():
|
|
"""Backup and restore page"""
|
|
from flask import current_app
|
|
|
|
# Ensure backups directory exists
|
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
|
os.makedirs(backups_dir, exist_ok=True)
|
|
|
|
# List available backups
|
|
backups = []
|
|
if os.path.exists(backups_dir):
|
|
for filename in os.listdir(backups_dir):
|
|
if filename.endswith('.sql'):
|
|
filepath = os.path.join(backups_dir, filename)
|
|
file_stat = os.stat(filepath)
|
|
backups.append({
|
|
'filename': filename,
|
|
'size': file_stat.st_size,
|
|
'created': datetime.fromtimestamp(file_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
|
})
|
|
|
|
# Sort by creation time (newest first)
|
|
backups.sort(key=lambda x: x['created'], reverse=True)
|
|
|
|
return render_with_user('backup.html', backups=backups)
|
|
|
|
@app.route('/backup/create', methods=['POST'])
|
|
@permission_required('view_admin')
|
|
def create_backup():
|
|
"""Create a database backup"""
|
|
from flask import current_app
|
|
|
|
try:
|
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
|
os.makedirs(backups_dir, exist_ok=True)
|
|
|
|
# Generate backup filename with timestamp
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
filename = f'ipam_backup_{timestamp}.sql'
|
|
filepath = os.path.join(backups_dir, filename)
|
|
|
|
# Get database configuration
|
|
db_host = current_app.config['MYSQL_HOST']
|
|
db_user = current_app.config['MYSQL_USER']
|
|
db_password = current_app.config['MYSQL_PASSWORD']
|
|
db_name = current_app.config['MYSQL_DATABASE']
|
|
|
|
# Create backup using mysqldump
|
|
cmd = [
|
|
'mysqldump',
|
|
f'--host={db_host}',
|
|
f'--user={db_user}',
|
|
f'--password={db_password}',
|
|
'--skip-ssl',
|
|
'--single-transaction',
|
|
'--routines',
|
|
'--triggers',
|
|
db_name
|
|
]
|
|
|
|
with open(filepath, 'w') as f:
|
|
result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True)
|
|
|
|
if result.returncode != 0:
|
|
os.remove(filepath)
|
|
return jsonify({'error': f'Backup failed: {result.stderr}'}), 500
|
|
|
|
# Log the backup creation
|
|
with get_db_connection(current_app) as conn:
|
|
add_audit_log(session.get('user_id'), 'create_backup', f'Created backup: {filename}', conn=conn)
|
|
|
|
return jsonify({'success': True, 'filename': filename, 'message': 'Backup created successfully'})
|
|
|
|
except Exception as e:
|
|
logging.error(f"Error creating backup: {e}")
|
|
return jsonify({'error': f'Failed to create backup: {str(e)}'}), 500
|
|
|
|
@app.route('/backup/download/<filename>')
|
|
@permission_required('view_admin')
|
|
def download_backup(filename):
|
|
"""Download a backup file"""
|
|
from flask import current_app
|
|
|
|
# Security: ensure filename is safe
|
|
filename = secure_filename(filename)
|
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
|
filepath = os.path.join(backups_dir, filename)
|
|
|
|
if not os.path.exists(filepath) or not filename.endswith('.sql'):
|
|
abort(404)
|
|
|
|
return send_file(filepath, as_attachment=True, download_name=filename)
|
|
|
|
@app.route('/backup/restore', methods=['POST'])
|
|
@permission_required('view_admin')
|
|
def restore_backup():
|
|
"""Restore database from backup"""
|
|
from flask import current_app
|
|
|
|
try:
|
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
|
os.makedirs(backups_dir, exist_ok=True)
|
|
|
|
# Check if file was uploaded or if using existing file
|
|
if 'backup_file' in request.files:
|
|
# Handle file upload
|
|
file = request.files['backup_file']
|
|
if file.filename == '':
|
|
return jsonify({'error': 'No file selected'}), 400
|
|
|
|
if not file.filename.endswith('.sql'):
|
|
return jsonify({'error': 'Invalid file type. Only .sql files are allowed'}), 400
|
|
|
|
# Save uploaded file
|
|
filename = secure_filename(file.filename)
|
|
filepath = os.path.join(backups_dir, filename)
|
|
file.save(filepath)
|
|
|
|
elif 'backup_filename' in request.form:
|
|
# Use existing backup file
|
|
filename = secure_filename(request.form['backup_filename'])
|
|
filepath = os.path.join(backups_dir, filename)
|
|
|
|
if not os.path.exists(filepath):
|
|
return jsonify({'error': 'Backup file not found'}), 404
|
|
else:
|
|
return jsonify({'error': 'No backup file specified'}), 400
|
|
|
|
# Get database configuration
|
|
db_host = current_app.config['MYSQL_HOST']
|
|
db_user = current_app.config['MYSQL_USER']
|
|
db_password = current_app.config['MYSQL_PASSWORD']
|
|
db_name = current_app.config['MYSQL_DATABASE']
|
|
|
|
# Close any existing database connections before restore
|
|
# This is important to avoid connection conflicts during restore
|
|
try:
|
|
# Try to close any open connections
|
|
pass
|
|
except:
|
|
pass
|
|
|
|
# Restore database using mysql command
|
|
cmd = [
|
|
'mysql',
|
|
f'--host={db_host}',
|
|
f'--user={db_user}',
|
|
f'--password={db_password}',
|
|
'--skip-ssl',
|
|
db_name
|
|
]
|
|
|
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
result = subprocess.run(cmd, stdin=f, stderr=subprocess.PIPE, text=True)
|
|
|
|
if result.returncode != 0:
|
|
return jsonify({'error': f'Restore failed: {result.stderr}'}), 500
|
|
|
|
# Log the restore
|
|
with get_db_connection(current_app) as conn:
|
|
add_audit_log(session.get('user_id'), 'restore_backup', f'Restored backup: {filename}', conn=conn)
|
|
|
|
return jsonify({'success': True, 'message': 'Database restored successfully'})
|
|
|
|
except Exception as e:
|
|
logging.error(f"Error restoring backup: {e}")
|
|
return jsonify({'error': f'Failed to restore backup: {str(e)}'}), 500
|
|
|
|
@app.route('/backup/delete/<filename>', methods=['POST'])
|
|
@permission_required('view_admin')
|
|
def delete_backup(filename):
|
|
"""Delete a backup file"""
|
|
from flask import current_app
|
|
|
|
# Security: ensure filename is safe
|
|
filename = secure_filename(filename)
|
|
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
|
|
filepath = os.path.join(backups_dir, filename)
|
|
|
|
if not os.path.exists(filepath) or not filename.endswith('.sql'):
|
|
return jsonify({'error': 'Backup file not found'}), 404
|
|
|
|
try:
|
|
os.remove(filepath)
|
|
|
|
# Log the deletion
|
|
with get_db_connection(current_app) as conn:
|
|
add_audit_log(session.get('user_id'), 'delete_backup', f'Deleted backup: {filename}', conn=conn)
|
|
|
|
return jsonify({'success': True, 'message': 'Backup deleted successfully'})
|
|
except Exception as e:
|
|
logging.error(f"Error deleting backup: {e}")
|
|
return jsonify({'error': f'Failed to delete backup: {str(e)}'}), 500
|
|
|
|
@app.route('/get_available_ips')
|
|
@permission_required('view_device')
|
|
def get_available_ips():
|
|
subnet_id = request.args.get('subnet_id')
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip FROM IPAddress ip
|
|
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
WHERE ip.subnet_id = %s AND dia.ip_id IS NULL AND (ip.hostname IS NULL OR ip.hostname != 'DHCP')
|
|
''', (subnet_id,))
|
|
available_ips = cursor.fetchall()
|
|
|
|
# Filter out DHCP pool IPs
|
|
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
dhcp_row = cursor.fetchone()
|
|
if dhcp_row:
|
|
start_ip, end_ip, excluded_ips = dhcp_row
|
|
excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x]
|
|
in_range = False
|
|
filtered_ips = []
|
|
for ip_obj in available_ips:
|
|
ip = ip_obj[1]
|
|
if ip == start_ip:
|
|
in_range = True
|
|
if ip in excluded_list or not (in_range and ip not in excluded_list):
|
|
filtered_ips.append(ip_obj)
|
|
if ip == end_ip:
|
|
in_range = False
|
|
available_ips = filtered_ips
|
|
|
|
available_ips = [{'id': row[0], 'ip': row[1]} for row in available_ips]
|
|
return {'available_ips': available_ips}
|
|
|
|
@app.route('/rename_device', methods=['POST'])
|
|
@permission_required('edit_device')
|
|
def rename_device():
|
|
device_id = request.form['device_id']
|
|
new_name = request.form['new_name']
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
old_name = cursor.fetchone()[0]
|
|
cursor.execute('UPDATE Device SET name = %s WHERE id = %s', (new_name, device_id))
|
|
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE hostname = %s', (new_name, old_name))
|
|
conn.commit()
|
|
add_audit_log(session['user_id'], 'rename_device', f"Renamed device '{old_name}' to '{new_name}'", conn=conn)
|
|
# Invalidate cache
|
|
invalidate_cache_for_device(device_id)
|
|
cache.clear('subnet:') # Invalidate all subnet caches since hostnames changed
|
|
logging.info(f"User {user_name} renamed device {device_id} from '{old_name}' to '{new_name}'.")
|
|
return redirect(url_for('device', device_id=device_id))
|
|
|
|
@app.route('/update_device_description', methods=['POST'])
|
|
@permission_required('edit_device')
|
|
def update_device_description():
|
|
device_id = request.form['device_id']
|
|
description = request.form['description']
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('UPDATE Device SET description = %s WHERE id = %s', (description, device_id))
|
|
conn.commit()
|
|
# Invalidate cache
|
|
invalidate_cache_for_device(device_id)
|
|
logging.info(f"User {user_name} updated description for device {device_id}.")
|
|
return redirect(url_for('device', device_id=device_id))
|
|
|
|
@app.route('/ip/<int:ip_id>/update_notes', methods=['POST'])
|
|
@permission_required('edit_subnet')
|
|
def update_ip_notes(ip_id):
|
|
from flask import jsonify
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
|
|
# Get notes from request (can be JSON or form data)
|
|
if request.is_json:
|
|
notes = request.json.get('notes', '')
|
|
else:
|
|
notes = request.form.get('notes', '')
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
# Get subnet_id for cache invalidation and audit log
|
|
cursor.execute('SELECT subnet_id, ip FROM IPAddress WHERE id = %s', (ip_id,))
|
|
ip_result = cursor.fetchone()
|
|
if not ip_result:
|
|
return jsonify({'success': False, 'error': 'IP address not found'}), 404
|
|
|
|
subnet_id, ip_address = ip_result
|
|
|
|
# Update notes
|
|
cursor.execute('UPDATE IPAddress SET notes = %s WHERE id = %s', (notes, ip_id))
|
|
conn.commit()
|
|
|
|
# Add audit log
|
|
add_audit_log(
|
|
session['user_id'],
|
|
'update_ip_notes',
|
|
f"Updated notes for IP {ip_address}",
|
|
subnet_id,
|
|
conn=conn
|
|
)
|
|
|
|
# Invalidate subnet cache
|
|
invalidate_cache_for_subnet(subnet_id)
|
|
|
|
logging.info(f"User {user_name} updated notes for IP {ip_address} (ID: {ip_id}).")
|
|
return jsonify({'success': True, 'message': 'Notes updated successfully'})
|
|
|
|
@app.route('/device/<int:device_id>/update_custom_fields', methods=['POST'])
|
|
@permission_required('edit_device')
|
|
def update_device_custom_fields(device_id):
|
|
"""Update custom field values for a device"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
|
|
# Get all field definitions for devices
|
|
cursor.execute('''
|
|
SELECT id, field_key, field_type, required, validation_rules
|
|
FROM CustomFieldDefinition
|
|
WHERE entity_type = 'device'
|
|
''')
|
|
field_defs = {f['field_key']: f for f in cursor.fetchall()}
|
|
|
|
# Get current custom fields
|
|
cursor.execute('SELECT custom_fields FROM Device WHERE id = %s', (device_id,))
|
|
result = cursor.fetchone()
|
|
current_values = {}
|
|
if result and result.get('custom_fields'):
|
|
try:
|
|
current_values = json.loads(result['custom_fields'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
current_values = {}
|
|
|
|
# Process submitted values
|
|
new_values = {}
|
|
errors = []
|
|
|
|
for field_key, field_def in field_defs.items():
|
|
submitted_value = request.form.get(f'custom_field_{field_key}', '')
|
|
|
|
# Parse validation rules
|
|
validation_rules = field_def.get('validation_rules')
|
|
if isinstance(validation_rules, str):
|
|
try:
|
|
validation_rules = json.loads(validation_rules)
|
|
except json.JSONDecodeError:
|
|
validation_rules = {}
|
|
elif validation_rules is None:
|
|
validation_rules = {}
|
|
field_def['validation_rules'] = validation_rules
|
|
|
|
# Validate value
|
|
if submitted_value == '' and not field_def.get('required'):
|
|
# Optional field left empty - remove from values
|
|
continue
|
|
|
|
is_valid, error_msg = validate_custom_field_value(field_def, submitted_value)
|
|
if not is_valid:
|
|
errors.append(error_msg)
|
|
else:
|
|
parsed_value = parse_custom_field_value(field_def['field_type'], submitted_value)
|
|
if parsed_value is not None:
|
|
new_values[field_key] = parsed_value
|
|
|
|
if errors:
|
|
return jsonify({'error': 'Validation errors', 'errors': errors}), 400
|
|
|
|
# Update custom_fields JSON
|
|
custom_fields_json = json.dumps(new_values)
|
|
cursor.execute('UPDATE Device SET custom_fields = %s WHERE id = %s', (custom_fields_json, device_id))
|
|
add_audit_log(session['user_id'], 'update_device_custom_fields',
|
|
f"Updated custom fields for device {device_id}", conn=conn)
|
|
conn.commit()
|
|
invalidate_cache_for_device(device_id)
|
|
|
|
if request.headers.get('Content-Type') == 'application/json':
|
|
return jsonify({'success': True, 'message': 'Custom fields updated successfully'})
|
|
return redirect(url_for('device', device_id=device_id))
|
|
|
|
@app.route('/subnet/<int:subnet_id>/update_custom_fields', methods=['POST'])
|
|
@permission_required('edit_subnet')
|
|
def update_subnet_custom_fields(subnet_id):
|
|
"""Update custom field values for a subnet"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
|
|
# Get all field definitions for subnets
|
|
cursor.execute('''
|
|
SELECT id, field_key, field_type, required, validation_rules
|
|
FROM CustomFieldDefinition
|
|
WHERE entity_type = 'subnet'
|
|
''')
|
|
field_defs = {f['field_key']: f for f in cursor.fetchall()}
|
|
|
|
# Get current custom fields
|
|
cursor.execute('SELECT custom_fields FROM Subnet WHERE id = %s', (subnet_id,))
|
|
result = cursor.fetchone()
|
|
current_values = {}
|
|
if result and result.get('custom_fields'):
|
|
try:
|
|
current_values = json.loads(result['custom_fields'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
current_values = {}
|
|
|
|
# Process submitted values
|
|
new_values = {}
|
|
errors = []
|
|
|
|
# Handle both form data and JSON requests
|
|
if request.is_json:
|
|
submitted_data = request.json
|
|
else:
|
|
submitted_data = request.form
|
|
|
|
for field_key, field_def in field_defs.items():
|
|
if request.is_json:
|
|
submitted_value = submitted_data.get(f'custom_field_{field_key}', '')
|
|
else:
|
|
submitted_value = submitted_data.get(f'custom_field_{field_key}', '')
|
|
|
|
# Parse validation rules
|
|
validation_rules = field_def.get('validation_rules')
|
|
if isinstance(validation_rules, str):
|
|
try:
|
|
validation_rules = json.loads(validation_rules)
|
|
except json.JSONDecodeError:
|
|
validation_rules = {}
|
|
elif validation_rules is None:
|
|
validation_rules = {}
|
|
field_def['validation_rules'] = validation_rules
|
|
|
|
# Validate value
|
|
if submitted_value == '' and not field_def.get('required'):
|
|
# Optional field left empty - remove from values
|
|
continue
|
|
|
|
is_valid, error_msg = validate_custom_field_value(field_def, submitted_value)
|
|
if not is_valid:
|
|
errors.append(error_msg)
|
|
else:
|
|
parsed_value = parse_custom_field_value(field_def['field_type'], submitted_value)
|
|
if parsed_value is not None:
|
|
new_values[field_key] = parsed_value
|
|
|
|
if errors:
|
|
return jsonify({'error': 'Validation errors', 'errors': errors}), 400
|
|
|
|
# Update custom_fields JSON
|
|
custom_fields_json = json.dumps(new_values)
|
|
cursor.execute('UPDATE Subnet SET custom_fields = %s WHERE id = %s', (custom_fields_json, subnet_id))
|
|
add_audit_log(session['user_id'], 'update_subnet_custom_fields',
|
|
f"Updated custom fields for subnet {subnet_id}", conn=conn)
|
|
conn.commit()
|
|
invalidate_cache_for_subnet(subnet_id)
|
|
|
|
if request.is_json or request.headers.get('Content-Type') == 'application/json':
|
|
return jsonify({'success': True, 'message': 'Custom fields updated successfully'})
|
|
return redirect(url_for('subnet', subnet_id=subnet_id))
|
|
|
|
@app.route('/subnet/<int:subnet_id>/export_csv')
|
|
@permission_required('export_subnet_csv')
|
|
def export_subnet_csv(subnet_id):
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
if not subnet:
|
|
return 'Subnet not found', 404
|
|
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
ip_addresses = cursor.fetchall()
|
|
cursor.execute('SELECT id, name, description FROM Device')
|
|
devices = cursor.fetchall()
|
|
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
|
|
ip_addresses_with_device = []
|
|
for ip in ip_addresses:
|
|
ip_id = ip[0]
|
|
ip_address = ip[1]
|
|
hostname = ip[2]
|
|
ip_notes = ip[3] if len(ip) > 3 else None
|
|
device_id = None
|
|
device_description = None
|
|
if hostname:
|
|
match = device_name_map.get(hostname.lower())
|
|
if match:
|
|
device_id, device_description = match
|
|
ip_addresses_with_device.append((ip_id, ip_address, hostname, device_id, device_description, ip_notes))
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(['IP Address', 'Hostname', 'Description'])
|
|
for ip in ip_addresses_with_device:
|
|
ip_addr = ip[1] or ''
|
|
hostname = ip[2] or ''
|
|
device_desc = ip[4] or ''
|
|
ip_notes = ip[5] if len(ip) > 5 and ip[5] else ''
|
|
# Combine device description and IP notes
|
|
combined_desc = ''
|
|
if device_desc:
|
|
combined_desc = device_desc
|
|
if ip_notes:
|
|
if combined_desc:
|
|
combined_desc = combined_desc + '\n' + ip_notes
|
|
else:
|
|
combined_desc = ip_notes
|
|
writer.writerow([ip_addr, hostname, combined_desc])
|
|
csv_bytes = output.getvalue().encode('utf-8')
|
|
output_bytes = BytesIO(csv_bytes)
|
|
output_bytes.seek(0)
|
|
filename = f"{subnet[1]}_{subnet[2]}_subnet.csv".replace(' ', '_')
|
|
return send_file(
|
|
output_bytes,
|
|
mimetype='text/csv',
|
|
as_attachment=True,
|
|
download_name=filename
|
|
)
|
|
|
|
@app.route('/subnet/<int:subnet_id>/dhcp', methods=['GET', 'POST'])
|
|
@permission_required('view_dhcp')
|
|
def dhcp_pool(subnet_id):
|
|
error = None
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
dhcp_pool = None
|
|
cursor.execute('''SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s''', (subnet_id,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
dhcp_pool = {'start_ip': row[0], 'end_ip': row[1], 'excluded_ips': row[2] if len(row) > 2 else ''}
|
|
if request.method == 'POST':
|
|
if not has_permission('configure_dhcp', conn=conn):
|
|
error = 'You do not have permission to configure DHCP pools.'
|
|
else:
|
|
user_name = get_current_user_name()
|
|
if 'remove' in request.form:
|
|
cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
cursor.execute('UPDATE IPAddress SET hostname=NULL WHERE subnet_id=%s AND hostname="DHCP"', (subnet_id,))
|
|
conn.commit()
|
|
dhcp_pool = None
|
|
add_audit_log(session['user_id'], 'dhcp_pool_remove', f"Removed DHCP pool for subnet {subnet[1]} ({subnet[2]})", subnet_id, conn=conn)
|
|
# Invalidate subnet cache and related caches
|
|
cache.invalidate_subnet(subnet_id)
|
|
cache.clear('index')
|
|
cache.clear('admin')
|
|
else:
|
|
start_ip = request.form['start_ip']
|
|
end_ip = request.form['end_ip']
|
|
excluded_ips = request.form.get('excluded_ips', '').replace(' ', '')
|
|
excluded_list = [ip for ip in excluded_ips.split(',') if ip]
|
|
cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
all_ips = [row[0] for row in cursor.fetchall()]
|
|
if start_ip not in all_ips or end_ip not in all_ips:
|
|
error = 'Start and End IP must be within the subnet.'
|
|
else:
|
|
cursor.execute('UPDATE IPAddress SET hostname=NULL WHERE subnet_id=%s AND hostname="DHCP"', (subnet_id,))
|
|
if dhcp_pool:
|
|
cursor.execute('''UPDATE DHCPPool SET start_ip = %s, end_ip = %s, excluded_ips = %s WHERE subnet_id = %s''', (start_ip, end_ip, excluded_ips, subnet_id))
|
|
action = 'dhcp_pool_update'
|
|
details = f"Updated DHCP pool for subnet {subnet[1]} ({subnet[2]}): {start_ip} - {end_ip}, excluded: {excluded_ips}"
|
|
else:
|
|
cursor.execute('''INSERT INTO DHCPPool (subnet_id, start_ip, end_ip, excluded_ips) VALUES (%s, %s, %s, %s)''', (subnet_id, start_ip, end_ip, excluded_ips))
|
|
action = 'dhcp_pool_create'
|
|
details = f"Created DHCP pool for subnet {subnet[1]} ({subnet[2]}): {start_ip} - {end_ip}, excluded: {excluded_ips}"
|
|
in_range = False
|
|
for ip in all_ips:
|
|
if ip == start_ip:
|
|
in_range = True
|
|
if in_range and ip not in excluded_list:
|
|
cursor.execute('UPDATE IPAddress SET hostname="DHCP" WHERE subnet_id=%s AND ip=%s', (subnet_id, ip))
|
|
if ip == end_ip:
|
|
break
|
|
conn.commit()
|
|
dhcp_pool = {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_ips}
|
|
add_audit_log(session['user_id'], action, details, subnet_id, conn=conn)
|
|
# Invalidate subnet cache and related caches
|
|
cache.invalidate_subnet(subnet_id)
|
|
cache.clear('index')
|
|
cache.clear('admin')
|
|
return render_with_user('dhcp.html', subnet={'id': subnet[0], 'name': subnet[1]}, dhcp_pool=dhcp_pool, error=error)
|
|
|
|
@app.route('/device_type_stats')
|
|
@permission_required('view_device_type_stats')
|
|
def device_type_stats():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT DeviceType.name, DeviceType.icon_class, COUNT(Device.id) as count
|
|
FROM DeviceType
|
|
LEFT JOIN Device ON Device.device_type_id = DeviceType.id
|
|
GROUP BY DeviceType.id, DeviceType.name, DeviceType.icon_class
|
|
ORDER BY DeviceType.name
|
|
''')
|
|
stats = cursor.fetchall()
|
|
return render_with_user('device_type_stats.html', stats=stats)
|
|
|
|
@app.route('/device_types', methods=['GET', 'POST'])
|
|
@permission_required('view_device_types')
|
|
def device_types():
|
|
from flask import current_app
|
|
error = None
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
if request.method == 'POST':
|
|
action = request.form['action']
|
|
user_name = get_current_user_name()
|
|
if action == 'add':
|
|
if not has_permission('add_device_type', conn=conn):
|
|
error = 'You do not have permission to add device types.'
|
|
else:
|
|
name = request.form['name'].strip()
|
|
icon_class = request.form['icon_class'].strip()
|
|
if not name:
|
|
error = 'Device type name is required.'
|
|
elif not icon_class:
|
|
error = 'Icon class is required.'
|
|
else:
|
|
try:
|
|
cursor.execute('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', (name, icon_class))
|
|
conn.commit()
|
|
# Invalidate all device caches since they contain device_types list
|
|
cache.clear('device:')
|
|
cache.clear('devices')
|
|
cache.clear('device_list')
|
|
logging.info(f"User {user_name} added device type '{name}' with icon '{icon_class}'.")
|
|
except mysql.connector.IntegrityError as e:
|
|
if e.errno == 1062: # Duplicate entry
|
|
error = f"Device type '{name}' already exists."
|
|
else:
|
|
raise
|
|
elif action == 'edit':
|
|
if not has_permission('edit_device_type', conn=conn):
|
|
error = 'You do not have permission to edit device types.'
|
|
else:
|
|
device_type_id = request.form['device_type_id']
|
|
name = request.form['name'].strip()
|
|
icon_class = request.form['icon_class'].strip()
|
|
if not name:
|
|
error = 'Device type name is required.'
|
|
elif not icon_class:
|
|
error = 'Icon class is required.'
|
|
else:
|
|
try:
|
|
cursor.execute('UPDATE DeviceType SET name = %s, icon_class = %s WHERE id = %s', (name, icon_class, device_type_id))
|
|
conn.commit()
|
|
cache.clear('device:')
|
|
cache.clear('devices')
|
|
cache.clear('device_list')
|
|
logging.info(f"User {user_name} edited device type {device_type_id} to '{name}' with icon '{icon_class}'.")
|
|
except mysql.connector.IntegrityError as e:
|
|
if e.errno == 1062: # Duplicate entry
|
|
error = f"Device type '{name}' already exists."
|
|
else:
|
|
raise
|
|
elif action == 'delete':
|
|
if not has_permission('delete_device_type', conn=conn):
|
|
error = 'You do not have permission to delete device types.'
|
|
else:
|
|
device_type_id = request.form['device_type_id']
|
|
# Check if any devices are using this device type
|
|
cursor.execute('SELECT COUNT(*) FROM Device WHERE device_type_id = %s', (device_type_id,))
|
|
device_count = cursor.fetchone()[0]
|
|
if device_count > 0:
|
|
cursor.execute('SELECT name FROM DeviceType WHERE id = %s', (device_type_id,))
|
|
device_type_name = cursor.fetchone()[0]
|
|
error = f"Cannot delete device type '{device_type_name}' because {device_count} device(s) are using it."
|
|
else:
|
|
cursor.execute('SELECT name FROM DeviceType WHERE id = %s', (device_type_id,))
|
|
device_type_name = cursor.fetchone()[0]
|
|
cursor.execute('DELETE FROM DeviceType WHERE id = %s', (device_type_id,))
|
|
conn.commit()
|
|
# Invalidate all device caches since they contain device_types list
|
|
cache.clear('device:')
|
|
cache.clear('devices')
|
|
cache.clear('device_list')
|
|
logging.info(f"User {user_name} deleted device type '{device_type_name}'.")
|
|
cursor.execute('SELECT id, name, icon_class FROM DeviceType ORDER BY name')
|
|
device_types = cursor.fetchall()
|
|
return render_with_user('device_types.html', device_types=device_types, error=error)
|
|
|
|
@app.route('/devices/type/<device_type>')
|
|
@permission_required('view_devices_by_type')
|
|
def devices_by_type(device_type):
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, icon_class FROM DeviceType WHERE name = %s', (device_type,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return f"Device type '{device_type}' not found", 404
|
|
device_type_id, icon_class = row
|
|
cursor.execute('''
|
|
SELECT DISTINCT Device.id, Device.name, Device.description, Subnet.site
|
|
FROM Device
|
|
LEFT JOIN DeviceIPAddress ON Device.id = DeviceIPAddress.device_id
|
|
LEFT JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id
|
|
LEFT JOIN Subnet ON IPAddress.subnet_id = Subnet.id
|
|
WHERE Device.device_type_id = %s
|
|
''', (device_type_id,))
|
|
devices = cursor.fetchall()
|
|
seen_ids = set()
|
|
site_devices = {}
|
|
for device_id, name, description, site in devices:
|
|
if device_id in seen_ids:
|
|
continue
|
|
seen_ids.add(device_id)
|
|
site = site or 'Unassigned'
|
|
if site not in site_devices:
|
|
site_devices[site] = []
|
|
site_devices[site].append({'id': device_id, 'name': name, 'description': description})
|
|
return render_with_user('devices_by_type.html', device_type=device_type, icon_class=icon_class, site_devices=site_devices)
|
|
|
|
@app.route('/devices/tag/<int:tag_id>')
|
|
@permission_required('view_devices')
|
|
def devices_by_tag(tag_id):
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name, color FROM Tag WHERE id = %s', (tag_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return f"Tag not found", 404
|
|
tag_id_db, tag_name, tag_color = row
|
|
cursor.execute('''
|
|
SELECT DISTINCT Device.id, Device.name, Device.description, Subnet.site
|
|
FROM Device
|
|
JOIN DeviceTag ON Device.id = DeviceTag.device_id
|
|
LEFT JOIN DeviceIPAddress ON Device.id = DeviceIPAddress.device_id
|
|
LEFT JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id
|
|
LEFT JOIN Subnet ON IPAddress.subnet_id = Subnet.id
|
|
WHERE DeviceTag.tag_id = %s
|
|
''', (tag_id,))
|
|
devices = cursor.fetchall()
|
|
seen_ids = set()
|
|
site_devices = {}
|
|
for device_id, name, description, site in devices:
|
|
if device_id in seen_ids:
|
|
continue
|
|
seen_ids.add(device_id)
|
|
site = site or 'Unassigned'
|
|
if site not in site_devices:
|
|
site_devices[site] = []
|
|
site_devices[site].append({'id': device_id, 'name': name, 'description': description})
|
|
return render_with_user('devices_by_tag.html', tag_name=tag_name, tag_color=tag_color, site_devices=site_devices)
|
|
|
|
@app.route('/racks')
|
|
@permission_required('view_racks')
|
|
def racks():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT * FROM Rack')
|
|
racks = cursor.fetchall()
|
|
rack_ids = [rack['id'] for rack in racks]
|
|
usage = {rack_id: 0 for rack_id in rack_ids}
|
|
if rack_ids:
|
|
format_strings = ','.join(['%s'] * len(rack_ids))
|
|
cursor.execute(f'SELECT rack_id, COUNT(*) as used FROM RackDevice WHERE rack_id IN ({format_strings}) AND side = %s GROUP BY rack_id', tuple(rack_ids) + ('front',))
|
|
for row in cursor.fetchall():
|
|
usage[row['rack_id']] = row['used']
|
|
for rack in racks:
|
|
rack['used_u'] = usage.get(rack['id'], 0)
|
|
rack['percent_full'] = int((rack['used_u'] / rack['height_u']) * 100) if rack['height_u'] else 0
|
|
return render_with_user('racks.html', racks=racks)
|
|
|
|
@app.route('/rack/add', methods=['GET', 'POST'])
|
|
@permission_required('add_rack')
|
|
def add_rack():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
abort(404)
|
|
if request.method == 'POST':
|
|
name = request.form['name']
|
|
site = request.form['site']
|
|
height_u = int(request.form['height_u'])
|
|
user_name = get_current_user_name()
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u))
|
|
rack_id = cursor.lastrowid
|
|
add_audit_log(session['user_id'], 'add_rack', f"Added rack '{name}' at site '{site}' ({height_u}U)", conn=conn)
|
|
conn.commit()
|
|
logging.info(f"User {user_name} added rack '{name}' at site '{site}' ({height_u}U).")
|
|
return redirect(url_for('racks'))
|
|
return render_with_user('add_rack.html')
|
|
|
|
@app.route('/rack/<int:rack_id>')
|
|
@permission_required('view_rack')
|
|
def rack(rack_id):
|
|
from flask import current_app, request
|
|
side = request.args.get('side', 'front')
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT * FROM Rack WHERE id = %s', (rack_id,))
|
|
rack = cursor.fetchone()
|
|
if not rack:
|
|
return 'Rack not found', 404
|
|
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
|
|
rack_devices = cursor.fetchall()
|
|
device_ids = [rd['device_id'] for rd in rack_devices if rd['device_id']]
|
|
device_names = {}
|
|
if device_ids:
|
|
format_strings = ','.join(['%s'] * len(device_ids))
|
|
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
|
|
for row in cursor.fetchall():
|
|
device_names[row['id']] = row['name']
|
|
for rd in rack_devices:
|
|
if rd['device_id']:
|
|
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
|
|
else:
|
|
rd['device_name'] = rd['nonnet_device_name']
|
|
cursor.execute('''
|
|
SELECT DISTINCT Device.id, Device.name, Device.device_type_id, Device.description
|
|
FROM Device
|
|
JOIN DeviceIPAddress ON Device.id = DeviceIPAddress.device_id
|
|
JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id
|
|
JOIN Subnet ON IPAddress.subnet_id = Subnet.id
|
|
WHERE Device.device_type_id NOT IN (2, 6)
|
|
AND Subnet.site = %s
|
|
''', (rack['site'],))
|
|
site_devices = cursor.fetchall()
|
|
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side)
|
|
|
|
@app.route('/rack/<int:rack_id>/add_device', methods=['POST'])
|
|
@permission_required('add_device_to_rack')
|
|
def rack_add_device(rack_id):
|
|
device_id = int(request.form['device_id'])
|
|
position_u = int(request.form['position_u'])
|
|
side = request.form['side']
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT height_u FROM Rack WHERE id = %s', (rack_id,))
|
|
rack = cursor.fetchone()
|
|
if not rack:
|
|
return 'Rack not found', 404
|
|
if position_u < 1 or position_u > rack['height_u']:
|
|
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
|
|
rack_devices = cursor.fetchall()
|
|
device_ids = [rd['device_id'] for rd in rack_devices]
|
|
device_names = {}
|
|
if device_ids:
|
|
format_strings = ','.join(['%s'] * len(device_ids))
|
|
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
|
|
for row in cursor.fetchall():
|
|
device_names[row['id']] = row['name']
|
|
for rd in rack_devices:
|
|
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
|
|
cursor.execute('SELECT id, name, device_type_id FROM Device')
|
|
all_devices = cursor.fetchall()
|
|
site_devices = [d for d in all_devices if d['device_type_id'] not in (2, 6)]
|
|
error = f"Invalid U position: {position_u}. Rack is {rack['height_u']}U tall."
|
|
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side, error=error)
|
|
cursor.execute('SELECT COUNT(*) FROM RackDevice WHERE rack_id = %s AND position_u = %s AND side = %s', (rack_id, position_u, side))
|
|
if cursor.fetchone()['COUNT(*)'] > 0:
|
|
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
|
|
rack_devices = cursor.fetchall()
|
|
device_ids = [rd['device_id'] for rd in rack_devices]
|
|
device_names = {}
|
|
if device_ids:
|
|
format_strings = ','.join(['%s'] * len(device_ids))
|
|
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
|
|
for row in cursor.fetchall():
|
|
device_names[row['id']] = row['name']
|
|
for rd in rack_devices:
|
|
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
|
|
cursor.execute('SELECT id, name, device_type_id FROM Device')
|
|
all_devices = cursor.fetchall()
|
|
site_devices = [d for d in all_devices if d['device_type_id'] not in (2, 6)]
|
|
error = f"U{position_u} on the {side} is already occupied."
|
|
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side, error=error)
|
|
cursor.execute('INSERT INTO RackDevice (rack_id, device_id, position_u, side) VALUES (%s, %s, %s, %s)', (rack_id, device_id, position_u, side))
|
|
cursor2 = conn.cursor()
|
|
cursor2.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device_name = cursor2.fetchone()
|
|
cursor2.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
|
|
rack_name = cursor2.fetchone()
|
|
add_audit_log(session['user_id'], 'rack_add_device', f"Assigned device '{device_name[0] if device_name else device_id}' to rack '{rack_name[0] if rack_name else rack_id}' U{position_u} ({side})", conn=conn)
|
|
conn.commit()
|
|
logging.info(f"User {user_name} assigned device {device_id} to rack {rack_id} at U{position_u} ({side}).")
|
|
return redirect(url_for('rack', rack_id=rack_id))
|
|
|
|
@app.route('/rack/<int:rack_id>/add_nonnet_device', methods=['POST'])
|
|
@permission_required('add_nonnet_device_to_rack')
|
|
def rack_add_nonnet_device(rack_id):
|
|
device_name = request.form['device_name']
|
|
position_u = int(request.form['position_u'])
|
|
side = request.form['side']
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT height_u FROM Rack WHERE id = %s', (rack_id,))
|
|
rack = cursor.fetchone()
|
|
if not rack:
|
|
return 'Rack not found', 404
|
|
if position_u < 1 or position_u > rack['height_u']:
|
|
error = f"Invalid U position: {position_u}. Rack is {rack['height_u']}U tall."
|
|
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
|
|
rack_devices = cursor.fetchall()
|
|
device_ids = [rd['device_id'] for rd in rack_devices if rd['device_id']]
|
|
device_names = {}
|
|
if device_ids:
|
|
format_strings = ','.join(['%s'] * len(device_ids))
|
|
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
|
|
for row in cursor.fetchall():
|
|
device_names[row['id']] = row['name']
|
|
for rd in rack_devices:
|
|
if rd['device_id']:
|
|
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
|
|
else:
|
|
rd['device_name'] = rd['nonnet_device_name']
|
|
cursor.execute('SELECT id, name, device_type_id FROM Device WHERE device_type_id NOT IN (2, 6)')
|
|
site_devices = cursor.fetchall()
|
|
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side, error=error)
|
|
cursor.execute('SELECT COUNT(*) FROM RackDevice WHERE rack_id = %s AND position_u = %s AND side = %s', (rack_id, position_u, side))
|
|
if cursor.fetchone()['COUNT(*)'] > 0:
|
|
error = f"U{position_u} on the {side} is already occupied."
|
|
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
|
|
rack_devices = cursor.fetchall()
|
|
device_ids = [rd['device_id'] for rd in rack_devices if rd['device_id']]
|
|
device_names = {}
|
|
if device_ids:
|
|
format_strings = ','.join(['%s'] * len(device_ids))
|
|
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
|
|
for row in cursor.fetchall():
|
|
device_names[row['id']] = row['name']
|
|
for rd in rack_devices:
|
|
if rd['device_id']:
|
|
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
|
|
else:
|
|
rd['device_name'] = rd['nonnet_device_name']
|
|
cursor.execute('SELECT id, name, device_type_id FROM Device WHERE device_type_id NOT IN (2, 6)')
|
|
site_devices = cursor.fetchall()
|
|
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side, error=error)
|
|
cursor.execute('INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, NULL, %s, %s, %s)', (rack_id, position_u, side, device_name))
|
|
add_audit_log(session['user_id'], 'rack_add_nonnet_device', f"Added non-networked device '{device_name}' to rack '{rack_id}' U{position_u} ({side})", conn=conn)
|
|
conn.commit()
|
|
logging.info(f"User {user_name} added non-networked device '{device_name}' to rack {rack_id} at U{position_u} ({side}).")
|
|
return redirect(url_for('rack', rack_id=rack_id))
|
|
|
|
@app.route('/rack/<int:rack_id>/remove_device', methods=['POST'])
|
|
@permission_required('remove_device_from_rack')
|
|
def rack_remove_device(rack_id):
|
|
rack_device_id = int(request.form['rack_device_id'])
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT device_id, nonnet_device_name, position_u, side FROM RackDevice WHERE id = %s', (rack_device_id,))
|
|
rd = cursor.fetchone()
|
|
if rd['device_id']:
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (rd['device_id'],))
|
|
device_name_row = cursor.fetchone()
|
|
device_label = device_name_row['name'] if device_name_row and 'name' in device_name_row else rd['device_id']
|
|
else:
|
|
device_label = rd['nonnet_device_name']
|
|
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
|
|
rack_name_row = cursor.fetchone()
|
|
rack_label = rack_name_row['name'] if rack_name_row and 'name' in rack_name_row else rack_id
|
|
add_audit_log(session['user_id'], 'rack_remove_device', f"Removed device '{device_label}' from rack '{rack_label}' U{rd['position_u']} ({rd['side']})", conn=conn)
|
|
cursor.execute('DELETE FROM RackDevice WHERE id = %s', (rack_device_id,))
|
|
conn.commit()
|
|
logging.info(f"User {user_name} removed device '{device_label}' from rack {rack_label} at U{rd['position_u']} ({rd['side']}).")
|
|
return redirect(url_for('rack', rack_id=rack_id))
|
|
|
|
@app.route('/rack/<int:rack_id>/delete', methods=['POST'])
|
|
@permission_required('delete_rack')
|
|
def delete_rack(rack_id):
|
|
user_name = get_current_user_name()
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
|
|
rack_name = cursor.fetchone()
|
|
cursor.execute('DELETE FROM Rack WHERE id = %s', (rack_id,))
|
|
add_audit_log(session['user_id'], 'delete_rack', f"Deleted rack '{rack_name[0] if rack_name else rack_id}'", conn=conn)
|
|
conn.commit()
|
|
logging.info(f"User {user_name} deleted rack {rack_id}.")
|
|
return redirect(url_for('racks'))
|
|
|
|
@app.route('/rack/<int:rack_id>/export_csv')
|
|
@permission_required('export_rack_csv')
|
|
def export_rack_csv(rack_id):
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT * FROM Rack WHERE id = %s', (rack_id,))
|
|
rack = cursor.fetchone()
|
|
if not rack:
|
|
return 'Rack not found', 404
|
|
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
|
|
rack_devices = cursor.fetchall()
|
|
device_ids = [rd['device_id'] for rd in rack_devices]
|
|
device_names = {}
|
|
if device_ids:
|
|
format_strings = ','.join(['%s'] * len(device_ids))
|
|
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
|
|
for row in cursor.fetchall():
|
|
device_names[row['id']] = row['name']
|
|
for rd in rack_devices:
|
|
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow([f"Rack: {rack['name']} ({rack['height_u']}U, {rack['site']})"])
|
|
writer.writerow([])
|
|
for side in ['front', 'back']:
|
|
writer.writerow([side.capitalize()])
|
|
writer.writerow(['U', 'Device'])
|
|
for u in range(rack['height_u'], 0, -1):
|
|
found = False
|
|
for rd in rack_devices:
|
|
if rd['position_u'] == u and rd['side'] == side:
|
|
writer.writerow([u, rd['device_name']])
|
|
found = True
|
|
break
|
|
if not found:
|
|
writer.writerow([u, ''])
|
|
writer.writerow([])
|
|
csv_bytes = output.getvalue().encode('utf-8')
|
|
output_bytes = BytesIO(csv_bytes)
|
|
output_bytes.seek(0)
|
|
filename = f"{rack['name']}_rack.csv".replace(' ', '_')
|
|
return send_file(
|
|
output_bytes,
|
|
mimetype='text/csv',
|
|
as_attachment=True,
|
|
download_name=filename
|
|
)
|
|
|
|
@app.route('/search')
|
|
@login_required
|
|
def search():
|
|
query = request.args.get('q', '').strip()
|
|
results = {
|
|
'subnets': [],
|
|
'ips': [],
|
|
'devices': [],
|
|
'tags': [],
|
|
'racks': [],
|
|
'sites': []
|
|
}
|
|
|
|
if query:
|
|
from flask import current_app
|
|
conn = get_db_connection(current_app)
|
|
try:
|
|
cursor = conn.cursor()
|
|
search_pattern = f'%{query}%'
|
|
|
|
# Search Subnets (name, cidr, site)
|
|
cursor.execute('''
|
|
SELECT id, name, cidr, site
|
|
FROM Subnet
|
|
WHERE name LIKE %s OR cidr LIKE %s OR site LIKE %s
|
|
ORDER BY site, name
|
|
''', (search_pattern, search_pattern, search_pattern))
|
|
results['subnets'] = [{'id': row[0], 'name': row[1], 'cidr': row[2], 'site': row[3] or 'Unassigned'}
|
|
for row in cursor.fetchall()]
|
|
|
|
# Search IP Addresses (ip, hostname, notes)
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip, ip.hostname, ip.subnet_id, s.name, s.cidr, s.site
|
|
FROM IPAddress ip
|
|
JOIN Subnet s ON ip.subnet_id = s.id
|
|
WHERE ip.ip LIKE %s OR ip.hostname LIKE %s OR ip.notes LIKE %s
|
|
ORDER BY ip.ip
|
|
''', (search_pattern, search_pattern, search_pattern))
|
|
results['ips'] = [{'id': row[0], 'ip': row[1], 'hostname': row[2],
|
|
'subnet_id': row[3], 'subnet_name': row[4],
|
|
'subnet_cidr': row[5], 'site': row[6] or 'Unassigned'}
|
|
for row in cursor.fetchall()]
|
|
|
|
# Search Devices (name, description)
|
|
cursor.execute('''
|
|
SELECT id, name, description
|
|
FROM Device
|
|
WHERE name LIKE %s OR description LIKE %s
|
|
ORDER BY name
|
|
''', (search_pattern, search_pattern))
|
|
results['devices'] = [{'id': row[0], 'name': row[1], 'description': row[2] or ''}
|
|
for row in cursor.fetchall()]
|
|
|
|
# Search Tags (name, description)
|
|
cursor.execute('''
|
|
SELECT id, name, description
|
|
FROM Tag
|
|
WHERE name LIKE %s OR description LIKE %s
|
|
ORDER BY name
|
|
''', (search_pattern, search_pattern))
|
|
results['tags'] = [{'id': row[0], 'name': row[1], 'description': row[2] or ''}
|
|
for row in cursor.fetchall()]
|
|
|
|
# Search Racks (name, site)
|
|
cursor.execute('''
|
|
SELECT id, name, site, height_u
|
|
FROM Rack
|
|
WHERE name LIKE %s OR site LIKE %s
|
|
ORDER BY site, name
|
|
''', (search_pattern, search_pattern))
|
|
results['racks'] = [{'id': row[0], 'name': row[1], 'site': row[2], 'height_u': row[3]}
|
|
for row in cursor.fetchall()]
|
|
|
|
# Get unique sites from subnets and racks
|
|
all_sites = set()
|
|
for subnet in results['subnets']:
|
|
all_sites.add(subnet['site'])
|
|
for rack in results['racks']:
|
|
all_sites.add(rack['site'])
|
|
for ip in results['ips']:
|
|
all_sites.add(ip['site'])
|
|
|
|
# Filter sites that match the query
|
|
matching_sites = [site for site in all_sites if query.lower() in site.lower()]
|
|
results['sites'] = sorted(matching_sites)
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
return render_with_user('search.html', query=query, results=results)
|
|
|
|
@app.route('/help')
|
|
@permission_required('view_help')
|
|
def help():
|
|
return render_with_user('help.html')
|
|
|
|
# ========== API ROUTES ==========
|
|
|
|
@app.route('/api/v1/info', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_auth_required
|
|
def api_info():
|
|
"""Get API information and authenticated user info"""
|
|
return jsonify({
|
|
'api_version': '1.0',
|
|
'user': {
|
|
'id': request.api_user['id'],
|
|
'name': request.api_user['name'],
|
|
'email': request.api_user['email']
|
|
}
|
|
})
|
|
|
|
# Devices API
|
|
@app.route('/api/v1/devices', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_devices')
|
|
def api_devices():
|
|
"""Get all devices"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('''
|
|
SELECT d.id, d.name, d.description, dt.name as device_type, dt.icon_class
|
|
FROM Device d
|
|
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
|
|
ORDER BY d.name
|
|
''')
|
|
devices = cursor.fetchall()
|
|
for device in devices:
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip, ip.hostname, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site
|
|
FROM DeviceIPAddress dia
|
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
|
JOIN Subnet s ON ip.subnet_id = s.id
|
|
WHERE dia.device_id = %s
|
|
''', (device['id'],))
|
|
device['ip_addresses'] = cursor.fetchall()
|
|
cursor.execute('''
|
|
SELECT t.id, t.name, t.color
|
|
FROM DeviceTag dt
|
|
JOIN Tag t ON dt.tag_id = t.id
|
|
WHERE dt.device_id = %s
|
|
ORDER BY t.name
|
|
''', (device['id'],))
|
|
device['tags'] = cursor.fetchall()
|
|
# Get custom fields
|
|
cursor.execute('SELECT custom_fields FROM Device WHERE id = %s', (device['id'],))
|
|
cf_result = cursor.fetchone()
|
|
if cf_result and cf_result.get('custom_fields'):
|
|
try:
|
|
device['custom_fields'] = json.loads(cf_result['custom_fields'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
device['custom_fields'] = {}
|
|
else:
|
|
device['custom_fields'] = {}
|
|
return jsonify({'devices': devices})
|
|
|
|
@app.route('/api/v1/devices/<int:device_id>', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_device')
|
|
def api_device(device_id):
|
|
"""Get a specific device"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('''
|
|
SELECT d.id, d.name, d.description, dt.name as device_type, dt.icon_class
|
|
FROM Device d
|
|
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
|
|
WHERE d.id = %s
|
|
''', (device_id,))
|
|
device = cursor.fetchone()
|
|
if not device:
|
|
return jsonify({'error': 'Device not found'}), 404
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip, ip.hostname, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site
|
|
FROM DeviceIPAddress dia
|
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
|
JOIN Subnet s ON ip.subnet_id = s.id
|
|
WHERE dia.device_id = %s
|
|
''', (device_id,))
|
|
device['ip_addresses'] = cursor.fetchall()
|
|
cursor.execute('''
|
|
SELECT t.id, t.name, t.color
|
|
FROM DeviceTag dt
|
|
JOIN Tag t ON dt.tag_id = t.id
|
|
WHERE dt.device_id = %s
|
|
ORDER BY t.name
|
|
''', (device_id,))
|
|
device['tags'] = cursor.fetchall()
|
|
# Get custom fields
|
|
cursor.execute('SELECT custom_fields FROM Device WHERE id = %s', (device_id,))
|
|
cf_result = cursor.fetchone()
|
|
if cf_result and cf_result.get('custom_fields'):
|
|
try:
|
|
device['custom_fields'] = json.loads(cf_result['custom_fields'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
device['custom_fields'] = {}
|
|
else:
|
|
device['custom_fields'] = {}
|
|
return jsonify(device)
|
|
|
|
@app.route('/api/v1/devices', methods=['POST'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('add_device')
|
|
def api_add_device():
|
|
"""Create a new device"""
|
|
data = request.get_json()
|
|
if not data or 'name' not in data:
|
|
return jsonify({'error': 'Device name is required'}), 400
|
|
|
|
name = data['name']
|
|
description = data.get('description', '')
|
|
device_type_id = data.get('device_type_id', 1)
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT INTO Device (name, description, device_type_id) VALUES (%s, %s, %s)',
|
|
(name, description, device_type_id))
|
|
device_id = cursor.lastrowid
|
|
add_audit_log(request.api_user['id'], 'add_device', f"Added device {name}", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'id': device_id, 'name': name, 'description': description, 'device_type_id': device_type_id}), 201
|
|
|
|
@app.route('/api/v1/devices/<int:device_id>', methods=['PUT'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('edit_device')
|
|
def api_update_device(device_id):
|
|
"""Update a device"""
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'Request body is required'}), 400
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name, description, device_type_id FROM Device WHERE id = %s', (device_id,))
|
|
current = cursor.fetchone()
|
|
if not current:
|
|
return jsonify({'error': 'Device not found'}), 404
|
|
current_name, current_description, current_device_type = current
|
|
|
|
updates = []
|
|
values = []
|
|
rename = False
|
|
new_name = current_name
|
|
if 'name' in data:
|
|
new_name = data['name']
|
|
if new_name != current_name:
|
|
updates.append('name = %s')
|
|
values.append(new_name)
|
|
rename = True
|
|
if 'description' in data and data['description'] != current_description:
|
|
updates.append('description = %s')
|
|
values.append(data['description'])
|
|
if 'device_type_id' in data and data['device_type_id'] != current_device_type:
|
|
updates.append('device_type_id = %s')
|
|
values.append(data['device_type_id'])
|
|
|
|
if not updates:
|
|
return jsonify({'error': 'No changes to apply'}), 400
|
|
|
|
values.append(device_id)
|
|
cursor.execute(f'UPDATE Device SET {", ".join(updates)} WHERE id = %s', values)
|
|
|
|
if rename:
|
|
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE hostname = %s', (new_name, current_name))
|
|
add_audit_log(request.api_user['id'], 'rename_device', f"Renamed device '{current_name}' to '{new_name}'", conn=conn)
|
|
|
|
conn.commit()
|
|
return jsonify({'message': 'Device updated successfully', 'device': {'id': device_id, 'name': new_name}})
|
|
|
|
@app.route('/api/v1/devices/<int:device_id>', methods=['DELETE'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('delete_device')
|
|
def api_delete_device(device_id):
|
|
"""Delete a device"""
|
|
from flask import current_app
|
|
subnet_ids_to_invalidate = set()
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device = cursor.fetchone()
|
|
if not device:
|
|
return jsonify({'error': 'Device not found'}), 404
|
|
device_name = device[0]
|
|
# Get subnet IDs for all IPs assigned to this device before deleting
|
|
cursor.execute('''
|
|
SELECT DISTINCT ip.subnet_id
|
|
FROM DeviceIPAddress dia
|
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
|
WHERE dia.device_id = %s
|
|
''', (device_id,))
|
|
subnet_ids_to_invalidate = {row[0] for row in cursor.fetchall()}
|
|
|
|
cursor.execute('SELECT ip_id FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
|
|
ip_ids = [row[0] for row in cursor.fetchall()]
|
|
if ip_ids:
|
|
cursor.executemany('UPDATE IPAddress SET hostname = NULL WHERE id = %s', [(ip_id,) for ip_id in ip_ids])
|
|
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
|
|
cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,))
|
|
add_audit_log(request.api_user['id'], 'delete_device', f"Deleted device {device_name}", conn=conn)
|
|
conn.commit()
|
|
invalidate_cache_for_device(device_id)
|
|
# Invalidate subnet caches for all subnets that had IPs assigned to this device
|
|
for subnet_id in subnet_ids_to_invalidate:
|
|
cache.invalidate_subnet(subnet_id)
|
|
return jsonify({'message': 'Device deleted successfully', 'device': {'id': device_id, 'name': device_name}})
|
|
|
|
@app.route('/api/v1/devices/<int:device_id>/ips', methods=['POST'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('add_device_ip')
|
|
def api_add_device_ip(device_id):
|
|
"""Add an IP address to a device"""
|
|
data = request.get_json()
|
|
if not data or 'ip_id' not in data:
|
|
return jsonify({'error': 'ip_id is required'}), 400
|
|
|
|
ip_id = data['ip_id']
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name FROM Device WHERE id = %s', (device_id,))
|
|
device_row = cursor.fetchone()
|
|
if not device_row:
|
|
return jsonify({'error': 'Device not found'}), 404
|
|
device_name = device_row[1]
|
|
|
|
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,))
|
|
ip_row = cursor.fetchone()
|
|
if not ip_row:
|
|
return jsonify({'error': 'IP address not found'}), 404
|
|
ip, subnet_id = ip_row
|
|
|
|
cursor.execute('SELECT id FROM DeviceIPAddress WHERE ip_id = %s', (ip_id,))
|
|
if cursor.fetchone():
|
|
return jsonify({'error': 'IP address already assigned to a device'}), 400
|
|
|
|
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
dhcp_row = cursor.fetchone()
|
|
if dhcp_row:
|
|
start_ip, end_ip, excluded_ips = dhcp_row
|
|
excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x]
|
|
if ip not in excluded_list:
|
|
cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s ORDER BY ip', (subnet_id,))
|
|
all_ips = [row[0] for row in cursor.fetchall()]
|
|
in_range = False
|
|
reserved_for_dhcp = False
|
|
for candidate_ip in all_ips:
|
|
if candidate_ip == start_ip:
|
|
in_range = True
|
|
if in_range and candidate_ip == ip:
|
|
reserved_for_dhcp = True
|
|
break
|
|
if candidate_ip == end_ip:
|
|
in_range = False
|
|
if reserved_for_dhcp:
|
|
return jsonify({'error': 'This IP is reserved for DHCP and cannot be assigned to a device'}), 400
|
|
|
|
cursor.execute('INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (%s, %s)', (device_id, ip_id))
|
|
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE id = %s', (device_name, ip_id))
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet_row = cursor.fetchone()
|
|
if subnet_row:
|
|
subnet_name, subnet_cidr = subnet_row
|
|
details = f"Assigned IP {ip} ({subnet_name} {subnet_cidr}) to device {device_name}"
|
|
else:
|
|
details = f"Assigned IP {ip} to device {device_name}"
|
|
add_audit_log(request.api_user['id'], 'device_add_ip', details, subnet_id, conn=conn)
|
|
conn.commit()
|
|
return jsonify({'message': 'IP address added to device successfully', 'ip_id': ip_id}), 201
|
|
|
|
@app.route('/api/v1/devices/<int:device_id>/ips/<int:ip_id>', methods=['DELETE'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('remove_device_ip')
|
|
def api_remove_device_ip(device_id, ip_id):
|
|
"""Remove an IP address from a device"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT ip.ip, ip.subnet_id, d.name
|
|
FROM DeviceIPAddress dia
|
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
|
JOIN Device d ON dia.device_id = d.id
|
|
WHERE dia.device_id = %s AND dia.ip_id = %s
|
|
''', (device_id, ip_id))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'IP address not found on device'}), 404
|
|
ip, subnet_id, device_name = row
|
|
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s AND ip_id = %s', (device_id, ip_id))
|
|
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE id = %s', (ip_id,))
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet_row = cursor.fetchone()
|
|
if subnet_row:
|
|
subnet_name, subnet_cidr = subnet_row
|
|
details = f"Removed IP {ip} ({subnet_name} {subnet_cidr}) from device {device_name}"
|
|
else:
|
|
details = f"Removed IP {ip} from device {device_name}"
|
|
add_audit_log(request.api_user['id'], 'device_delete_ip', details, subnet_id, conn=conn)
|
|
conn.commit()
|
|
return jsonify({'message': 'IP address removed from device successfully', 'ip_id': ip_id})
|
|
|
|
# Subnets API
|
|
@app.route('/api/v1/subnets', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_subnet')
|
|
def api_subnets():
|
|
"""Get all subnets"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet ORDER BY site, name')
|
|
subnets = cursor.fetchall()
|
|
for subnet in subnets:
|
|
cursor.execute('SELECT COUNT(*) as total, COUNT(CASE WHEN hostname IS NOT NULL THEN 1 END) as used FROM IPAddress WHERE subnet_id = %s', (subnet['id'],))
|
|
stats = cursor.fetchone()
|
|
subnet['total_ips'] = stats['total']
|
|
subnet['used_ips'] = stats['used']
|
|
subnet['available_ips'] = stats['total'] - stats['used']
|
|
# Get custom fields
|
|
cursor.execute('SELECT custom_fields FROM Subnet WHERE id = %s', (subnet['id'],))
|
|
cf_result = cursor.fetchone()
|
|
if cf_result and cf_result.get('custom_fields'):
|
|
try:
|
|
subnet['custom_fields'] = json.loads(cf_result['custom_fields'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
subnet['custom_fields'] = {}
|
|
else:
|
|
subnet['custom_fields'] = {}
|
|
return jsonify({'subnets': subnets})
|
|
|
|
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_subnet')
|
|
def api_subnet(subnet_id):
|
|
"""Get a specific subnet with IP addresses"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
if not subnet:
|
|
return jsonify({'error': 'Subnet not found'}), 404
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip, ip.hostname, d.id as device_id, d.name as device_name
|
|
FROM IPAddress ip
|
|
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
LEFT JOIN Device d ON dia.device_id = d.id
|
|
WHERE ip.subnet_id = %s
|
|
ORDER BY ip.ip
|
|
''', (subnet_id,))
|
|
subnet['ip_addresses'] = cursor.fetchall()
|
|
# Get custom fields
|
|
cursor.execute('SELECT custom_fields FROM Subnet WHERE id = %s', (subnet_id,))
|
|
cf_result = cursor.fetchone()
|
|
if cf_result and cf_result.get('custom_fields'):
|
|
try:
|
|
subnet['custom_fields'] = json.loads(cf_result['custom_fields'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
subnet['custom_fields'] = {}
|
|
else:
|
|
subnet['custom_fields'] = {}
|
|
return jsonify(subnet)
|
|
|
|
@app.route('/api/v1/subnets/<int:subnet_id>/next_free_ip', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_subnet')
|
|
def api_subnet_next_free_ip(subnet_id):
|
|
"""Get the next free IP address in a subnet"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
# First check if subnet exists
|
|
cursor.execute('SELECT id FROM Subnet WHERE id = %s', (subnet_id,))
|
|
if not cursor.fetchone():
|
|
return jsonify({'error': 'Subnet not found'}), 404
|
|
|
|
# Find the first IP in the subnet that is not assigned to any device
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip
|
|
FROM IPAddress ip
|
|
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
WHERE ip.subnet_id = %s AND dia.ip_id IS NULL
|
|
ORDER BY INET_ATON(ip.ip)
|
|
LIMIT 1
|
|
''', (subnet_id,))
|
|
result = cursor.fetchone()
|
|
if not result:
|
|
return jsonify({'error': 'No free IP addresses available in this subnet'}), 404
|
|
|
|
return jsonify({'id': result['id'], 'ip': result['ip']})
|
|
|
|
@app.route('/api/v1/subnets', methods=['POST'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('add_subnet')
|
|
def api_add_subnet():
|
|
"""Create a new subnet"""
|
|
data = request.get_json()
|
|
if not data or 'name' not in data or 'cidr' not in data:
|
|
return jsonify({'error': 'Name and CIDR are required'}), 400
|
|
|
|
name = data['name']
|
|
cidr = data['cidr']
|
|
site = data.get('site', '')
|
|
vlan_id_str = str(data.get('vlan_id', '')).strip() if data.get('vlan_id') else ''
|
|
vlan_description = data.get('vlan_description', '').strip() if data.get('vlan_description') else ''
|
|
vlan_notes = data.get('vlan_notes', '').strip() if data.get('vlan_notes') else ''
|
|
|
|
# Validate VLAN ID if provided
|
|
if vlan_id_str:
|
|
is_valid, error_msg, vlan_id = validate_vlan_id(vlan_id_str)
|
|
if not is_valid:
|
|
return jsonify({'error': error_msg}), 400
|
|
else:
|
|
vlan_id = None
|
|
|
|
try:
|
|
network = ip_network(cidr, strict=False)
|
|
if network.prefixlen < 24:
|
|
return jsonify({'error': 'Subnet must be /24 or smaller'}), 400
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid CIDR format'}), 400
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT INTO Subnet (name, cidr, site, vlan_id, vlan_description, vlan_notes) VALUES (%s, %s, %s, %s, %s, %s)',
|
|
(name, cidr, site, vlan_id, vlan_description if vlan_description else None, vlan_notes if vlan_notes else None))
|
|
subnet_id = cursor.lastrowid
|
|
ip_rows = [(str(ip), subnet_id) for ip in network.hosts()]
|
|
cursor.executemany('INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)', ip_rows)
|
|
vlan_info = f" (VLAN {vlan_id})" if vlan_id else ""
|
|
add_audit_log(request.api_user['id'], 'add_subnet', f"Added subnet {name} ({cidr}){vlan_info}", subnet_id, conn=conn)
|
|
conn.commit()
|
|
return jsonify({
|
|
'id': subnet_id,
|
|
'name': name,
|
|
'cidr': cidr,
|
|
'site': site,
|
|
'vlan_id': vlan_id,
|
|
'vlan_description': vlan_description if vlan_description else None,
|
|
'vlan_notes': vlan_notes if vlan_notes else None
|
|
}), 201
|
|
|
|
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['PUT'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('edit_subnet')
|
|
def api_update_subnet(subnet_id):
|
|
"""Update a subnet"""
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'Request body is required'}), 400
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
|
|
old_subnet = cursor.fetchone()
|
|
if not old_subnet:
|
|
return jsonify({'error': 'Subnet not found'}), 404
|
|
old_name, old_cidr, old_site, old_vlan_id, old_vlan_desc, old_vlan_notes = old_subnet
|
|
|
|
new_name = data.get('name', old_name)
|
|
new_cidr = data.get('cidr', old_cidr)
|
|
new_site = data.get('site', old_site)
|
|
|
|
# Handle VLAN fields
|
|
vlan_id_str = str(data.get('vlan_id', '')).strip() if data.get('vlan_id') is not None else ''
|
|
new_vlan_description = data.get('vlan_description', '').strip() if data.get('vlan_description') else ''
|
|
new_vlan_notes = data.get('vlan_notes', '').strip() if data.get('vlan_notes') else ''
|
|
|
|
# Validate VLAN ID if provided
|
|
if vlan_id_str:
|
|
is_valid, error_msg, new_vlan_id = validate_vlan_id(vlan_id_str)
|
|
if not is_valid:
|
|
return jsonify({'error': error_msg}), 400
|
|
elif 'vlan_id' in data and data['vlan_id'] is None:
|
|
new_vlan_id = None
|
|
else:
|
|
new_vlan_id = old_vlan_id
|
|
|
|
# Use old values if not provided in request
|
|
if 'vlan_description' not in data:
|
|
new_vlan_description = old_vlan_desc if old_vlan_desc else ''
|
|
if 'vlan_notes' not in data:
|
|
new_vlan_notes = old_vlan_notes if old_vlan_notes else ''
|
|
|
|
updates = []
|
|
values = []
|
|
if new_name != old_name:
|
|
updates.append('name = %s')
|
|
values.append(new_name)
|
|
if new_cidr != old_cidr:
|
|
updates.append('cidr = %s')
|
|
values.append(new_cidr)
|
|
if new_site != old_site:
|
|
updates.append('site = %s')
|
|
values.append(new_site)
|
|
if new_vlan_id != old_vlan_id:
|
|
updates.append('vlan_id = %s')
|
|
values.append(new_vlan_id)
|
|
if new_vlan_description != (old_vlan_desc or ''):
|
|
updates.append('vlan_description = %s')
|
|
values.append(new_vlan_description if new_vlan_description else None)
|
|
if new_vlan_notes != (old_vlan_notes or ''):
|
|
updates.append('vlan_notes = %s')
|
|
values.append(new_vlan_notes if new_vlan_notes else None)
|
|
|
|
if not updates:
|
|
return jsonify({'error': 'No changes to apply'}), 400
|
|
|
|
values.append(subnet_id)
|
|
cursor.execute(f'UPDATE Subnet SET {", ".join(updates)} WHERE id = %s', values)
|
|
vlan_info = f" (VLAN {new_vlan_id})" if new_vlan_id else ""
|
|
add_audit_log(
|
|
request.api_user['id'],
|
|
'edit_subnet',
|
|
f"Edited subnet from {old_name} ({old_cidr}) to {new_name} ({new_cidr}) at site {new_site or 'Unassigned'}{vlan_info}",
|
|
subnet_id,
|
|
conn=conn
|
|
)
|
|
conn.commit()
|
|
return jsonify({
|
|
'message': 'Subnet updated successfully',
|
|
'subnet': {
|
|
'id': subnet_id,
|
|
'name': new_name,
|
|
'cidr': new_cidr,
|
|
'site': new_site,
|
|
'vlan_id': new_vlan_id,
|
|
'vlan_description': new_vlan_description if new_vlan_description else None,
|
|
'vlan_notes': new_vlan_notes if new_vlan_notes else None
|
|
}
|
|
})
|
|
|
|
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['DELETE'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('delete_subnet')
|
|
def api_delete_subnet(subnet_id):
|
|
"""Delete a subnet"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
if not subnet:
|
|
return jsonify({'error': 'Subnet not found'}), 404
|
|
subnet_name, subnet_cidr = subnet
|
|
cursor.execute('SELECT id FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
ip_ids = [row[0] for row in cursor.fetchall()]
|
|
if ip_ids:
|
|
cursor.executemany('DELETE FROM DeviceIPAddress WHERE ip_id = %s', [(ip_id,) for ip_id in ip_ids])
|
|
cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
cursor.execute('UPDATE AuditLog SET subnet_id = NULL WHERE subnet_id = %s', (subnet_id,))
|
|
cursor.execute('DELETE FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
cursor.execute('DELETE FROM Subnet WHERE id = %s', (subnet_id,))
|
|
add_audit_log(request.api_user['id'], 'delete_subnet', f"Deleted subnet {subnet_name} ({subnet_cidr})", subnet_id, conn=conn)
|
|
conn.commit()
|
|
return jsonify({'message': 'Subnet deleted successfully', 'subnet': {'id': subnet_id, 'name': subnet_name, 'cidr': subnet_cidr}})
|
|
|
|
# Racks API
|
|
@app.route('/api/v1/racks', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_racks')
|
|
def api_racks():
|
|
"""Get all racks"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
return jsonify({'error': 'Racks feature is disabled'}), 404
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name, site, height_u FROM Rack ORDER BY site, name')
|
|
racks = cursor.fetchall()
|
|
for rack in racks:
|
|
cursor.execute('SELECT COUNT(*) as used FROM RackDevice WHERE rack_id = %s AND side = %s', (rack['id'], 'front'))
|
|
usage_row = cursor.fetchone()
|
|
rack['used_u'] = usage_row['used'] if usage_row and 'used' in usage_row else 0
|
|
rack['percent_full'] = int((rack['used_u'] / rack['height_u']) * 100) if rack['height_u'] else 0
|
|
cursor.execute('''
|
|
SELECT rd.id, rd.position_u, rd.side, rd.device_id, rd.nonnet_device_name,
|
|
d.name as device_name
|
|
FROM RackDevice rd
|
|
LEFT JOIN Device d ON rd.device_id = d.id
|
|
WHERE rd.rack_id = %s
|
|
ORDER BY rd.position_u, rd.side
|
|
''', (rack['id'],))
|
|
rack['devices'] = cursor.fetchall()
|
|
return jsonify({'racks': racks})
|
|
|
|
@app.route('/api/v1/racks/<int:rack_id>', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_rack')
|
|
def api_rack(rack_id):
|
|
"""Get a specific rack"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
return jsonify({'error': 'Racks feature is disabled'}), 404
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name, site, height_u FROM Rack WHERE id = %s', (rack_id,))
|
|
rack = cursor.fetchone()
|
|
if not rack:
|
|
return jsonify({'error': 'Rack not found'}), 404
|
|
cursor.execute('''
|
|
SELECT rd.id, rd.position_u, rd.side, rd.device_id, rd.nonnet_device_name,
|
|
d.name as device_name
|
|
FROM RackDevice rd
|
|
LEFT JOIN Device d ON rd.device_id = d.id
|
|
WHERE rd.rack_id = %s
|
|
ORDER BY rd.position_u, rd.side
|
|
''', (rack_id,))
|
|
rack['devices'] = cursor.fetchall()
|
|
return jsonify(rack)
|
|
|
|
@app.route('/api/v1/racks', methods=['POST'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('add_rack')
|
|
def api_add_rack():
|
|
"""Create a new rack"""
|
|
from flask import current_app
|
|
data = request.get_json()
|
|
if not data or 'name' not in data or 'site' not in data or 'height_u' not in data:
|
|
return jsonify({'error': 'Name, site, and height_u are required'}), 400
|
|
|
|
name = data['name']
|
|
site = data['site']
|
|
height_u = data['height_u']
|
|
try:
|
|
height_u = int(height_u)
|
|
except (TypeError, ValueError):
|
|
return jsonify({'error': 'height_u must be an integer'}), 400
|
|
if height_u <= 0:
|
|
return jsonify({'error': 'height_u must be greater than zero'}), 400
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
return jsonify({'error': 'Racks feature is disabled'}), 404
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u))
|
|
rack_id = cursor.lastrowid
|
|
add_audit_log(request.api_user['id'], 'add_rack', f"Added rack '{name}' at site '{site}' ({height_u}U)", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'id': rack_id, 'name': name, 'site': site, 'height_u': height_u}), 201
|
|
|
|
@app.route('/api/v1/racks/<int:rack_id>', methods=['DELETE'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('delete_rack')
|
|
def api_delete_rack(rack_id):
|
|
"""Delete a rack"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
return jsonify({'error': 'Racks feature is disabled'}), 404
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
|
|
rack = cursor.fetchone()
|
|
if not rack:
|
|
return jsonify({'error': 'Rack not found'}), 404
|
|
rack_name = rack[0]
|
|
cursor.execute('DELETE FROM Rack WHERE id = %s', (rack_id,))
|
|
add_audit_log(request.api_user['id'], 'delete_rack', f"Deleted rack '{rack_name}'", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'message': 'Rack deleted successfully', 'rack': {'id': rack_id, 'name': rack_name}})
|
|
|
|
@app.route('/api/v1/racks/<int:rack_id>/devices', methods=['POST'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('add_device_to_rack')
|
|
def api_add_device_to_rack(rack_id):
|
|
"""Add a device to a rack"""
|
|
from flask import current_app
|
|
data = request.get_json()
|
|
if not data or 'position_u' not in data or 'side' not in data:
|
|
return jsonify({'error': 'position_u and side are required'}), 400
|
|
|
|
position_u = data['position_u']
|
|
side = data['side']
|
|
device_id = data.get('device_id')
|
|
nonnet_device_name = data.get('nonnet_device_name')
|
|
|
|
if device_id is None and not nonnet_device_name:
|
|
return jsonify({'error': 'Either device_id or nonnet_device_name is required'}), 400
|
|
|
|
try:
|
|
position_u = int(position_u)
|
|
except (TypeError, ValueError):
|
|
return jsonify({'error': 'position_u must be an integer'}), 400
|
|
side = str(side).lower()
|
|
if side not in ('front', 'back'):
|
|
return jsonify({'error': "side must be either 'front' or 'back'"}), 400
|
|
if device_id is not None:
|
|
try:
|
|
device_id = int(device_id)
|
|
except (TypeError, ValueError):
|
|
return jsonify({'error': 'device_id must be an integer'}), 400
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
return jsonify({'error': 'Racks feature is disabled'}), 404
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT name, height_u FROM Rack WHERE id = %s', (rack_id,))
|
|
rack = cursor.fetchone()
|
|
if not rack:
|
|
return jsonify({'error': 'Rack not found'}), 404
|
|
if position_u < 1 or position_u > rack['height_u']:
|
|
return jsonify({'error': f'Invalid U position: {position_u}. Rack is {rack["height_u"]}U tall.'}), 400
|
|
|
|
cursor.execute('SELECT COUNT(*) as occupied_count FROM RackDevice WHERE rack_id = %s AND position_u = %s AND side = %s', (rack_id, position_u, side))
|
|
occupied = cursor.fetchone()
|
|
if occupied and occupied['occupied_count'] > 0:
|
|
return jsonify({'error': f'U{position_u} on the {side} is already occupied.'}), 400
|
|
|
|
if device_id is not None:
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device_row = cursor.fetchone()
|
|
if not device_row:
|
|
return jsonify({'error': 'Device not found'}), 404
|
|
device_name = device_row['name']
|
|
cursor.execute(
|
|
'INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, %s, %s, %s, NULL)',
|
|
(rack_id, device_id, position_u, side)
|
|
)
|
|
action = 'rack_add_device'
|
|
details = f"Assigned device '{device_name}' to rack '{rack['name']}' U{position_u} ({side})"
|
|
else:
|
|
nonnet_device_name = (nonnet_device_name or '').strip()
|
|
if not nonnet_device_name:
|
|
return jsonify({'error': 'nonnet_device_name is required when device_id is not provided'}), 400
|
|
cursor.execute(
|
|
'INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, NULL, %s, %s, %s)',
|
|
(rack_id, position_u, side, nonnet_device_name)
|
|
)
|
|
device_name = nonnet_device_name
|
|
action = 'rack_add_nonnet_device'
|
|
details = f"Added non-networked device '{device_name}' to rack '{rack['name']}' U{position_u} ({side})"
|
|
|
|
rack_device_id = cursor.lastrowid
|
|
add_audit_log(request.api_user['id'], action, details, conn=conn)
|
|
conn.commit()
|
|
return jsonify({
|
|
'message': 'Device added to rack successfully',
|
|
'rack_device': {
|
|
'id': rack_device_id,
|
|
'rack_id': rack_id,
|
|
'device_id': device_id,
|
|
'nonnet_device_name': device_name if device_id is None else None,
|
|
'device_name': device_name,
|
|
'position_u': position_u,
|
|
'side': side
|
|
}
|
|
}), 201
|
|
|
|
@app.route('/api/v1/racks/<int:rack_id>/devices/<int:rack_device_id>', methods=['DELETE'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('remove_device_from_rack')
|
|
def api_remove_device_from_rack(rack_id, rack_device_id):
|
|
"""Remove a device from a rack"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if racks feature is enabled
|
|
if not is_feature_enabled('racks', conn=conn):
|
|
return jsonify({'error': 'Racks feature is disabled'}), 404
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('''
|
|
SELECT rd.device_id, rd.nonnet_device_name, rd.position_u, rd.side,
|
|
d.name AS device_name, r.name AS rack_name
|
|
FROM RackDevice rd
|
|
JOIN Rack r ON rd.rack_id = r.id
|
|
LEFT JOIN Device d ON rd.device_id = d.id
|
|
WHERE rd.id = %s AND rd.rack_id = %s
|
|
''', (rack_device_id, rack_id))
|
|
rd = cursor.fetchone()
|
|
if not rd:
|
|
return jsonify({'error': 'Device not found in rack'}), 404
|
|
if rd['device_id']:
|
|
device_label = rd['device_name'] or str(rd['device_id'])
|
|
else:
|
|
device_label = rd['nonnet_device_name']
|
|
cursor.execute('DELETE FROM RackDevice WHERE id = %s AND rack_id = %s', (rack_device_id, rack_id))
|
|
add_audit_log(
|
|
request.api_user['id'],
|
|
'rack_remove_device',
|
|
f"Removed device '{device_label}' from rack '{rd['rack_name']}' U{rd['position_u']} ({rd['side']})",
|
|
conn=conn
|
|
)
|
|
conn.commit()
|
|
return jsonify({'message': 'Device removed from rack successfully', 'rack_device_id': rack_device_id})
|
|
|
|
# Custom Fields API
|
|
@app.route('/api/v1/custom_fields/<entity_type>', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_custom_fields')
|
|
def api_custom_fields_by_type(entity_type):
|
|
"""Get custom field definitions for a specific entity type"""
|
|
if entity_type not in ['device', 'subnet']:
|
|
return jsonify({'error': 'Invalid entity type. Must be "device" or "subnet"'}), 400
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('''
|
|
SELECT id, entity_type, name, field_key, field_type, required,
|
|
default_value, help_text, display_order, validation_rules, searchable
|
|
FROM CustomFieldDefinition
|
|
WHERE entity_type = %s
|
|
ORDER BY display_order, name
|
|
''', (entity_type,))
|
|
fields = cursor.fetchall()
|
|
# Parse validation_rules JSON strings
|
|
for field in fields:
|
|
if field.get('validation_rules'):
|
|
try:
|
|
field['validation_rules'] = json.loads(field['validation_rules'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
field['validation_rules'] = {}
|
|
return jsonify({'fields': fields})
|
|
|
|
@app.route('/api/v1/custom_fields', methods=['POST'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('manage_custom_fields')
|
|
def api_add_custom_field():
|
|
"""Create a new custom field definition"""
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'Request body is required'}), 400
|
|
|
|
required_fields = ['entity_type', 'name', 'field_key', 'field_type']
|
|
for field in required_fields:
|
|
if field not in data:
|
|
return jsonify({'error': f'{field} is required'}), 400
|
|
|
|
entity_type = data['entity_type']
|
|
if entity_type not in ['device', 'subnet']:
|
|
return jsonify({'error': 'Invalid entity_type. Must be "device" or "subnet"'}), 400
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
try:
|
|
validation_rules = data.get('validation_rules', {})
|
|
validation_rules_json = json.dumps(validation_rules) if validation_rules else None
|
|
|
|
cursor.execute('''
|
|
INSERT INTO CustomFieldDefinition
|
|
(entity_type, name, field_key, field_type, required, default_value,
|
|
help_text, display_order, validation_rules, searchable)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
''', (entity_type, data['name'], data['field_key'], data['field_type'],
|
|
data.get('required', False), data.get('default_value'),
|
|
data.get('help_text'), data.get('display_order', 0),
|
|
validation_rules_json, data.get('searchable', False)))
|
|
field_id = cursor.lastrowid
|
|
add_audit_log(request.api_user['id'], 'add_custom_field',
|
|
f"Added custom field '{data['name']}' for {entity_type}", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'id': field_id, 'message': 'Custom field created successfully'}), 201
|
|
except mysql.connector.IntegrityError:
|
|
return jsonify({'error': f'Field key "{data["field_key"]}" already exists'}), 400
|
|
|
|
@app.route('/api/v1/custom_fields/<int:field_id>', methods=['PUT'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('manage_custom_fields')
|
|
def api_update_custom_field(field_id):
|
|
"""Update a custom field definition"""
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'Request body is required'}), 400
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id FROM CustomFieldDefinition WHERE id = %s', (field_id,))
|
|
if not cursor.fetchone():
|
|
return jsonify({'error': 'Custom field not found'}), 404
|
|
|
|
updates = []
|
|
values = []
|
|
|
|
if 'name' in data:
|
|
updates.append('name = %s')
|
|
values.append(data['name'])
|
|
if 'field_type' in data:
|
|
updates.append('field_type = %s')
|
|
values.append(data['field_type'])
|
|
if 'required' in data:
|
|
updates.append('required = %s')
|
|
values.append(data['required'])
|
|
if 'default_value' in data:
|
|
updates.append('default_value = %s')
|
|
values.append(data['default_value'])
|
|
if 'help_text' in data:
|
|
updates.append('help_text = %s')
|
|
values.append(data['help_text'])
|
|
if 'display_order' in data:
|
|
updates.append('display_order = %s')
|
|
values.append(data['display_order'])
|
|
if 'validation_rules' in data:
|
|
validation_rules_json = json.dumps(data['validation_rules']) if data['validation_rules'] else None
|
|
updates.append('validation_rules = %s')
|
|
values.append(validation_rules_json)
|
|
if 'searchable' in data:
|
|
updates.append('searchable = %s')
|
|
values.append(data['searchable'])
|
|
|
|
if not updates:
|
|
return jsonify({'error': 'No changes to apply'}), 400
|
|
|
|
values.append(field_id)
|
|
cursor.execute(f'UPDATE CustomFieldDefinition SET {", ".join(updates)} WHERE id = %s', values)
|
|
add_audit_log(request.api_user['id'], 'edit_custom_field',
|
|
f"Updated custom field {field_id}", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'message': 'Custom field updated successfully'})
|
|
|
|
@app.route('/api/v1/custom_fields/<int:field_id>', methods=['DELETE'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('manage_custom_fields')
|
|
def api_delete_custom_field(field_id):
|
|
"""Delete a custom field definition"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT name FROM CustomFieldDefinition WHERE id = %s', (field_id,))
|
|
field = cursor.fetchone()
|
|
if not field:
|
|
return jsonify({'error': 'Custom field not found'}), 404
|
|
|
|
cursor.execute('DELETE FROM CustomFieldDefinition WHERE id = %s', (field_id,))
|
|
add_audit_log(request.api_user['id'], 'delete_custom_field',
|
|
f"Deleted custom field '{field['name']}'", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'message': 'Custom field deleted successfully'})
|
|
|
|
# Device Types API
|
|
@app.route('/api/v1/device-types', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_device_types')
|
|
def api_device_types():
|
|
"""Get all device types"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name, icon_class FROM DeviceType ORDER BY name')
|
|
device_types = cursor.fetchall()
|
|
return jsonify({'device_types': device_types})
|
|
|
|
# DHCP API
|
|
@app.route('/api/v1/subnets/<int:subnet_id>/dhcp', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_dhcp')
|
|
def api_get_dhcp(subnet_id):
|
|
"""Get DHCP pools for a subnet"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
pools = cursor.fetchall()
|
|
return jsonify({'pools': pools})
|
|
|
|
@app.route('/api/v1/subnets/<int:subnet_id>/dhcp', methods=['POST'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('configure_dhcp')
|
|
def api_configure_dhcp(subnet_id):
|
|
"""Configure DHCP pools for a subnet"""
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'Request body is required'}), 400
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
if not subnet:
|
|
return jsonify({'error': 'Subnet not found'}), 404
|
|
subnet_name, subnet_cidr = subnet
|
|
|
|
if data.get('remove'):
|
|
cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE subnet_id = %s AND hostname = %s', (subnet_id, 'DHCP'))
|
|
add_audit_log(
|
|
request.api_user['id'],
|
|
'dhcp_pool_remove',
|
|
f"Removed DHCP pool for subnet {subnet_name} ({subnet_cidr})",
|
|
subnet_id,
|
|
conn=conn
|
|
)
|
|
conn.commit()
|
|
# Invalidate subnet cache and related caches
|
|
cache.invalidate_subnet(subnet_id)
|
|
cache.clear('index')
|
|
cache.clear('admin')
|
|
return jsonify({'message': 'DHCP pool removed successfully'})
|
|
|
|
pools = data.get('pools')
|
|
if not pools or not isinstance(pools, list):
|
|
return jsonify({'error': 'pools array is required'}), 400
|
|
pool = pools[0]
|
|
start_ip = pool.get('start_ip')
|
|
end_ip = pool.get('end_ip')
|
|
if not start_ip or not end_ip:
|
|
return jsonify({'error': 'start_ip and end_ip are required'}), 400
|
|
excluded_ips = pool.get('excluded_ips', [])
|
|
if not isinstance(excluded_ips, list):
|
|
return jsonify({'error': 'excluded_ips must be a list of IP strings'}), 400
|
|
excluded_list = [ip.strip() for ip in excluded_ips if ip.strip()]
|
|
excluded_str = ','.join(excluded_list)
|
|
|
|
cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s ORDER BY ip', (subnet_id,))
|
|
all_ips = [row[0] for row in cursor.fetchall()]
|
|
if start_ip not in all_ips or end_ip not in all_ips:
|
|
return jsonify({'error': 'start_ip and end_ip must be addresses within the subnet'}), 400
|
|
|
|
cursor.execute('SELECT id FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
existing = cursor.fetchone()
|
|
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE subnet_id = %s AND hostname = %s', (subnet_id, 'DHCP'))
|
|
if existing:
|
|
cursor.execute(
|
|
'UPDATE DHCPPool SET start_ip = %s, end_ip = %s, excluded_ips = %s WHERE subnet_id = %s',
|
|
(start_ip, end_ip, excluded_str, subnet_id)
|
|
)
|
|
action = 'dhcp_pool_update'
|
|
details = f"Updated DHCP pool for subnet {subnet_name} ({subnet_cidr}): {start_ip} - {end_ip}, excluded: {excluded_str}"
|
|
else:
|
|
cursor.execute(
|
|
'INSERT INTO DHCPPool (subnet_id, start_ip, end_ip, excluded_ips) VALUES (%s, %s, %s, %s)',
|
|
(subnet_id, start_ip, end_ip, excluded_str)
|
|
)
|
|
action = 'dhcp_pool_create'
|
|
details = f"Created DHCP pool for subnet {subnet_name} ({subnet_cidr}): {start_ip} - {end_ip}, excluded: {excluded_str}"
|
|
|
|
in_range = False
|
|
for candidate_ip in all_ips:
|
|
if candidate_ip == start_ip:
|
|
in_range = True
|
|
if in_range and candidate_ip not in excluded_list:
|
|
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE subnet_id = %s AND ip = %s', ('DHCP', subnet_id, candidate_ip))
|
|
if candidate_ip == end_ip:
|
|
break
|
|
|
|
add_audit_log(request.api_user['id'], action, details, subnet_id, conn=conn)
|
|
conn.commit()
|
|
# Invalidate subnet cache and related caches
|
|
cache.invalidate_subnet(subnet_id)
|
|
cache.clear('index')
|
|
cache.clear('admin')
|
|
return jsonify({'message': 'DHCP pools configured successfully', 'pool': {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_list}})
|
|
|
|
# Tags API
|
|
@app.route('/api/v1/tags', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_tags')
|
|
def api_tags():
|
|
"""Get all tags"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
return jsonify({'error': 'Device tags feature is disabled'}), 404
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name, color, description, created_at FROM Tag ORDER BY name')
|
|
tags = cursor.fetchall()
|
|
for tag in tags:
|
|
cursor.execute('SELECT COUNT(*) as device_count FROM DeviceTag WHERE tag_id = %s', (tag['id'],))
|
|
tag['device_count'] = cursor.fetchone()['device_count']
|
|
return jsonify({'tags': tags})
|
|
|
|
@app.route('/api/v1/tags', methods=['POST'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('add_tag')
|
|
def api_add_tag():
|
|
"""Create a new tag"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
return jsonify({'error': 'Device tags feature is disabled'}), 404
|
|
data = request.get_json()
|
|
if not data or 'name' not in data:
|
|
return jsonify({'error': 'Tag name is required'}), 400
|
|
|
|
name = data['name'].strip()
|
|
if not name:
|
|
return jsonify({'error': 'Tag name cannot be empty'}), 400
|
|
|
|
color = data.get('color', '#6B7280')
|
|
description = data.get('description', '')
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
try:
|
|
cursor.execute('INSERT INTO Tag (name, color, description) VALUES (%s, %s, %s)', (name, color, description))
|
|
tag_id = cursor.lastrowid
|
|
add_audit_log(request.api_user['id'], 'add_tag', f"Added tag '{name}'", conn=conn)
|
|
conn.commit()
|
|
# Invalidate device caches since they contain tags
|
|
cache.clear('device:')
|
|
cache.clear('devices')
|
|
return jsonify({'id': tag_id, 'name': name, 'color': color, 'description': description}), 201
|
|
except mysql.connector.IntegrityError:
|
|
return jsonify({'error': 'Tag name already exists'}), 400
|
|
|
|
@app.route('/api/v1/tags/<int:tag_id>', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_tags')
|
|
def api_tag(tag_id):
|
|
"""Get a specific tag"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
return jsonify({'error': 'Device tags feature is disabled'}), 404
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name, color, description, created_at FROM Tag WHERE id = %s', (tag_id,))
|
|
tag = cursor.fetchone()
|
|
if not tag:
|
|
return jsonify({'error': 'Tag not found'}), 404
|
|
cursor.execute('''
|
|
SELECT d.id, d.name, d.description, dt.name as device_type
|
|
FROM DeviceTag dtag
|
|
JOIN Device d ON dtag.device_id = d.id
|
|
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
|
|
WHERE dtag.tag_id = %s
|
|
ORDER BY d.name
|
|
''', (tag_id,))
|
|
tag['devices'] = cursor.fetchall()
|
|
return jsonify(tag)
|
|
|
|
@app.route('/api/v1/tags/<int:tag_id>', methods=['PUT'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('edit_tag')
|
|
def api_update_tag(tag_id):
|
|
"""Update a tag"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
return jsonify({'error': 'Device tags feature is disabled'}), 404
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'Request body is required'}), 400
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name, color, description FROM Tag WHERE id = %s', (tag_id,))
|
|
current = cursor.fetchone()
|
|
if not current:
|
|
return jsonify({'error': 'Tag not found'}), 404
|
|
|
|
current_name, current_color, current_description = current
|
|
updates = []
|
|
values = []
|
|
|
|
if 'name' in data and data['name'].strip() != current_name:
|
|
new_name = data['name'].strip()
|
|
if not new_name:
|
|
return jsonify({'error': 'Tag name cannot be empty'}), 400
|
|
updates.append('name = %s')
|
|
values.append(new_name)
|
|
|
|
if 'color' in data and data['color'] != current_color:
|
|
updates.append('color = %s')
|
|
values.append(data['color'])
|
|
|
|
if 'description' in data and data['description'] != current_description:
|
|
updates.append('description = %s')
|
|
values.append(data['description'])
|
|
|
|
if not updates:
|
|
return jsonify({'error': 'No changes to apply'}), 400
|
|
|
|
values.append(tag_id)
|
|
try:
|
|
cursor.execute(f'UPDATE Tag SET {", ".join(updates)} WHERE id = %s', values)
|
|
add_audit_log(request.api_user['id'], 'edit_tag', f"Updated tag '{current_name}'", conn=conn)
|
|
conn.commit()
|
|
# Invalidate device caches since they contain tags
|
|
cache.clear('device:')
|
|
cache.clear('devices')
|
|
return jsonify({'message': 'Tag updated successfully'})
|
|
except mysql.connector.IntegrityError:
|
|
return jsonify({'error': 'Tag name already exists'}), 400
|
|
|
|
@app.route('/api/v1/tags/<int:tag_id>', methods=['DELETE'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('delete_tag')
|
|
def api_delete_tag(tag_id):
|
|
"""Delete a tag"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
return jsonify({'error': 'Device tags feature is disabled'}), 404
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag = cursor.fetchone()
|
|
if not tag:
|
|
return jsonify({'error': 'Tag not found'}), 404
|
|
tag_name = tag[0]
|
|
cursor.execute('DELETE FROM Tag WHERE id = %s', (tag_id,))
|
|
add_audit_log(request.api_user['id'], 'delete_tag', f"Deleted tag '{tag_name}'", conn=conn)
|
|
conn.commit()
|
|
# Invalidate device caches since they contain tags
|
|
cache.clear('device:')
|
|
cache.clear('devices')
|
|
return jsonify({'message': 'Tag deleted successfully'})
|
|
|
|
@app.route('/api/v1/devices/<int:device_id>/tags', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_device')
|
|
def api_device_tags(device_id):
|
|
"""Get tags for a specific device"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
return jsonify({'error': 'Device tags feature is disabled'}), 404
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name FROM Device WHERE id = %s', (device_id,))
|
|
if not cursor.fetchone():
|
|
return jsonify({'error': 'Device not found'}), 404
|
|
cursor.execute('''
|
|
SELECT t.id, t.name, t.color, t.description, dt.created_at
|
|
FROM DeviceTag dt
|
|
JOIN Tag t ON dt.tag_id = t.id
|
|
WHERE dt.device_id = %s
|
|
ORDER BY t.name
|
|
''', (device_id,))
|
|
tags = cursor.fetchall()
|
|
return jsonify({'tags': tags})
|
|
|
|
@app.route('/api/v1/devices/<int:device_id>/tags', methods=['POST'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('assign_device_tag')
|
|
def api_assign_device_tag(device_id):
|
|
"""Assign a tag to a device"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
return jsonify({'error': 'Device tags feature is disabled'}), 404
|
|
data = request.get_json()
|
|
if not data or 'tag_id' not in data:
|
|
return jsonify({'error': 'tag_id is required'}), 400
|
|
|
|
tag_id = data['tag_id']
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device = cursor.fetchone()
|
|
if not device:
|
|
return jsonify({'error': 'Device not found'}), 404
|
|
device_name = device[0]
|
|
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag = cursor.fetchone()
|
|
if not tag:
|
|
return jsonify({'error': 'Tag not found'}), 404
|
|
tag_name = tag[0]
|
|
|
|
cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
|
|
if cursor.fetchone():
|
|
return jsonify({'error': 'Tag already assigned to device'}), 400
|
|
|
|
cursor.execute('INSERT INTO DeviceTag (device_id, tag_id) VALUES (%s, %s)', (device_id, tag_id))
|
|
add_audit_log(request.api_user['id'], 'assign_device_tag', f"Assigned tag '{tag_name}' to device '{device_name}'", conn=conn)
|
|
conn.commit()
|
|
invalidate_cache_for_device(device_id)
|
|
cache.clear('devices')
|
|
return jsonify({'message': 'Tag assigned successfully'})
|
|
|
|
@app.route('/api/v1/devices/<int:device_id>/tags/<int:tag_id>', methods=['DELETE'])
|
|
@rate_limit("50 per minute")
|
|
@api_permission_required('remove_device_tag')
|
|
def api_remove_device_tag(device_id, tag_id):
|
|
"""Remove a tag from a device"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
return jsonify({'error': 'Device tags feature is disabled'}), 404
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device = cursor.fetchone()
|
|
if not device:
|
|
return jsonify({'error': 'Device not found'}), 404
|
|
device_name = device[0]
|
|
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag = cursor.fetchone()
|
|
if not tag:
|
|
return jsonify({'error': 'Tag not found'}), 404
|
|
tag_name = tag[0]
|
|
|
|
cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
|
|
if not cursor.fetchone():
|
|
return jsonify({'error': 'Tag not assigned to device'}), 404
|
|
|
|
cursor.execute('DELETE FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
|
|
add_audit_log(request.api_user['id'], 'remove_device_tag', f"Removed tag '{tag_name}' from device '{device_name}'", conn=conn)
|
|
conn.commit()
|
|
invalidate_cache_for_device(device_id)
|
|
cache.clear('devices')
|
|
return jsonify({'message': 'Tag removed successfully'})
|
|
|
|
@app.route('/api/v1/devices/by-tag/<tag_identifier>', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_devices')
|
|
def api_devices_by_tag(tag_identifier):
|
|
"""Get devices by tag name or ID. Use ?format=simple for simplified response."""
|
|
from flask import current_app
|
|
simple_format = request.args.get('format') == 'simple'
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
|
|
# Check if tag_identifier is numeric (tag ID) or string (tag name)
|
|
try:
|
|
tag_id = int(tag_identifier)
|
|
# Query by tag ID
|
|
if simple_format:
|
|
cursor.execute('''
|
|
SELECT d.id, d.name
|
|
FROM DeviceTag dtag
|
|
JOIN Device d ON dtag.device_id = d.id
|
|
WHERE dtag.tag_id = %s
|
|
ORDER BY d.name
|
|
''', (tag_id,))
|
|
else:
|
|
cursor.execute('''
|
|
SELECT d.id, d.name, d.description, dt.name as device_type, dt.icon_class
|
|
FROM DeviceTag dtag
|
|
JOIN Device d ON dtag.device_id = d.id
|
|
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
|
|
WHERE dtag.tag_id = %s
|
|
ORDER BY d.name
|
|
''', (tag_id,))
|
|
# Get tag name for response
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag_result = cursor.fetchone()
|
|
if not tag_result:
|
|
return jsonify({'error': 'Tag not found'}), 404
|
|
tag_name = tag_result['name']
|
|
except ValueError:
|
|
# Query by tag name
|
|
tag_name = tag_identifier
|
|
if simple_format:
|
|
cursor.execute('''
|
|
SELECT d.id, d.name
|
|
FROM DeviceTag dtag
|
|
JOIN Device d ON dtag.device_id = d.id
|
|
JOIN Tag t ON dtag.tag_id = t.id
|
|
WHERE t.name = %s
|
|
ORDER BY d.name
|
|
''', (tag_name,))
|
|
else:
|
|
cursor.execute('''
|
|
SELECT d.id, d.name, d.description, dt.name as device_type, dt.icon_class
|
|
FROM DeviceTag dtag
|
|
JOIN Device d ON dtag.device_id = d.id
|
|
JOIN Tag t ON dtag.tag_id = t.id
|
|
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
|
|
WHERE t.name = %s
|
|
ORDER BY d.name
|
|
''', (tag_name,))
|
|
|
|
devices = cursor.fetchall()
|
|
|
|
if not devices:
|
|
return jsonify({'devices': [], 'tag_name': tag_name, 'count': 0})
|
|
|
|
if simple_format:
|
|
# Simple format: just name and first IP as clean array
|
|
simple_devices = []
|
|
for device in devices:
|
|
cursor.execute('''
|
|
SELECT ip.ip
|
|
FROM DeviceIPAddress dia
|
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
|
WHERE dia.device_id = %s
|
|
ORDER BY ip.ip
|
|
LIMIT 1
|
|
''', (device['id'],))
|
|
ip_result = cursor.fetchone()
|
|
first_ip = ip_result['ip'] if ip_result else None
|
|
|
|
# Only include devices that have an IP address
|
|
if first_ip:
|
|
simple_devices.append({
|
|
'device': device['name'],
|
|
'ip': first_ip
|
|
})
|
|
|
|
return jsonify(simple_devices)
|
|
else:
|
|
# Full format: complete device information
|
|
for device in devices:
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip, ip.hostname, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site
|
|
FROM DeviceIPAddress dia
|
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
|
JOIN Subnet s ON ip.subnet_id = s.id
|
|
WHERE dia.device_id = %s
|
|
''', (device['id'],))
|
|
device['ip_addresses'] = cursor.fetchall()
|
|
|
|
cursor.execute('''
|
|
SELECT t.id, t.name, t.color
|
|
FROM DeviceTag dtag
|
|
JOIN Tag t ON dtag.tag_id = t.id
|
|
WHERE dtag.device_id = %s
|
|
ORDER BY t.name
|
|
''', (device['id'],))
|
|
device['tags'] = cursor.fetchall()
|
|
|
|
return jsonify({'devices': devices, 'tag_name': tag_name, 'count': len(devices)})
|
|
|
|
# Audit Log API
|
|
@app.route('/api/v1/audit', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_audit')
|
|
def api_audit():
|
|
"""Get audit log entries"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
limit = request.args.get('limit', 100, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
cursor.execute('''
|
|
SELECT al.id, al.user_id, u.name as user_name, al.action, al.details, al.subnet_id, al.timestamp
|
|
FROM AuditLog al
|
|
LEFT JOIN User u ON al.user_id = u.id
|
|
ORDER BY al.timestamp DESC
|
|
LIMIT %s OFFSET %s
|
|
''', (limit, offset))
|
|
logs = cursor.fetchall()
|
|
return jsonify({'logs': logs})
|
|
|
|
# Users API (admin only)
|
|
@app.route('/api/v1/users', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_users')
|
|
def api_users():
|
|
"""Get all users (admin only)"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('''
|
|
SELECT u.id, u.name, u.email, r.id as role_id, r.name as role_name
|
|
FROM User u
|
|
LEFT JOIN Role r ON u.role_id = r.id
|
|
ORDER BY u.name
|
|
''')
|
|
users = cursor.fetchall()
|
|
# Don't return API keys in list
|
|
for user in users:
|
|
user.pop('api_key', None)
|
|
return jsonify({'users': users})
|
|
|
|
# Roles API (admin only)
|
|
@app.route('/api/v1/roles', methods=['GET'])
|
|
@rate_limit("100 per minute")
|
|
@api_permission_required('view_users')
|
|
def api_roles():
|
|
"""Get all roles (admin only)"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name, description FROM Role ORDER BY name')
|
|
roles = cursor.fetchall()
|
|
for role in roles:
|
|
cursor.execute('''
|
|
SELECT p.id, p.name, p.description, p.category
|
|
FROM RolePermission rp
|
|
JOIN Permission p ON rp.permission_id = p.id
|
|
WHERE rp.role_id = %s
|
|
''', (role['id'],))
|
|
role['permissions'] = cursor.fetchall()
|
|
return jsonify({'roles': roles})
|
|
|
|
def get_current_user_name():
|
|
user_id = session.get('user_id')
|
|
if not user_id:
|
|
return ''
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM User WHERE id = %s', (user_id,))
|
|
row = cursor.fetchone()
|
|
return row[0] if row else ''
|
|
|
|
def render_with_user(*args, **kwargs):
|
|
if 'current_user_name' not in kwargs:
|
|
kwargs['current_user_name'] = get_current_user_name()
|
|
return render_template(*args, **kwargs)
|
|
|
|
# Bulk Operations
|
|
@app.route('/bulk', methods=['GET'])
|
|
@permission_required('view_devices')
|
|
def bulk_operations():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if bulk operations feature is enabled
|
|
if not is_feature_enabled('bulk_operations', conn=conn):
|
|
abort(404)
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, name FROM Device ORDER BY name')
|
|
devices = cursor.fetchall()
|
|
cursor.execute('SELECT id, name, cidr, site FROM Subnet ORDER BY site, name')
|
|
subnets = cursor.fetchall()
|
|
|
|
# Get tags only if device tags feature is enabled
|
|
tags_enabled = is_feature_enabled('device_tags', conn=conn)
|
|
tags = []
|
|
if tags_enabled:
|
|
cursor.execute('SELECT id, name FROM Tag ORDER BY name')
|
|
tags = cursor.fetchall()
|
|
|
|
cursor.execute('SELECT id, name FROM DeviceType ORDER BY name')
|
|
device_types = cursor.fetchall()
|
|
return render_with_user('bulk_operations.html',
|
|
devices=devices,
|
|
subnets=subnets,
|
|
tags=tags,
|
|
device_types=device_types,
|
|
can_add_device_ip=has_permission('add_device_ip'),
|
|
can_add_device=has_permission('add_device'),
|
|
can_assign_device_tag=has_permission('assign_device_tag'),
|
|
can_export_subnet_csv=has_permission('export_subnet_csv'))
|
|
|
|
@app.route('/bulk/assign_ips', methods=['POST'])
|
|
@permission_required('add_device_ip')
|
|
def bulk_assign_ips():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if bulk operations feature is enabled
|
|
if not is_feature_enabled('bulk_operations', conn=conn):
|
|
abort(404)
|
|
device_id = request.form['device_id']
|
|
ip_ids = request.form.getlist('ip_ids[]')
|
|
user_name = get_current_user_name()
|
|
results = {'success': [], 'failed': []}
|
|
|
|
from flask import current_app
|
|
subnet_ids_to_invalidate = set()
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device = cursor.fetchone()
|
|
if not device:
|
|
return jsonify({'success': [], 'failed': [{'ip_id': 'all', 'reason': 'Device not found'}]})
|
|
|
|
device_name = device[0]
|
|
|
|
for ip_id in ip_ids:
|
|
try:
|
|
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,))
|
|
ip_row = cursor.fetchone()
|
|
if not ip_row:
|
|
results['failed'].append({'ip_id': ip_id, 'reason': 'IP not found'})
|
|
continue
|
|
|
|
ip, subnet_id = ip_row[0], ip_row[1]
|
|
subnet_ids_to_invalidate.add(subnet_id)
|
|
|
|
# Check if IP is already assigned
|
|
cursor.execute('SELECT id FROM DeviceIPAddress WHERE ip_id = %s', (ip_id,))
|
|
if cursor.fetchone():
|
|
results['failed'].append({'ip_id': ip_id, 'ip': ip, 'reason': 'IP already assigned'})
|
|
continue
|
|
|
|
# Check if IP is in DHCP pool (using exact same logic as device_add_ip)
|
|
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
dhcp_row = cursor.fetchone()
|
|
if dhcp_row:
|
|
start_ip, end_ip, excluded_ips = dhcp_row
|
|
excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x]
|
|
if ip not in excluded_list:
|
|
cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
all_ips = [row[0] for row in cursor.fetchall()]
|
|
in_range = False
|
|
reserved_for_dhcp = False
|
|
for candidate_ip in all_ips:
|
|
if candidate_ip == start_ip:
|
|
in_range = True
|
|
if in_range and candidate_ip == ip:
|
|
reserved_for_dhcp = True
|
|
break
|
|
if candidate_ip == end_ip:
|
|
in_range = False
|
|
if reserved_for_dhcp:
|
|
results['failed'].append({'ip_id': ip_id, 'ip': ip, 'reason': 'IP is reserved for DHCP'})
|
|
continue
|
|
|
|
cursor.execute('INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (%s, %s)', (device_id, ip_id))
|
|
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE id = %s', (device_name, ip_id))
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
subnet_name, subnet_cidr = subnet[0], subnet[1]
|
|
add_audit_log(session['user_id'], 'add_device_ip',
|
|
f"Assigned IP {ip} ({subnet_name} {subnet_cidr}) to device {device_name}",
|
|
subnet_id, conn=conn)
|
|
results['success'].append({'ip_id': ip_id, 'ip': ip})
|
|
except Exception as e:
|
|
results['failed'].append({'ip_id': ip_id, 'reason': str(e)})
|
|
|
|
conn.commit()
|
|
|
|
# Invalidate device and subnet caches
|
|
invalidate_cache_for_device(device_id)
|
|
for subnet_id in subnet_ids_to_invalidate:
|
|
cache.invalidate_subnet(subnet_id)
|
|
return jsonify(results)
|
|
|
|
@app.route('/bulk/create_devices', methods=['POST'])
|
|
@permission_required('add_device')
|
|
def bulk_create_devices():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if bulk operations feature is enabled
|
|
if not is_feature_enabled('bulk_operations', conn=conn):
|
|
abort(404)
|
|
device_names = request.form.get('device_names', '').strip().split('\n')
|
|
device_type_id = int(request.form.get('device_type', 1))
|
|
user_name = get_current_user_name()
|
|
results = {'success': [], 'failed': []}
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
for name in device_names:
|
|
name = name.strip()
|
|
if not name:
|
|
continue
|
|
try:
|
|
cursor.execute('INSERT INTO Device (name, device_type_id) VALUES (%s, %s)', (name, device_type_id))
|
|
device_id = cursor.lastrowid
|
|
add_audit_log(session['user_id'], 'add_device', f"Added device {name}", conn=conn)
|
|
results['success'].append({'name': name, 'id': device_id})
|
|
except Exception as e:
|
|
results['failed'].append({'name': name, 'reason': str(e)})
|
|
conn.commit()
|
|
|
|
# Invalidate devices cache
|
|
cache.clear('devices')
|
|
cache.clear('device_list')
|
|
logging.info(f"User {user_name} bulk created {len(results['success'])} devices.")
|
|
return jsonify(results)
|
|
|
|
@app.route('/bulk/assign_tags', methods=['POST'])
|
|
@permission_required('assign_device_tag')
|
|
def bulk_assign_tags():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if bulk operations feature is enabled
|
|
if not is_feature_enabled('bulk_operations', conn=conn):
|
|
abort(404)
|
|
# Check if device tags feature is enabled
|
|
if not is_feature_enabled('device_tags', conn=conn):
|
|
abort(404)
|
|
device_ids = request.form.getlist('device_ids[]')
|
|
tag_ids = request.form.getlist('tag_ids[]')
|
|
user_name = get_current_user_name()
|
|
results = {'success': [], 'failed': []}
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
for device_id in device_ids:
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device = cursor.fetchone()
|
|
if not device:
|
|
results['failed'].append({'device_id': device_id, 'reason': 'Device not found'})
|
|
continue
|
|
device_name = device[0]
|
|
|
|
for tag_id in tag_ids:
|
|
try:
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag = cursor.fetchone()
|
|
if not tag:
|
|
continue
|
|
tag_name = tag[0]
|
|
|
|
cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
|
|
if cursor.fetchone():
|
|
continue # Already assigned
|
|
|
|
cursor.execute('INSERT INTO DeviceTag (device_id, tag_id) VALUES (%s, %s)', (device_id, tag_id))
|
|
add_audit_log(session['user_id'], 'assign_device_tag',
|
|
f"Assigned tag '{tag_name}' to device '{device_name}'", conn=conn)
|
|
results['success'].append({'device_id': device_id, 'device_name': device_name, 'tag_id': tag_id, 'tag_name': tag_name})
|
|
except Exception as e:
|
|
results['failed'].append({'device_id': device_id, 'tag_id': tag_id, 'reason': str(e)})
|
|
conn.commit()
|
|
|
|
# Invalidate device caches for all affected devices
|
|
for device_id in device_ids:
|
|
invalidate_cache_for_device(device_id)
|
|
cache.clear('devices')
|
|
logging.info(f"User {user_name} bulk assigned tags to {len(device_ids)} devices.")
|
|
return jsonify(results)
|
|
|
|
@app.route('/bulk/export_subnets', methods=['POST'])
|
|
@permission_required('export_subnet_csv')
|
|
def bulk_export_subnets():
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
# Check if bulk operations feature is enabled
|
|
if not is_feature_enabled('bulk_operations', conn=conn):
|
|
abort(404)
|
|
subnet_ids = request.form.getlist('subnet_ids[]')
|
|
from flask import current_app
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
for subnet_id in subnet_ids:
|
|
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
if not subnet:
|
|
continue
|
|
|
|
writer.writerow([f"Subnet: {subnet[1]} ({subnet[2]})"])
|
|
writer.writerow(['IP Address', 'Hostname', 'Description'])
|
|
|
|
cursor.execute('SELECT * FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
|
ip_addresses = cursor.fetchall()
|
|
cursor.execute('SELECT id, name, description FROM Device')
|
|
devices = cursor.fetchall()
|
|
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
|
|
|
|
for ip in ip_addresses:
|
|
hostname = ip[2]
|
|
device_description = None
|
|
if hostname:
|
|
match = device_name_map.get(hostname.lower())
|
|
if match:
|
|
device_description = match[1]
|
|
writer.writerow([ip[1] or '', hostname or '', device_description or ''])
|
|
|
|
writer.writerow([]) # Empty row between subnets
|
|
|
|
output.seek(0)
|
|
return send_file(BytesIO(output.getvalue().encode('utf-8')),
|
|
mimetype='text/csv',
|
|
as_attachment=True,
|
|
download_name='bulk_subnet_export.csv')
|
|
|
|
# API key regeneration route
|
|
@app.route('/regenerate_api_key', methods=['POST'])
|
|
@permission_required('manage_users')
|
|
def regenerate_api_key():
|
|
user_id = request.form['user_id']
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
new_api_key = generate_api_key()
|
|
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (new_api_key, user_id))
|
|
conn.commit()
|
|
return redirect(url_for('users'))
|
|
|
|
app.add_url_rule('/login', 'login', login, methods=['GET', 'POST'])
|
|
app.add_url_rule('/logout', 'logout', logout)
|
|
app.add_url_rule('/', 'index', index)
|
|
app.add_url_rule('/devices', 'devices', devices)
|
|
app.add_url_rule('/add_device', 'add_device', add_device, methods=['GET', 'POST'])
|
|
app.add_url_rule('/device/<int:device_id>', 'device', device)
|
|
app.add_url_rule('/device/<int:device_id>/add_ip', 'device_add_ip', device_add_ip, methods=['POST'])
|
|
app.add_url_rule('/device/<int:device_id>/delete_ip', 'device_delete_ip', device_delete_ip, methods=['POST'])
|
|
app.add_url_rule('/device/<int:device_id>/assign_tag', 'device_assign_tag', device_assign_tag, methods=['POST'])
|
|
app.add_url_rule('/device/<int:device_id>/remove_tag', 'device_remove_tag', device_remove_tag, methods=['POST'])
|
|
app.add_url_rule('/delete_device', 'delete_device', delete_device, methods=['POST'])
|
|
app.add_url_rule('/subnet/<int:subnet_id>', 'subnet', subnet)
|
|
app.add_url_rule('/add_subnet', 'add_subnet', add_subnet, methods=['POST'])
|
|
app.add_url_rule('/edit_subnet', 'edit_subnet', edit_subnet, methods=['POST'])
|
|
app.add_url_rule('/delete_subnet', 'delete_subnet', delete_subnet, methods=['POST'])
|
|
app.add_url_rule('/admin', 'admin', admin, methods=['GET', 'POST'])
|
|
app.add_url_rule('/users', 'users', users, methods=['GET', 'POST'])
|
|
app.add_url_rule('/tags', 'tags', tags, methods=['GET', 'POST'])
|
|
app.add_url_rule('/audit', 'audit', audit)
|
|
app.add_url_rule('/get_available_ips', 'get_available_ips', get_available_ips)
|
|
app.add_url_rule('/rename_device', 'rename_device', rename_device, methods=['POST'])
|
|
app.add_url_rule('/update_device_description', 'update_device_description', update_device_description, methods=['POST'])
|
|
app.add_url_rule('/subnet/<int:subnet_id>/export_csv', 'export_subnet_csv', export_subnet_csv)
|
|
app.add_url_rule('/subnet/<int:subnet_id>/dhcp', 'dhcp_pool', dhcp_pool, methods=['GET', 'POST'])
|
|
app.add_url_rule('/device_type_stats', 'device_type_stats', device_type_stats)
|
|
app.add_url_rule('/device_types', 'device_types', device_types, methods=['GET', 'POST'])
|
|
app.add_url_rule('/devices/type/<device_type>', 'devices_by_type', devices_by_type)
|
|
app.add_url_rule('/devices/tag/<int:tag_id>', 'devices_by_tag', devices_by_tag)
|
|
app.add_url_rule('/racks', 'racks', racks)
|
|
app.add_url_rule('/rack/add', 'add_rack', add_rack, methods=['GET', 'POST'])
|
|
app.add_url_rule('/rack/<int:rack_id>', 'rack', rack)
|
|
app.add_url_rule('/rack/<int:rack_id>/add_device', 'rack_add_device', rack_add_device, methods=['POST'])
|
|
app.add_url_rule('/rack/<int:rack_id>/add_nonnet_device', 'rack_add_nonnet_device', rack_add_nonnet_device, methods=['POST'])
|
|
app.add_url_rule('/rack/<int:rack_id>/remove_device', 'rack_remove_device', rack_remove_device, methods=['POST'])
|
|
app.add_url_rule('/rack/<int:rack_id>/delete', 'delete_rack', delete_rack, methods=['POST'])
|
|
app.add_url_rule('/rack/<int:rack_id>/export_csv', 'export_rack_csv', export_rack_csv)
|
|
app.add_url_rule('/search', 'search', search)
|
|
app.add_url_rule('/help', 'help', help)
|
|
app.add_url_rule('/backup', 'backup', backup, methods=['GET', 'POST'])
|
|
app.add_url_rule('/backup/create', 'create_backup', create_backup, methods=['POST'])
|
|
app.add_url_rule('/backup/download/<filename>', 'download_backup', download_backup)
|
|
app.add_url_rule('/backup/restore', 'restore_backup', restore_backup, methods=['POST'])
|
|
app.add_url_rule('/backup/delete/<filename>', 'delete_backup', delete_backup, methods=['POST'])
|
|
app.add_url_rule('/backup/create', 'create_backup', create_backup, methods=['POST'])
|
|
app.add_url_rule('/backup/download/<filename>', 'download_backup', download_backup)
|
|
app.add_url_rule('/backup/restore', 'restore_backup', restore_backup, methods=['POST'])
|
|
app.add_url_rule('/backup/delete/<filename>', 'delete_backup', delete_backup, methods=['POST']) |