3343 lines
135 KiB
Python
3343 lines
135 KiB
Python
# ── Imports ───────────────────────────────────────────────────────────────────
|
|
import os
|
|
import re
|
|
import csv
|
|
import json
|
|
import shutil
|
|
import hashlib
|
|
import base64
|
|
import secrets
|
|
import logging
|
|
from io import StringIO, BytesIO
|
|
from datetime import datetime
|
|
from functools import wraps
|
|
from urllib.parse import urlencode, urlparse
|
|
from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
|
|
|
|
import pyotp
|
|
import qrcode
|
|
import mysql.connector
|
|
from dotenv import load_dotenv
|
|
from flask import (
|
|
Flask, session, request, abort, jsonify, redirect,
|
|
send_from_directory, send_file, current_app,
|
|
)
|
|
from werkzeug.utils import safe_join
|
|
|
|
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
|
load_dotenv()
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
# ── Config & app creation ─────────────────────────────────────────────────────
|
|
app = Flask(__name__)
|
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'changeme')
|
|
app.config['MYSQL_HOST'] = os.environ.get('MYSQL_HOST', 'localhost')
|
|
app.config['MYSQL_USER'] = os.environ.get('MYSQL_USER', 'user')
|
|
app.config['MYSQL_PASSWORD'] = os.environ.get('MYSQL_PASSWORD', 'password')
|
|
app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam')
|
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
|
app.config['NAME'] = os.environ.get('NAME', 'JDB-NET')
|
|
app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/projects/ipam.png')
|
|
app.config['VERSION'] = os.environ.get('VERSION', 'unknown')
|
|
app.config['LOGTO_OIDC_ENDPOINT'] = os.environ.get('LOGTO_OIDC_ENDPOINT', '')
|
|
app.config['LOGTO_APP_ID'] = os.environ.get('LOGTO_APP_ID', '')
|
|
app.config['LOGTO_APP_SECRET'] = os.environ.get('LOGTO_APP_SECRET', '')
|
|
|
|
from db import (
|
|
init_db, hash_password, get_db_connection, verify_password, generate_api_key,
|
|
get_app_settings, update_app_settings,
|
|
)
|
|
from logto_client import (
|
|
logto_sso_available, init_logto_oauth, oauth, extract_logto_email,
|
|
)
|
|
|
|
init_logto_oauth(app)
|
|
|
|
# ── TOTP / 2FA helpers ───────────────────────────────────────────────────────
|
|
def generate_totp_secret():
|
|
"""Generate a new TOTP secret"""
|
|
return pyotp.random_base32()
|
|
|
|
def get_totp_uri(secret, email, issuer_name="IPAM"):
|
|
"""Generate TOTP URI for QR code"""
|
|
totp = pyotp.TOTP(secret)
|
|
return totp.provisioning_uri(
|
|
name=email,
|
|
issuer_name=issuer_name
|
|
)
|
|
|
|
def generate_qr_code(uri):
|
|
"""Generate QR code image from URI"""
|
|
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
|
qr.add_data(uri)
|
|
qr.make(fit=True)
|
|
img = qr.make_image(fill_color="black", back_color="white")
|
|
buffer = BytesIO()
|
|
img.save(buffer, format='PNG')
|
|
buffer.seek(0)
|
|
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
|
|
def verify_totp(secret, code):
|
|
"""Verify a TOTP code"""
|
|
if not secret or not code:
|
|
return False
|
|
try:
|
|
totp = pyotp.TOTP(secret)
|
|
return totp.verify(code, valid_window=1) # Allow 1 time step window for clock skew
|
|
except Exception:
|
|
return False
|
|
|
|
def generate_backup_codes(count=10):
|
|
"""Generate backup codes for 2FA"""
|
|
return [secrets.token_urlsafe(8).upper() for _ in range(count)]
|
|
|
|
def verify_backup_code(backup_codes_json, code):
|
|
"""Verify a backup code and remove it if valid"""
|
|
if not backup_codes_json or not code:
|
|
return False, None
|
|
|
|
try:
|
|
codes = json.loads(backup_codes_json)
|
|
code_upper = code.upper().strip()
|
|
if code_upper in codes:
|
|
codes.remove(code_upper)
|
|
return True, json.dumps(codes) if codes else None
|
|
return False, None
|
|
except (json.JSONDecodeError, AttributeError):
|
|
return False, None
|
|
|
|
def format_backup_codes(codes):
|
|
"""Format backup codes for display (group in pairs)"""
|
|
formatted = []
|
|
for i in range(0, len(codes), 2):
|
|
if i + 1 < len(codes):
|
|
formatted.append(f"{codes[i]} {codes[i+1]}")
|
|
else:
|
|
formatted.append(codes[i])
|
|
return formatted
|
|
|
|
# ── Auth & permissions ────────────────────────────────────────────────────────
|
|
|
|
def _api_key_from_request():
|
|
if 'X-API-Key' in request.headers:
|
|
return request.headers['X-API-Key']
|
|
if 'api_key' in request.args:
|
|
return request.args.get('api_key')
|
|
auth_header = request.headers.get('Authorization', '')
|
|
if auth_header.startswith('Bearer '):
|
|
return auth_header[7:]
|
|
return None
|
|
|
|
|
|
def load_permissions_for_user(user_id, conn):
|
|
"""Return the set of permission names granted to a user via their role."""
|
|
cursor = conn.cursor()
|
|
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 set()
|
|
cursor.execute('''
|
|
SELECT p.name FROM RolePermission rp
|
|
JOIN Permission p ON rp.permission_id = p.id
|
|
WHERE rp.role_id = %s
|
|
''', (role_result[0],))
|
|
return {row[0] for row in cursor.fetchall()}
|
|
|
|
|
|
def _user_record_from_row(row, conn):
|
|
user = {
|
|
'id': row[0],
|
|
'name': row[1],
|
|
'email': row[2],
|
|
'role_id': row[3],
|
|
}
|
|
user['permissions'] = load_permissions_for_user(user['id'], conn)
|
|
return user
|
|
|
|
|
|
def get_user_from_api_key(api_key):
|
|
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 _user_record_from_row(result, conn)
|
|
return None
|
|
|
|
|
|
def establish_user_session(user_id, conn=None, via_sso=False):
|
|
"""Populate session after successful authentication."""
|
|
close_conn = False
|
|
if conn is None:
|
|
conn = get_db_connection(current_app)
|
|
close_conn = True
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name, email FROM User WHERE id = %s', (user_id,))
|
|
row = cursor.fetchone()
|
|
session['user_name'] = row[0] if row else ''
|
|
session['user_email'] = row[1] if row else ''
|
|
session['permissions'] = list(load_permissions_for_user(user_id, conn))
|
|
session['logged_in'] = True
|
|
session['user_id'] = user_id
|
|
session['auth_via_sso'] = bool(via_sso)
|
|
if via_sso:
|
|
session.pop('pending_user_id', None)
|
|
session.pop('pending_email', None)
|
|
session.modified = True
|
|
finally:
|
|
if close_conn:
|
|
conn.close()
|
|
|
|
|
|
def resolve_auth():
|
|
"""Return authenticated user dict or None (session or API key)."""
|
|
if session.get('logged_in') and session.get('user_id'):
|
|
return {
|
|
'id': session['user_id'],
|
|
'name': session.get('user_name', ''),
|
|
'email': session.get('user_email', ''),
|
|
'permissions': set(session.get('permissions', [])),
|
|
'auth_type': 'session',
|
|
}
|
|
api_key = _api_key_from_request()
|
|
if api_key:
|
|
user = get_user_from_api_key(api_key)
|
|
if user:
|
|
user['auth_type'] = 'api_key'
|
|
return user
|
|
return None
|
|
|
|
|
|
def current_user():
|
|
return getattr(request, 'current_user', None)
|
|
|
|
|
|
def has_permission(permission_name, user=None):
|
|
user = user or current_user()
|
|
if not user:
|
|
return False
|
|
perms = user.get('permissions')
|
|
if isinstance(perms, set):
|
|
return permission_name in perms
|
|
if isinstance(perms, list):
|
|
return permission_name in perms
|
|
return False
|
|
|
|
|
|
def require_permission(permission_name):
|
|
"""Decorator: session cookie or API key; enforces RBAC permission."""
|
|
def decorator(f):
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
user = resolve_auth()
|
|
if not user:
|
|
return jsonify({'error': 'Unauthorized'}), 401
|
|
if permission_name and not has_permission(permission_name, user):
|
|
return jsonify({'error': 'Permission denied', 'permission': permission_name}), 403
|
|
request.current_user = user
|
|
response = f(*args, **kwargs)
|
|
if user.get('auth_type') == 'api_key' and request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
|
|
try:
|
|
status_code = response.status_code if hasattr(response, 'status_code') else (
|
|
response[1] if isinstance(response, tuple) and len(response) > 1 else None
|
|
)
|
|
details = f"API call: {request.method} {request.path}"
|
|
if status_code:
|
|
details += f" (Status: {status_code})"
|
|
add_audit_log(user['id'], 'api_usage', details, subnet_id=None)
|
|
except Exception as e:
|
|
logging.error(f"Failed to log API usage: {e}")
|
|
return response
|
|
return decorated_function
|
|
return decorator
|
|
|
|
|
|
def require_auth(f):
|
|
"""Decorator: authenticated only (no specific permission)."""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
user = resolve_auth()
|
|
if not user:
|
|
return jsonify({'error': 'Unauthorized'}), 401
|
|
request.current_user = user
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
|
|
def require_sso_available(f):
|
|
"""Decorator: SSO subsystem must be enabled via environment variables."""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
if not logto_sso_available():
|
|
abort(404)
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
|
|
def _sso_oidc_callback_url():
|
|
return f"{request.scheme}://{request.host}/api/v2/auth/sso/callback"
|
|
|
|
|
|
def _me_payload(user=None):
|
|
payload = {
|
|
'app_version': app.config['VERSION'],
|
|
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
|
|
'features': {'sso': logto_sso_available()},
|
|
}
|
|
if user:
|
|
payload.update({
|
|
'logged_in': True,
|
|
'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')},
|
|
'permissions': sorted(user.get('permissions') or []),
|
|
})
|
|
else:
|
|
payload['logged_in'] = False
|
|
return payload
|
|
|
|
|
|
def json_body():
|
|
return request.get_json(silent=True) or {}
|
|
|
|
|
|
def items_response(items):
|
|
return jsonify({'items': items})
|
|
|
|
|
|
def build_audit_filters():
|
|
"""Build WHERE clause and params for audit log queries from request args."""
|
|
clauses = []
|
|
params = []
|
|
user = request.args.get('user', '').strip()
|
|
if user:
|
|
clauses.append('u.name LIKE %s')
|
|
params.append(f'%{user}%')
|
|
action = request.args.get('action', '').strip()
|
|
if action:
|
|
clauses.append('al.action = %s')
|
|
params.append(action)
|
|
from_date = request.args.get('from', '').strip()
|
|
if from_date:
|
|
clauses.append('al.timestamp >= %s')
|
|
params.append(f'{from_date} 00:00:00')
|
|
to_date = request.args.get('to', '').strip()
|
|
if to_date:
|
|
clauses.append('al.timestamp <= %s')
|
|
params.append(f'{to_date} 23:59:59')
|
|
where_sql = ('WHERE ' + ' AND '.join(clauses)) if clauses else ''
|
|
return where_sql, params
|
|
|
|
|
|
def get_current_user_id():
|
|
user = current_user()
|
|
return user['id'] if user else session.get('user_id')
|
|
|
|
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 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)
|
|
|
|
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:
|
|
return []
|
|
|
|
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}%')
|
|
|
|
if device_id and device_name:
|
|
query += ' AND al.details LIKE %s'
|
|
params.append(f'%device {device_name}%')
|
|
|
|
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 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
|
|
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()
|
|
|
|
# ── Data helpers (queries & business logic) ───────────────────────────────────
|
|
|
|
def get_subnet_utilization(cursor, subnet_id, include_available=False):
|
|
"""Return utilization stats for a single 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]
|
|
|
|
used_ips = assigned_ips + dhcp_ips
|
|
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
|
|
stats = {
|
|
'total': total_ips,
|
|
'assigned': assigned_ips,
|
|
'dhcp': dhcp_ips,
|
|
'used': used_ips,
|
|
'percent': round(utilization_percent, 1),
|
|
}
|
|
if include_available:
|
|
stats['available'] = total_ips - assigned_ips - dhcp_ips
|
|
return stats
|
|
|
|
|
|
def get_all_subnet_utilizations(cursor):
|
|
"""Return utilization stats keyed by subnet_id."""
|
|
cursor.execute('''
|
|
SELECT ip.subnet_id,
|
|
COUNT(*) AS total,
|
|
SUM(CASE WHEN dia.ip_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
|
|
SUM(CASE WHEN dia.ip_id IS NULL AND ip.hostname = 'DHCP' THEN 1 ELSE 0 END) AS dhcp
|
|
FROM IPAddress ip
|
|
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
|
GROUP BY ip.subnet_id
|
|
''')
|
|
result = {}
|
|
for row in cursor.fetchall():
|
|
if isinstance(row, dict):
|
|
subnet_id = row['subnet_id']
|
|
total = int(row['total'])
|
|
assigned = int(row['assigned'])
|
|
dhcp = int(row['dhcp'])
|
|
else:
|
|
subnet_id, total, assigned, dhcp = row
|
|
total = int(total)
|
|
assigned = int(assigned)
|
|
dhcp = int(dhcp)
|
|
used = assigned + dhcp
|
|
result[subnet_id] = {
|
|
'total': total,
|
|
'assigned': assigned,
|
|
'dhcp': dhcp,
|
|
'used': used,
|
|
'percent': round((used / total * 100) if total > 0 else 0, 1),
|
|
}
|
|
return result
|
|
|
|
|
|
def get_dhcp_pool(cursor, subnet_id):
|
|
cursor.execute(
|
|
'SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s',
|
|
(subnet_id,),
|
|
)
|
|
return cursor.fetchone()
|
|
|
|
|
|
def is_ip_dhcp_reserved(cursor, subnet_id, ip):
|
|
dhcp_row = get_dhcp_pool(cursor, subnet_id)
|
|
if not dhcp_row:
|
|
return False
|
|
start_ip, end_ip, excluded_ips = dhcp_row
|
|
excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x]
|
|
if ip in excluded_list:
|
|
return False
|
|
in_range = False
|
|
cursor.execute(
|
|
'SELECT ip FROM IPAddress WHERE subnet_id = %s ORDER BY INET_ATON(ip)',
|
|
(subnet_id,),
|
|
)
|
|
for (pool_ip,) in cursor.fetchall():
|
|
if pool_ip == start_ip:
|
|
in_range = True
|
|
if in_range and pool_ip not in excluded_list and pool_ip == ip:
|
|
return True
|
|
if pool_ip == end_ip:
|
|
in_range = False
|
|
return False
|
|
|
|
|
|
def filter_ips_outside_dhcp(cursor, subnet_id, ips):
|
|
"""Filter list of {id, ip} dicts, removing IPs inside DHCP pool range."""
|
|
dhcp_row = get_dhcp_pool(cursor, subnet_id)
|
|
if not dhcp_row:
|
|
return ips
|
|
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 = []
|
|
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.append(ip_obj)
|
|
if ip == end_ip:
|
|
in_range = False
|
|
return filtered
|
|
|
|
|
|
def get_subnet_name_cidr(cursor, subnet_id):
|
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
row = cursor.fetchone()
|
|
return (row[0], row[1]) if row else (None, None)
|
|
|
|
|
|
def get_device_name(cursor, device_id):
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
row = cursor.fetchone()
|
|
return row[0] if row else None
|
|
|
|
|
|
def normalize_site(site):
|
|
return site if site else 'Unassigned'
|
|
|
|
|
|
def assign_ip_to_device(conn, device_id, ip_id, user_id):
|
|
"""Assign an IP to a device. Raises ValueError on failure."""
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,))
|
|
ip_row = cursor.fetchone()
|
|
if not ip_row:
|
|
raise ValueError('IP not found')
|
|
ip, subnet_id = ip_row[0], ip_row[1]
|
|
|
|
cursor.execute('SELECT site FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet_row = cursor.fetchone()
|
|
if not subnet_row:
|
|
raise ValueError('Subnet not found')
|
|
new_site = normalize_site(subnet_row[0])
|
|
|
|
cursor.execute('''
|
|
SELECT DISTINCT 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,))
|
|
existing_sites = {normalize_site(row[0]) for row in cursor.fetchall()}
|
|
if existing_sites and new_site not in existing_sites:
|
|
if len(existing_sites) == 1:
|
|
home = next(iter(existing_sites))
|
|
raise ValueError(
|
|
f'Device is homed at site "{home}"; cannot assign an IP from site "{new_site}"'
|
|
)
|
|
raise ValueError(
|
|
f'Device already has IPs at other sites; cannot assign an IP from site "{new_site}"'
|
|
)
|
|
|
|
cursor.execute('SELECT id FROM DeviceIPAddress WHERE ip_id = %s', (ip_id,))
|
|
if cursor.fetchone():
|
|
raise ValueError('IP already assigned')
|
|
|
|
if is_ip_dhcp_reserved(cursor, subnet_id, ip):
|
|
raise ValueError('IP is reserved for DHCP')
|
|
|
|
device_name = get_device_name(cursor, device_id)
|
|
if not device_name:
|
|
raise ValueError('Device not found')
|
|
|
|
subnet_name, subnet_cidr = get_subnet_name_cidr(cursor, subnet_id)
|
|
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))
|
|
details = f"Assigned IP {ip} ({subnet_name} {subnet_cidr}) to device {device_name}"
|
|
add_audit_log(user_id, 'device_add_ip', details, subnet_id, conn=conn)
|
|
return ip
|
|
|
|
|
|
def remove_ip_from_device(conn, device_ip_id, user_id):
|
|
"""Remove an IP assignment. Returns (ip, device_name) or raises ValueError."""
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT dia.device_id, 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.id = %s
|
|
''', (device_ip_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
raise ValueError('Assignment not found')
|
|
_device_id, ip, subnet_id, device_name = row
|
|
subnet_name, subnet_cidr = get_subnet_name_cidr(cursor, subnet_id)
|
|
cursor.execute('DELETE FROM DeviceIPAddress WHERE id = %s', (device_ip_id,))
|
|
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE ip = %s', (ip,))
|
|
details = f"Removed IP {ip} ({subnet_name} {subnet_cidr}) from device {device_name}"
|
|
add_audit_log(user_id, 'device_delete_ip', details, subnet_id, conn=conn)
|
|
return ip, device_name
|
|
|
|
|
|
def delete_device_record(conn, device_id, user_id):
|
|
"""Delete device and clear IP hostnames. Returns device name or None."""
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
|
device_row = cursor.fetchone()
|
|
if not device_row:
|
|
return None
|
|
device_name = device_row[0]
|
|
add_audit_log(user_id, 'delete_device', f"Deleted device {device_name}", conn=conn)
|
|
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', [(i,) for i in ip_ids])
|
|
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
|
|
cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,))
|
|
return device_name
|
|
|
|
|
|
def create_subnet_from_cidr(conn, name, cidr, site, vlan_id, vlan_description, vlan_notes, user_id):
|
|
"""Create subnet and populate IPAddress rows. Returns subnet_id."""
|
|
network = ip_network(cidr, strict=False)
|
|
if network.prefixlen < 24:
|
|
raise ValueError('Subnet must be /24 or smaller')
|
|
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 or None, vlan_notes or None),
|
|
)
|
|
subnet_id = cursor.lastrowid
|
|
hosts = list(network.hosts())
|
|
if hosts:
|
|
cursor.executemany(
|
|
'INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)',
|
|
[(str(h), subnet_id) for h in hosts],
|
|
)
|
|
add_audit_log(user_id, 'add_subnet', f"Added subnet {name} ({cidr})", subnet_id, conn=conn)
|
|
return subnet_id
|
|
|
|
|
|
def build_audit_filter_clause(request_args):
|
|
"""Build SQL AND-clause fragment and params for audit list/export queries."""
|
|
conditions = []
|
|
params = []
|
|
user_ids = request_args.getlist('user_ids') if hasattr(request_args, 'getlist') else []
|
|
if user_ids:
|
|
placeholders = ','.join(['%s'] * len(user_ids))
|
|
conditions.append(f'AuditLog.user_id IN ({placeholders})')
|
|
params.extend(user_ids)
|
|
subnet_id = request_args.get('subnet_id')
|
|
if subnet_id:
|
|
conditions.append('AuditLog.subnet_id = %s')
|
|
params.append(subnet_id)
|
|
action = request_args.get('action')
|
|
if action:
|
|
conditions.append('AuditLog.action = %s')
|
|
params.append(action)
|
|
device_name = request_args.get('device_name')
|
|
if device_name:
|
|
conditions.append('AuditLog.details LIKE %s')
|
|
params.append(f'%{device_name}%')
|
|
date_from = request_args.get('date_from')
|
|
if date_from:
|
|
conditions.append('AuditLog.timestamp >= %s')
|
|
params.append(date_from)
|
|
date_to = request_args.get('date_to')
|
|
if date_to:
|
|
conditions.append('AuditLog.timestamp <= %s')
|
|
params.append(date_to + ' 23:59:59')
|
|
search_query = request_args.get('search', '')
|
|
if hasattr(request_args, 'get'):
|
|
search_query = (search_query or '').strip()
|
|
if search_query:
|
|
conditions.append(
|
|
"(AuditLog.details LIKE %s OR COALESCE(User.name, '') LIKE %s "
|
|
"OR AuditLog.action LIKE %s OR COALESCE(Subnet.name, '') LIKE %s)"
|
|
)
|
|
pattern = f'%{search_query}%'
|
|
params.extend([pattern, pattern, pattern, pattern])
|
|
where = (' AND ' + ' AND '.join(conditions)) if conditions else ''
|
|
return where, params
|
|
|
|
|
|
def configure_dhcp_pool(cursor, subnet_id, start_ip, end_ip, excluded_ips, subnet_name, subnet_cidr, user_id, conn, is_update):
|
|
"""Create or update a DHCP pool and mark IPs as DHCP-reserved."""
|
|
excluded_ips = (excluded_ips or '').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:
|
|
raise ValueError('Start and End IP must be within the subnet.')
|
|
cursor.execute('UPDATE IPAddress SET hostname=NULL WHERE subnet_id=%s AND hostname="DHCP"', (subnet_id,))
|
|
if is_update:
|
|
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_name} ({subnet_cidr}): {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_name} ({subnet_cidr}): {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
|
|
add_audit_log(user_id, action, details, subnet_id, conn=conn)
|
|
return {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_ips}
|
|
|
|
|
|
def remove_dhcp_pool(cursor, subnet_id, subnet_name, subnet_cidr, user_id, conn):
|
|
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,))
|
|
add_audit_log(user_id, 'dhcp_pool_remove', f"Removed DHCP pool for subnet {subnet_name} ({subnet_cidr})", subnet_id, conn=conn)
|
|
|
|
|
|
def assign_tag_to_device(conn, device_id, tag_id, user_id):
|
|
"""Assign tag to device. Returns (device_name, tag_name) or raises ValueError."""
|
|
cursor = conn.cursor()
|
|
device_name = get_device_name(cursor, device_id)
|
|
if not device_name:
|
|
raise ValueError('Device not found')
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag_row = cursor.fetchone()
|
|
if not tag_row:
|
|
raise ValueError('Tag not found')
|
|
tag_name = tag_row[0]
|
|
cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
|
|
if cursor.fetchone():
|
|
raise ValueError('Tag already assigned')
|
|
cursor.execute('INSERT INTO DeviceTag (device_id, tag_id) VALUES (%s, %s)', (device_id, tag_id))
|
|
add_audit_log(user_id, 'assign_device_tag', f"Assigned tag '{tag_name}' to device '{device_name}'", conn=conn)
|
|
return device_name, tag_name
|
|
|
|
|
|
def remove_tag_from_device(conn, device_id, tag_id, user_id):
|
|
"""Remove tag from device. Returns (device_name, tag_name) or raises ValueError."""
|
|
cursor = conn.cursor()
|
|
device_name = get_device_name(cursor, device_id)
|
|
if not device_name:
|
|
raise ValueError('Device not found')
|
|
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
|
tag_row = cursor.fetchone()
|
|
if not tag_row:
|
|
raise ValueError('Tag not found')
|
|
tag_name = tag_row[0]
|
|
cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
|
|
if not cursor.fetchone():
|
|
raise ValueError('Tag not assigned')
|
|
cursor.execute('DELETE FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
|
|
add_audit_log(user_id, 'remove_device_tag', f"Removed tag '{tag_name}' from device '{device_name}'", conn=conn)
|
|
return device_name, tag_name
|
|
|
|
|
|
def update_entity_custom_fields(conn, entity_type, entity_id, submitted_data, user_id, is_json=False):
|
|
"""Validate and save custom field values. Returns list of errors (empty if ok)."""
|
|
table = 'Device' if entity_type == 'device' else 'Subnet'
|
|
audit_action = f'update_{entity_type}_custom_fields'
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute(
|
|
'SELECT id, field_key, field_type, required, validation_rules FROM CustomFieldDefinition WHERE entity_type = %s',
|
|
(entity_type,),
|
|
)
|
|
field_defs = {f['field_key']: f for f in cursor.fetchall()}
|
|
new_values = {}
|
|
errors = []
|
|
for field_key, field_def in field_defs.items():
|
|
if is_json:
|
|
submitted_value = submitted_data.get(field_key, '')
|
|
else:
|
|
submitted_value = submitted_data.get(f'custom_field_{field_key}', '')
|
|
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
|
|
if submitted_value == '' and not field_def.get('required'):
|
|
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 errors
|
|
cursor.execute(f'UPDATE {table} SET custom_fields = %s WHERE id = %s', (json.dumps(new_values), entity_id))
|
|
add_audit_log(user_id, audit_action, f"Updated custom fields for {entity_type} {entity_id}", conn=conn)
|
|
return []
|
|
|
|
|
|
def build_validation_rules_from_form(form, field_type=None):
|
|
"""Build validation_rules JSON from custom field form data."""
|
|
rules = {}
|
|
if field_type is None or field_type in ('text', 'textarea'):
|
|
if form.get('min_length'):
|
|
rules['min_length'] = int(form['min_length'])
|
|
if form.get('max_length'):
|
|
rules['max_length'] = int(form['max_length'])
|
|
if form.get('regex_pattern'):
|
|
rules['regex_pattern'] = form['regex_pattern']
|
|
if field_type is None or field_type in ('number', 'decimal'):
|
|
if form.get('min_value'):
|
|
rules['min_value'] = float(form['min_value'])
|
|
if form.get('max_value'):
|
|
rules['max_value'] = float(form['max_value'])
|
|
if field_type is None or field_type == 'select':
|
|
if form.get('select_options'):
|
|
rules['select_options'] = [o.strip() for o in form['select_options'].split(',') if o.strip()]
|
|
return json.dumps(rules) if rules else None
|
|
|
|
|
|
def load_rack_view(conn, rack_id, side='front', networked_only=True):
|
|
"""Load rack page context: rack dict, rack_devices, site_devices."""
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT * FROM Rack WHERE id = %s', (rack_id,))
|
|
rack = cursor.fetchone()
|
|
if not rack:
|
|
return None
|
|
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:
|
|
fmt = ','.join(['%s'] * len(device_ids))
|
|
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({fmt})', 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']
|
|
if networked_only:
|
|
cursor.execute('''
|
|
SELECT DISTINCT Device.id, Device.name, 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 Subnet.site = %s
|
|
''', (rack['site'],))
|
|
else:
|
|
cursor.execute('SELECT id, name, description FROM Device')
|
|
site_devices = cursor.fetchall()
|
|
return {'rack': rack, 'rack_devices': rack_devices, 'site_devices': site_devices, 'current_side': side}
|
|
|
|
|
|
def enrich_devices_batch(cursor, devices):
|
|
"""Attach ip_addresses, tags, custom_fields to device dicts in batch."""
|
|
if not devices:
|
|
return devices
|
|
device_ids = [d['id'] for d in devices]
|
|
placeholders = ','.join(['%s'] * len(device_ids))
|
|
|
|
cursor.execute(f'''
|
|
SELECT dia.device_id, ip.id, ip.ip, ip.hostname, ip.notes, 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 IN ({placeholders})
|
|
''', tuple(device_ids))
|
|
ips_by_device = {}
|
|
for row in cursor.fetchall():
|
|
ips_by_device.setdefault(row['device_id'], []).append(row)
|
|
|
|
cursor.execute(f'''
|
|
SELECT dt.device_id, t.id, t.name, t.color
|
|
FROM DeviceTag dt JOIN Tag t ON dt.tag_id = t.id
|
|
WHERE dt.device_id IN ({placeholders}) ORDER BY t.name
|
|
''', tuple(device_ids))
|
|
tags_by_device = {}
|
|
for row in cursor.fetchall():
|
|
tags_by_device.setdefault(row['device_id'], []).append(
|
|
{'id': row['id'], 'name': row['name'], 'color': row['color']}
|
|
)
|
|
|
|
cursor.execute(f'SELECT id, custom_fields FROM Device WHERE id IN ({placeholders})', tuple(device_ids))
|
|
cf_by_id = {}
|
|
for row in cursor.fetchall():
|
|
cf = row['custom_fields']
|
|
if cf:
|
|
try:
|
|
cf_by_id[row['id']] = json.loads(cf)
|
|
except (json.JSONDecodeError, TypeError):
|
|
cf_by_id[row['id']] = {}
|
|
else:
|
|
cf_by_id[row['id']] = {}
|
|
|
|
for device in devices:
|
|
did = device['id']
|
|
device['ip_addresses'] = ips_by_device.get(did, [])
|
|
device['tags'] = tags_by_device.get(did, [])
|
|
device['custom_fields'] = cf_by_id.get(did, {})
|
|
return devices
|
|
|
|
|
|
def parse_custom_fields_json(raw):
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
return json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return {}
|
|
|
|
|
|
def fetch_subnet_ip_rows(cursor, subnet_id):
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip, ip.hostname, d.id, d.description, ip.notes
|
|
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 INET_ATON(ip.ip)
|
|
''', (subnet_id,))
|
|
return cursor.fetchall()
|
|
|
|
|
|
def subnet_ip_csv_rows(ip_rows):
|
|
"""Convert IP query rows to CSV data rows."""
|
|
rows = []
|
|
for ip in ip_rows:
|
|
device_desc = ip[4] or ''
|
|
ip_notes = ip[5] if len(ip) > 5 and ip[5] else ''
|
|
combined_desc = device_desc
|
|
if ip_notes:
|
|
combined_desc = f"{combined_desc}\n{ip_notes}" if combined_desc else ip_notes
|
|
rows.append([ip[1] or '', ip[2] or '', combined_desc])
|
|
return rows
|
|
|
|
|
|
def csv_attachment(rows, headers, filename):
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
if headers:
|
|
writer.writerow(headers)
|
|
writer.writerows(rows)
|
|
output_bytes = BytesIO(output.getvalue().encode('utf-8'))
|
|
output_bytes.seek(0)
|
|
return send_file(output_bytes, mimetype='text/csv', as_attachment=True, download_name=filename)
|
|
|
|
|
|
def check_rack_placement(cursor, rack_id, position_u, side):
|
|
"""Return (height_u, error_message). height_u is None if rack not found."""
|
|
cursor.execute('SELECT height_u FROM Rack WHERE id = %s', (rack_id,))
|
|
rack = cursor.fetchone()
|
|
if not rack:
|
|
return None, 'Rack not found'
|
|
height_u = rack['height_u']
|
|
if position_u < 1 or position_u > height_u:
|
|
return height_u, f"Invalid U position: {position_u}. Rack is {height_u}U tall."
|
|
cursor.execute(
|
|
'SELECT COUNT(*) AS cnt FROM RackDevice WHERE rack_id = %s AND position_u = %s AND side = %s',
|
|
(rack_id, position_u, side),
|
|
)
|
|
if cursor.fetchone()['cnt'] > 0:
|
|
return height_u, f"U{position_u} on the {side} is already occupied."
|
|
return height_u, None
|
|
|
|
|
|
def group_devices_by_site(devices):
|
|
"""Group device dicts by site key."""
|
|
sites = {}
|
|
seen_ids = set()
|
|
for device in devices:
|
|
if device['id'] in seen_ids:
|
|
continue
|
|
seen_ids.add(device['id'])
|
|
site = device.get('site') or 'Unassigned'
|
|
sites.setdefault(site, []).append(device)
|
|
return sites
|
|
|
|
|
|
# ── Auth & account (v2) ───────────────────────────────────────────────────────
|
|
|
|
@app.route('/api/v2/auth/login/precheck', methods=['POST'])
|
|
def api_auth_login_precheck():
|
|
data = json_body()
|
|
email = (data.get('email') or '').strip().lower()
|
|
if not email:
|
|
return jsonify({'error': 'Email required'}), 400
|
|
if not logto_sso_available():
|
|
return jsonify({'method': 'local'})
|
|
with get_db_connection(current_app) as conn:
|
|
settings = get_app_settings(conn)
|
|
if not settings.get('is_sso_enabled'):
|
|
return jsonify({'method': 'local'})
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM User WHERE LOWER(email) = %s', (email,))
|
|
if not cursor.fetchone():
|
|
return jsonify({'method': 'local'})
|
|
params = {'email': email}
|
|
redirect_target = (data.get('redirect') or '').strip()
|
|
if redirect_target:
|
|
params['redirect'] = redirect_target
|
|
return jsonify({
|
|
'method': 'sso',
|
|
'redirect_url': f"/api/v2/auth/sso/login?{urlencode(params)}",
|
|
})
|
|
|
|
|
|
@app.route('/api/v2/auth/sso/login', methods=['GET'])
|
|
@require_sso_available
|
|
def api_auth_sso_login():
|
|
email = (request.args.get('email') or '').strip().lower()
|
|
if not email:
|
|
return jsonify({'error': 'Email required'}), 400
|
|
redirect_target = (request.args.get('redirect') or '').strip()
|
|
if redirect_target:
|
|
session['sso_post_login_redirect'] = redirect_target
|
|
session['sso_expected_email'] = email
|
|
session.modified = True
|
|
redirect_uri = _sso_oidc_callback_url()
|
|
return oauth.logto.authorize_redirect(redirect_uri, login_hint=email)
|
|
|
|
|
|
@app.route('/api/v2/auth/sso/callback', methods=['GET'])
|
|
@require_sso_available
|
|
def api_auth_sso_callback():
|
|
try:
|
|
token = oauth.logto.authorize_access_token()
|
|
except Exception as exc:
|
|
logging.exception('Logto OIDC callback failed')
|
|
return redirect('/login?error=sso_failed')
|
|
email = extract_logto_email(oauth.logto, token)
|
|
if not email:
|
|
return redirect('/login?error=sso_no_email')
|
|
expected = (session.pop('sso_expected_email', '') or '').strip().lower()
|
|
if expected and email != expected:
|
|
logging.warning('SSO email mismatch: expected %s got %s', expected, email)
|
|
return redirect('/login?error=sso_email_mismatch')
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM User WHERE LOWER(email) = %s', (email,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return redirect('/login?error=sso_user_not_found')
|
|
user_id = row[0]
|
|
establish_user_session(user_id, conn=conn, via_sso=True)
|
|
add_audit_log(user_id, 'sso_login', f'SSO login for {email}', conn=conn)
|
|
logging.info('User %s logged in via SSO.', email)
|
|
redirect_to = session.pop('sso_post_login_redirect', None) or '/'
|
|
return redirect(redirect_to)
|
|
|
|
|
|
@app.route('/api/v2/auth/login', methods=['POST'])
|
|
def api_auth_login():
|
|
data = json_body()
|
|
email = (data.get('email') or '').strip()
|
|
password = data.get('password') or ''
|
|
if not email or not password:
|
|
return jsonify({'error': 'Email and password required'}), 400
|
|
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 not user or not verify_password(password, user[1]):
|
|
logging.info(f"Failed login attempt for email: {email}")
|
|
return jsonify({'error': 'Invalid email or password'}), 401
|
|
user_id = user[0]
|
|
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 and not setup_complete:
|
|
session['pending_user_id'] = user_id
|
|
session['pending_email'] = email
|
|
return jsonify({'requires_setup': True})
|
|
if totp_enabled:
|
|
session['pending_user_id'] = user_id
|
|
session['pending_email'] = email
|
|
return jsonify({'requires_2fa': True})
|
|
establish_user_session(user_id, conn=conn)
|
|
logging.info(f"User {email} logged in successfully.")
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/auth/logout', methods=['POST'])
|
|
def api_auth_logout():
|
|
session.clear()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/auth/me', methods=['GET'])
|
|
def api_auth_me():
|
|
user = resolve_auth()
|
|
return jsonify(_me_payload(user))
|
|
|
|
|
|
@app.route('/api/v2/auth/verify-2fa', methods=['POST'])
|
|
def api_auth_verify_2fa():
|
|
data = json_body()
|
|
pending_user_id = session.get('pending_user_id')
|
|
if not pending_user_id:
|
|
return jsonify({'error': 'No pending login'}), 400
|
|
code = (data.get('code') or '').strip()
|
|
use_backup = bool(data.get('use_backup'))
|
|
if not code:
|
|
return jsonify({'error': 'Verification code required'}), 400
|
|
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 jsonify({'error': 'User not found'}), 404
|
|
totp_secret, backup_codes_json = result
|
|
if not totp_secret:
|
|
return jsonify({'error': '2FA is not configured for this account'}), 400
|
|
if use_backup:
|
|
if not backup_codes_json:
|
|
return jsonify({'error': 'No backup codes available'}), 400
|
|
valid, updated_codes = verify_backup_code(backup_codes_json, code)
|
|
if not valid:
|
|
return jsonify({'error': 'Invalid backup code'}), 401
|
|
cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s', (updated_codes, pending_user_id))
|
|
conn.commit()
|
|
else:
|
|
if len(code) != 6 or not code.isdigit():
|
|
return jsonify({'error': 'Invalid code format'}), 400
|
|
if not verify_totp(totp_secret, code):
|
|
return jsonify({'error': 'Invalid verification code'}), 401
|
|
establish_user_session(pending_user_id, conn=conn)
|
|
session.pop('pending_user_id', None)
|
|
session.pop('pending_email', None)
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/auth/setup-2fa', methods=['POST'])
|
|
def api_auth_setup_2fa():
|
|
data = json_body()
|
|
action = data.get('action', 'generate')
|
|
user_id = session.get('pending_user_id') or session.get('user_id')
|
|
if not user_id:
|
|
return jsonify({'error': 'Not authenticated'}), 401
|
|
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,))
|
|
row = cursor.fetchone()
|
|
email = row[0] if row else ''
|
|
uri = get_totp_uri(secret, email)
|
|
return jsonify({'secret': secret, 'qr_code': generate_qr_code(uri), 'email': email})
|
|
if action == 'verify':
|
|
code = (data.get('code') or '').strip()
|
|
secret = session.get('temp_totp_secret')
|
|
if not secret:
|
|
return jsonify({'error': 'Session expired. Generate a new secret.'}), 400
|
|
if not verify_totp(secret, code):
|
|
return jsonify({'error': 'Invalid code'}), 401
|
|
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))
|
|
conn.commit()
|
|
session.pop('temp_totp_secret', None)
|
|
complete_login = session.get('pending_user_id') == user_id
|
|
if complete_login:
|
|
establish_user_session(user_id, conn=conn)
|
|
session.pop('pending_user_id', None)
|
|
session.pop('pending_email', None)
|
|
return jsonify({'ok': True, 'backup_codes': backup_codes})
|
|
return jsonify({'error': 'Invalid action'}), 400
|
|
|
|
|
|
@app.route('/api/v2/account', methods=['GET'])
|
|
@require_auth
|
|
def api_account_get():
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('''
|
|
SELECT u.totp_enabled, u.two_fa_setup_complete, u.backup_codes, r.require_2fa
|
|
FROM User u LEFT JOIN Role r ON u.role_id = r.id WHERE u.id = %s
|
|
''', (get_current_user_id(),))
|
|
row = cursor.fetchone()
|
|
backup_codes = None
|
|
if row and row.get('backup_codes'):
|
|
try:
|
|
codes = json.loads(row['backup_codes'])
|
|
backup_codes = format_backup_codes(codes)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return jsonify({
|
|
'user': {'id': get_current_user_id(), 'name': current_user()['name'], 'email': current_user().get('email', '')},
|
|
'totp_enabled': bool(row and row.get('totp_enabled')),
|
|
'two_fa_setup_complete': bool(row and row.get('two_fa_setup_complete')),
|
|
'role_requires_2fa': bool(row and row.get('require_2fa')),
|
|
'backup_codes': backup_codes,
|
|
})
|
|
|
|
|
|
@app.route('/api/v2/account/change-password', methods=['POST'])
|
|
@require_auth
|
|
def api_account_change_password():
|
|
data = json_body()
|
|
current_pw = data.get('current_password') or ''
|
|
new_pw = data.get('new_password') or ''
|
|
if not current_pw or not new_pw:
|
|
return jsonify({'error': 'Current and new password required'}), 400
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT password FROM User WHERE id = %s', (get_current_user_id(),))
|
|
row = cursor.fetchone()
|
|
if not row or not verify_password(current_pw, row[0]):
|
|
return jsonify({'error': 'Current password is incorrect'}), 401
|
|
cursor.execute('UPDATE User SET password = %s WHERE id = %s', (hash_password(new_pw), get_current_user_id()))
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/account/disable-2fa', methods=['POST'])
|
|
@require_auth
|
|
def api_account_disable_2fa():
|
|
data = json_body()
|
|
password = data.get('password') or ''
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT password, totp_enabled FROM User WHERE id = %s', (get_current_user_id(),))
|
|
row = cursor.fetchone()
|
|
if not row or not verify_password(password, row[0]):
|
|
return jsonify({'error': 'Password is incorrect'}), 401
|
|
if not row[1]:
|
|
return jsonify({'error': '2FA is not enabled'}), 400
|
|
cursor.execute('''
|
|
UPDATE User SET totp_secret = NULL, totp_enabled = FALSE, backup_codes = NULL, two_fa_setup_complete = FALSE
|
|
WHERE id = %s
|
|
''', (get_current_user_id(),))
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/account/regenerate-backup-codes', methods=['POST'])
|
|
@require_auth
|
|
def api_account_regenerate_backup_codes():
|
|
data = json_body()
|
|
password = data.get('password') or ''
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT password, totp_enabled FROM User WHERE id = %s', (get_current_user_id(),))
|
|
row = cursor.fetchone()
|
|
if not row or not verify_password(password, row[0]):
|
|
return jsonify({'error': 'Password is incorrect'}), 401
|
|
if not row[1]:
|
|
return jsonify({'error': '2FA is not enabled'}), 400
|
|
backup_codes = generate_backup_codes()
|
|
cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s', (json.dumps(backup_codes), get_current_user_id()))
|
|
conn.commit()
|
|
return jsonify({'backup_codes': backup_codes})
|
|
|
|
|
|
def _sso_settings_response(settings):
|
|
return {
|
|
'is_sso_enabled': bool(settings.get('is_sso_enabled')),
|
|
}
|
|
|
|
|
|
@app.route('/api/v2/settings/sso', methods=['GET'])
|
|
@require_sso_available
|
|
@require_permission('view_admin')
|
|
def api_settings_sso_get():
|
|
with get_db_connection(current_app) as conn:
|
|
settings = get_app_settings(conn)
|
|
return jsonify(_sso_settings_response(settings))
|
|
|
|
|
|
@app.route('/api/v2/settings/sso', methods=['POST'])
|
|
@require_sso_available
|
|
@require_permission('view_admin')
|
|
def api_settings_sso_post():
|
|
data = json_body()
|
|
is_sso_enabled = bool(data.get('is_sso_enabled'))
|
|
with get_db_connection(current_app) as conn:
|
|
update_app_settings(conn, is_sso_enabled=is_sso_enabled)
|
|
settings = get_app_settings(conn)
|
|
add_audit_log(
|
|
get_current_user_id(),
|
|
'sso_settings_update',
|
|
f"External sign-in {'enabled' if is_sso_enabled else 'disabled'}",
|
|
conn=conn,
|
|
)
|
|
return jsonify(_sso_settings_response(settings))
|
|
|
|
|
|
# ── API v2 routes ─────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/api/v2/info', methods=['GET'])
|
|
@require_auth
|
|
def api_info():
|
|
"""Get API information and authenticated user info"""
|
|
return jsonify({
|
|
'api_version': '2.0',
|
|
'user': {
|
|
'id': get_current_user_id(),
|
|
'name': current_user()['name'],
|
|
'email': current_user()['email']
|
|
}
|
|
})
|
|
|
|
# Devices API
|
|
@app.route('/api/v2/devices', methods=['GET'])
|
|
@require_permission('view_devices')
|
|
def api_devices():
|
|
"""Get all devices"""
|
|
tag_filter = request.args.get('tag')
|
|
site_filter = request.args.get('site')
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
if tag_filter:
|
|
cursor.execute('''
|
|
SELECT DISTINCT d.id, d.name, d.description
|
|
FROM Device d
|
|
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,))
|
|
elif site_filter:
|
|
site_val = None if site_filter == 'Unassigned' else site_filter
|
|
cursor.execute('''
|
|
SELECT DISTINCT d.id, d.name, d.description
|
|
FROM Device d
|
|
JOIN DeviceIPAddress dia ON d.id = dia.device_id
|
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
|
JOIN Subnet s ON ip.subnet_id = s.id
|
|
WHERE s.site <=> %s
|
|
ORDER BY d.name
|
|
''', (site_val,))
|
|
else:
|
|
cursor.execute('SELECT id, name, description FROM Device ORDER BY name')
|
|
devices = cursor.fetchall()
|
|
enrich_devices_batch(cursor, devices)
|
|
return items_response(devices)
|
|
|
|
@app.route('/api/v2/devices/<int:device_id>', methods=['GET'])
|
|
@require_permission('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
|
|
FROM Device d
|
|
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, ip.notes, 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/v2/devices', methods=['POST'])
|
|
@require_permission('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', '')
|
|
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT INTO Device (name, description) VALUES (%s, %s)',
|
|
(name, description))
|
|
device_id = cursor.lastrowid
|
|
add_audit_log(get_current_user_id(), 'add_device', f"Added device {name}", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'id': device_id, 'name': name, 'description': description}), 201
|
|
|
|
@app.route('/api/v2/devices/<int:device_id>', methods=['PUT'])
|
|
@require_permission('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 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
|
|
|
|
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 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(get_current_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/v2/devices/<int:device_id>', methods=['DELETE'])
|
|
@require_permission('delete_device')
|
|
def api_delete_device(device_id):
|
|
"""Delete a device"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
device_name = delete_device_record(conn, device_id, get_current_user_id())
|
|
if not device_name:
|
|
return jsonify({'error': 'Device not found'}), 404
|
|
conn.commit()
|
|
return jsonify({'message': 'Device deleted successfully', 'device': {'id': device_id, 'name': device_name}})
|
|
|
|
@app.route('/api/v2/devices/<int:device_id>/ips', methods=['POST'])
|
|
@require_permission('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
|
|
try:
|
|
with get_db_connection(current_app) as conn:
|
|
assign_ip_to_device(conn, device_id, ip_id, get_current_user_id())
|
|
conn.commit()
|
|
except ValueError as e:
|
|
msg = str(e)
|
|
if 'not found' in msg.lower():
|
|
return jsonify({'error': msg}), 404
|
|
return jsonify({'error': msg}), 400
|
|
return jsonify({'message': 'IP address added to device successfully', 'ip_id': ip_id}), 201
|
|
|
|
@app.route('/api/v2/devices/<int:device_id>/ips/<int:ip_id>', methods=['DELETE'])
|
|
@require_permission('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(get_current_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/v2/subnets', methods=['GET'])
|
|
@require_permission('view_subnet')
|
|
def api_subnets():
|
|
"""Get all subnets"""
|
|
include_util = request.args.get('include') == 'utilization'
|
|
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, custom_fields FROM Subnet ORDER BY site, name')
|
|
subnets = cursor.fetchall()
|
|
utils = get_all_subnet_utilizations(cursor) if include_util else {}
|
|
for subnet in subnets:
|
|
subnet['custom_fields'] = parse_custom_fields_json(subnet.pop('custom_fields', None))
|
|
if include_util:
|
|
u = utils.get(subnet['id'], {'total': 0, 'used': 0, 'percent': 0})
|
|
subnet['utilization'] = u['percent']
|
|
subnet['total_ips'] = u['total']
|
|
subnet['used_ips'] = u['used']
|
|
return items_response(subnets)
|
|
|
|
@app.route('/api/v2/subnets/<int:subnet_id>', methods=['GET'])
|
|
@require_permission('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, ip.notes, d.id as device_id, d.name as device_name, d.description
|
|
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 INET_ATON(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/v2/subnets/<int:subnet_id>/next_free_ip', methods=['GET'])
|
|
@require_permission('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 unassigned IP outside DHCP pools
|
|
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)
|
|
''', (subnet_id,))
|
|
ips = [{'id': r['id'], 'ip': r['ip']} for r in cursor.fetchall()]
|
|
ips = filter_ips_outside_dhcp(cursor, subnet_id, ips)
|
|
if not ips:
|
|
return jsonify({'error': 'No free IP addresses available in this subnet'}), 404
|
|
|
|
return jsonify({'id': ips[0]['id'], 'ip': ips[0]['ip']})
|
|
|
|
@app.route('/api/v2/subnets', methods=['POST'])
|
|
@require_permission('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:
|
|
with get_db_connection(current_app) as conn:
|
|
subnet_id = create_subnet_from_cidr(
|
|
conn, name, cidr, site, vlan_id, vlan_description, vlan_notes, get_current_user_id(),
|
|
)
|
|
conn.commit()
|
|
except ValueError as exc:
|
|
return jsonify({'error': str(exc)}), 400
|
|
except Exception:
|
|
return jsonify({'error': 'Invalid CIDR format'}), 400
|
|
|
|
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/v2/subnets/<int:subnet_id>', methods=['PUT'])
|
|
@require_permission('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(
|
|
get_current_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/v2/subnets/<int:subnet_id>', methods=['DELETE'])
|
|
@require_permission('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(get_current_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/v2/racks', methods=['GET'])
|
|
@require_permission('view_racks')
|
|
def api_racks():
|
|
"""Get all racks"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
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 items_response(racks)
|
|
|
|
@app.route('/api/v2/racks/<int:rack_id>', methods=['GET'])
|
|
@require_permission('view_rack')
|
|
def api_rack(rack_id):
|
|
"""Get a specific rack"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
ctx = load_rack_view(conn, rack_id)
|
|
if not ctx:
|
|
return jsonify({'error': 'Rack not found'}), 404
|
|
rack = ctx['rack']
|
|
rack['devices'] = [
|
|
{
|
|
'id': rd['id'],
|
|
'position_u': rd['position_u'],
|
|
'side': rd['side'],
|
|
'device_id': rd['device_id'],
|
|
'device_name': rd.get('device_name'),
|
|
'nonnet_device_name': rd.get('nonnet_device_name'),
|
|
}
|
|
for rd in ctx['rack_devices']
|
|
]
|
|
rack['site_devices'] = ctx['site_devices']
|
|
return jsonify(rack)
|
|
|
|
@app.route('/api/v2/racks/<int:rack_id>', methods=['PUT'])
|
|
@require_permission('add_rack')
|
|
def api_update_rack(rack_id):
|
|
"""Update a rack"""
|
|
data = json_body()
|
|
with get_db_connection(current_app) as conn:
|
|
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
|
|
name = (data.get('name') or rack['name']).strip()
|
|
site = (data.get('site') or rack['site']).strip()
|
|
height_u = data.get('height_u', rack['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
|
|
cursor.execute('UPDATE Rack SET name = %s, site = %s, height_u = %s WHERE id = %s',
|
|
(name, site, height_u, rack_id))
|
|
add_audit_log(get_current_user_id(), 'edit_rack',
|
|
f"Updated rack '{rack['name']}' to '{name}' at site '{site}' ({height_u}U)", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'id': rack_id, 'name': name, 'site': site, 'height_u': height_u})
|
|
|
|
@app.route('/api/v2/racks', methods=['POST'])
|
|
@require_permission('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:
|
|
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(get_current_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/v2/racks/<int:rack_id>', methods=['DELETE'])
|
|
@require_permission('delete_rack')
|
|
def api_delete_rack(rack_id):
|
|
"""Delete a rack"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
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(get_current_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/v2/racks/<int:rack_id>/devices', methods=['POST'])
|
|
@require_permission('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:
|
|
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(get_current_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/v2/racks/<int:rack_id>/devices/<int:rack_device_id>', methods=['DELETE'])
|
|
@require_permission('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:
|
|
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(
|
|
get_current_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/v2/custom_fields/<entity_type>', methods=['GET'])
|
|
@require_permission('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
|
|
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 items_response(fields)
|
|
|
|
@app.route('/api/v2/custom_fields', methods=['POST'])
|
|
@require_permission('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)
|
|
VALUES (%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))
|
|
field_id = cursor.lastrowid
|
|
add_audit_log(get_current_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/v2/custom_fields/<int:field_id>', methods=['PUT'])
|
|
@require_permission('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 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(get_current_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/v2/custom_fields/<int:field_id>', methods=['DELETE'])
|
|
@require_permission('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(get_current_user_id(), 'delete_custom_field',
|
|
f"Deleted custom field '{field['name']}'", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'message': 'Custom field deleted successfully'})
|
|
|
|
# DHCP API
|
|
@app.route('/api/v2/subnets/<int:subnet_id>/dhcp', methods=['GET'])
|
|
@require_permission('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/v2/subnets/<int:subnet_id>/dhcp', methods=['POST'])
|
|
@require_permission('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
|
|
|
|
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'):
|
|
remove_dhcp_pool(cursor, subnet_id, subnet_name, subnet_cidr, get_current_user_id(), conn)
|
|
conn.commit()
|
|
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 id FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
|
is_update = cursor.fetchone() is not None
|
|
try:
|
|
result = configure_dhcp_pool(
|
|
cursor, subnet_id, start_ip, end_ip, excluded_str,
|
|
subnet_name, subnet_cidr, get_current_user_id(), conn, is_update,
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({'error': str(exc)}), 400
|
|
conn.commit()
|
|
return jsonify({
|
|
'message': 'DHCP pools configured successfully',
|
|
'pool': {'start_ip': result['start_ip'], 'end_ip': result['end_ip'], 'excluded_ips': excluded_list},
|
|
})
|
|
|
|
# Tags API
|
|
@app.route('/api/v2/tags', methods=['GET'])
|
|
@require_permission('view_tags')
|
|
def api_tags():
|
|
"""Get all tags"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
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 items_response(tags)
|
|
|
|
@app.route('/api/v2/tags', methods=['POST'])
|
|
@require_permission('add_tag')
|
|
def api_add_tag():
|
|
"""Create a new tag"""
|
|
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', '')
|
|
|
|
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(get_current_user_id(), 'add_tag', f"Added tag '{name}'", conn=conn)
|
|
conn.commit()
|
|
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/v2/tags/<int:tag_id>', methods=['GET'])
|
|
@require_permission('view_tags')
|
|
def api_tag(tag_id):
|
|
"""Get a specific tag"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
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
|
|
FROM DeviceTag dtag
|
|
JOIN Device d ON dtag.device_id = d.id
|
|
WHERE dtag.tag_id = %s
|
|
ORDER BY d.name
|
|
''', (tag_id,))
|
|
tag['devices'] = cursor.fetchall()
|
|
return jsonify(tag)
|
|
|
|
@app.route('/api/v2/tags/<int:tag_id>', methods=['PUT'])
|
|
@require_permission('edit_tag')
|
|
def api_update_tag(tag_id):
|
|
"""Update a tag"""
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'Request body is required'}), 400
|
|
|
|
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(get_current_user_id(), 'edit_tag', f"Updated tag '{current_name}'", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'message': 'Tag updated successfully'})
|
|
except mysql.connector.IntegrityError:
|
|
return jsonify({'error': 'Tag name already exists'}), 400
|
|
|
|
@app.route('/api/v2/tags/<int:tag_id>', methods=['DELETE'])
|
|
@require_permission('delete_tag')
|
|
def api_delete_tag(tag_id):
|
|
"""Delete a tag"""
|
|
from flask import current_app
|
|
with get_db_connection(current_app) as conn:
|
|
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(get_current_user_id(), 'delete_tag', f"Deleted tag '{tag_name}'", conn=conn)
|
|
conn.commit()
|
|
return jsonify({'message': 'Tag deleted successfully'})
|
|
|
|
@app.route('/api/v2/devices/<int:device_id>/tags', methods=['GET'])
|
|
@require_permission('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:
|
|
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 items_response(tags)
|
|
|
|
@app.route('/api/v2/devices/<int:device_id>/tags', methods=['POST'])
|
|
@require_permission('assign_device_tag')
|
|
def api_assign_device_tag(device_id):
|
|
"""Assign a tag to a device"""
|
|
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']
|
|
with get_db_connection(current_app) as conn:
|
|
try:
|
|
assign_tag_to_device(conn, device_id, tag_id, get_current_user_id())
|
|
conn.commit()
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if 'not found' in msg.lower():
|
|
return jsonify({'error': msg}), 404
|
|
if 'already assigned' in msg.lower():
|
|
return jsonify({'error': 'Tag already assigned to device'}), 400
|
|
return jsonify({'error': msg}), 400
|
|
return jsonify({'message': 'Tag assigned successfully'})
|
|
|
|
@app.route('/api/v2/devices/<int:device_id>/tags/<int:tag_id>', methods=['DELETE'])
|
|
@require_permission('remove_device_tag')
|
|
def api_remove_device_tag(device_id, tag_id):
|
|
"""Remove a tag from a device"""
|
|
with get_db_connection(current_app) as conn:
|
|
try:
|
|
remove_tag_from_device(conn, device_id, tag_id, get_current_user_id())
|
|
conn.commit()
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if 'not found' in msg.lower():
|
|
return jsonify({'error': msg}), 404
|
|
if 'not assigned' in msg.lower():
|
|
return jsonify({'error': 'Tag not assigned to device'}), 404
|
|
return jsonify({'error': msg}), 400
|
|
return jsonify({'message': 'Tag removed successfully'})
|
|
|
|
@app.route('/api/v2/devices/by-tag/<tag_identifier>', methods=['GET'])
|
|
@require_permission('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
|
|
FROM DeviceTag dtag
|
|
JOIN Device d ON dtag.device_id = d.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
|
|
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,))
|
|
|
|
devices = cursor.fetchall()
|
|
|
|
if not devices:
|
|
return jsonify({'items': [], 'meta': {'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 INET_ATON(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, ip.notes, 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({'items': devices, 'meta': {'tag_name': tag_name, 'count': len(devices)}})
|
|
|
|
# Audit Log API
|
|
@app.route('/api/v2/audit/actions', methods=['GET'])
|
|
@require_permission('view_audit')
|
|
def api_audit_actions():
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT DISTINCT action FROM AuditLog ORDER BY action')
|
|
actions = [row[0] for row in cursor.fetchall()]
|
|
return items_response(actions)
|
|
|
|
|
|
@app.route('/api/v2/audit', methods=['GET'])
|
|
@require_permission('view_audit')
|
|
def api_audit():
|
|
"""Get audit log entries"""
|
|
from flask import current_app
|
|
where_sql, filter_params = build_audit_filters()
|
|
limit = request.args.get('limit', 100, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute(f'''
|
|
SELECT COUNT(*) as total
|
|
FROM AuditLog al
|
|
LEFT JOIN User u ON al.user_id = u.id
|
|
{where_sql}
|
|
''', tuple(filter_params))
|
|
total = cursor.fetchone()['total']
|
|
cursor.execute(f'''
|
|
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
|
|
{where_sql}
|
|
ORDER BY al.timestamp DESC
|
|
LIMIT %s OFFSET %s
|
|
''', tuple(filter_params) + (limit, offset))
|
|
logs = cursor.fetchall()
|
|
return jsonify({'items': logs, 'total': total})
|
|
|
|
# Users API (admin only)
|
|
@app.route('/api/v2/users', methods=['GET'])
|
|
@require_permission('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 items_response(users)
|
|
|
|
# Roles API (admin only)
|
|
@app.route('/api/v2/roles', methods=['GET'])
|
|
@require_permission('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, require_2fa 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 items_response(roles)
|
|
|
|
|
|
# ── Extended v2 endpoints ─────────────────────────────────────────────────────
|
|
|
|
@app.route('/api/v2/dashboard', methods=['GET'])
|
|
@require_permission('view_index')
|
|
def api_dashboard():
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
utils = get_all_subnet_utilizations(cursor)
|
|
cursor.execute('SELECT COUNT(*) AS n FROM Device')
|
|
device_count = cursor.fetchone()['n']
|
|
cursor.execute('SELECT COUNT(*) AS n FROM Subnet')
|
|
subnet_count = cursor.fetchone()['n']
|
|
|
|
total_ips = sum(u['total'] for u in utils.values())
|
|
used_ips = sum(u['used'] for u in utils.values())
|
|
available_ips = max(total_ips - used_ips, 0)
|
|
utilization_percent = round((used_ips / total_ips * 100) if total_ips > 0 else 0, 1)
|
|
alerting_subnets = sum(1 for u in utils.values() if u['percent'] >= 90)
|
|
|
|
cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet ORDER BY name')
|
|
subnet_overview = []
|
|
for s in cursor.fetchall():
|
|
u = utils.get(s['id'], {'total': 0, 'used': 0, 'percent': 0})
|
|
pct = u['percent']
|
|
subnet_overview.append({
|
|
'id': s['id'],
|
|
'name': s['name'],
|
|
'cidr': s['cidr'],
|
|
'site': s['site'] or 'Unassigned',
|
|
'vlan_id': s['vlan_id'],
|
|
'utilization': pct,
|
|
'available': u['total'] - u['used'],
|
|
'status': 'alerting' if pct >= 90 else 'active',
|
|
})
|
|
|
|
cursor.execute('''
|
|
SELECT HOUR(timestamp) AS hour, COUNT(*) AS count
|
|
FROM AuditLog
|
|
WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
|
|
GROUP BY HOUR(timestamp)
|
|
ORDER BY hour
|
|
''')
|
|
activity_by_hour = {row['hour']: row['count'] for row in cursor.fetchall()}
|
|
activity = [{'hour': h, 'count': activity_by_hour.get(h, 0)} for h in range(24)]
|
|
|
|
return jsonify({
|
|
'stats': {
|
|
'total_ips': total_ips,
|
|
'used_ips': used_ips,
|
|
'available_ips': available_ips,
|
|
'utilization_percent': utilization_percent,
|
|
'subnet_count': subnet_count,
|
|
'alerting_subnets': alerting_subnets,
|
|
'device_count': device_count,
|
|
},
|
|
'subnet_overview': subnet_overview,
|
|
'activity': activity,
|
|
})
|
|
|
|
|
|
@app.route('/api/v2/search', methods=['GET'])
|
|
@require_auth
|
|
def api_search():
|
|
query = (request.args.get('q') or '').strip()
|
|
results = {'subnets': [], 'ips': [], 'devices': [], 'tags': [], 'racks': [], 'sites': []}
|
|
if not query:
|
|
return jsonify(results)
|
|
pattern = f'%{query}%'
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
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
|
|
''', (pattern, pattern, pattern))
|
|
results['subnets'] = [{**r, 'site': r['site'] or 'Unassigned'} for r in cursor.fetchall()]
|
|
cursor.execute('''
|
|
SELECT ip.id, ip.ip, ip.hostname, ip.subnet_id, s.name as subnet_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 INET_ATON(ip.ip)
|
|
''', (pattern, pattern, pattern))
|
|
results['ips'] = [{**r, 'site': r['site'] or 'Unassigned'} for r in cursor.fetchall()]
|
|
cursor.execute('SELECT id, name, description FROM Device WHERE name LIKE %s OR description LIKE %s ORDER BY name', (pattern, pattern))
|
|
results['devices'] = cursor.fetchall()
|
|
cursor.execute('SELECT id, name, description FROM Tag WHERE name LIKE %s OR description LIKE %s ORDER BY name', (pattern, pattern))
|
|
results['tags'] = cursor.fetchall()
|
|
cursor.execute('SELECT id, name, site, height_u FROM Rack WHERE name LIKE %s OR site LIKE %s ORDER BY site, name', (pattern, pattern))
|
|
results['racks'] = cursor.fetchall()
|
|
all_sites = {x['site'] for x in results['subnets']} | {x['site'] for x in results['racks']} | {x['site'] for x in results['ips']}
|
|
results['sites'] = sorted(s for s in all_sites if query.lower() in s.lower())
|
|
return jsonify(results)
|
|
|
|
|
|
@app.route('/api/v2/devices/<int:device_id>/ip-history', methods=['GET'])
|
|
@require_permission('view_device')
|
|
def api_device_ip_history(device_id):
|
|
with get_db_connection(current_app) as conn:
|
|
history = get_ip_history_from_audit_logs(device_id=device_id, conn=conn)
|
|
return jsonify({'items': history})
|
|
|
|
|
|
@app.route('/api/v2/ips/<path:ip_address>/history', methods=['GET'])
|
|
@require_permission('view_subnet')
|
|
def api_ip_history(ip_address):
|
|
with get_db_connection(current_app) as conn:
|
|
history = get_ip_history_from_audit_logs(ip_address=ip_address, conn=conn)
|
|
return jsonify({'items': history, 'ip': ip_address})
|
|
|
|
|
|
@app.route('/api/v2/subnets/<int:subnet_id>/available-ips', methods=['GET'])
|
|
@require_permission('view_subnet')
|
|
def api_subnet_available_ips(subnet_id):
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id FROM Subnet WHERE id = %s', (subnet_id,))
|
|
if not cursor.fetchone():
|
|
return jsonify({'error': 'Subnet not found'}), 404
|
|
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)
|
|
''', (subnet_id,))
|
|
ips = [{'id': r['id'], 'ip': r['ip']} for r in cursor.fetchall()]
|
|
ips = filter_ips_outside_dhcp(cursor, subnet_id, ips)
|
|
return items_response(ips)
|
|
|
|
|
|
@app.route('/api/v2/subnets/<int:subnet_id>/export', methods=['GET'])
|
|
@require_permission('export_subnet_csv')
|
|
def api_subnet_export(subnet_id):
|
|
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
|
|
rows = subnet_ip_csv_rows(fetch_subnet_ip_rows(cursor, subnet_id))
|
|
return csv_attachment(rows, ['IP Address', 'Hostname', 'Description'], f"{subnet[0]}_{subnet[1]}.csv".replace('/', '_'))
|
|
|
|
|
|
@app.route('/api/v2/ip-addresses/<int:ip_id>', methods=['PATCH'])
|
|
@require_permission('edit_subnet')
|
|
def api_patch_ip_address(ip_id):
|
|
data = json_body()
|
|
notes = data.get('notes')
|
|
if notes is None:
|
|
return jsonify({'error': 'notes field required'}), 400
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'IP not found'}), 404
|
|
cursor.execute('UPDATE IPAddress SET notes = %s WHERE id = %s', (notes.strip() or None, ip_id))
|
|
add_audit_log(get_current_user_id(), 'update_ip_notes', f"Updated notes for IP {row['ip']}", row['subnet_id'], conn=conn)
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/devices/<int:device_id>/custom-fields', methods=['PATCH'])
|
|
@require_permission('edit_device')
|
|
def api_patch_device_custom_fields(device_id):
|
|
data = json_body()
|
|
with get_db_connection(current_app) as conn:
|
|
errors = update_entity_custom_fields(conn, 'device', device_id, data.get('custom_fields') or data, get_current_user_id(), is_json=True)
|
|
if errors:
|
|
return jsonify({'error': '; '.join(errors)}), 400
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/subnets/<int:subnet_id>/custom-fields', methods=['PATCH'])
|
|
@require_permission('edit_subnet')
|
|
def api_patch_subnet_custom_fields(subnet_id):
|
|
data = json_body()
|
|
with get_db_connection(current_app) as conn:
|
|
errors = update_entity_custom_fields(conn, 'subnet', subnet_id, data.get('custom_fields') or data, get_current_user_id(), is_json=True)
|
|
if errors:
|
|
return jsonify({'error': '; '.join(errors)}), 400
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/custom-fields/reorder', methods=['POST'])
|
|
@require_permission('manage_custom_fields')
|
|
def api_reorder_custom_fields():
|
|
data = json_body()
|
|
entity_type = data.get('entity_type')
|
|
field_orders = data.get('field_orders') or {}
|
|
if entity_type not in ('device', 'subnet'):
|
|
return jsonify({'error': 'Invalid entity_type'}), 400
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
for field_id, order in field_orders.items():
|
|
cursor.execute('UPDATE CustomFieldDefinition SET display_order = %s WHERE id = %s AND entity_type = %s',
|
|
(int(order), int(field_id), entity_type))
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/audit/export', methods=['GET'])
|
|
@require_permission('view_audit')
|
|
def api_audit_export():
|
|
where_sql, filter_params = build_audit_filters()
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(f'''
|
|
SELECT al.timestamp, u.name, al.action, al.details, s.name
|
|
FROM AuditLog al
|
|
LEFT JOIN User u ON al.user_id = u.id
|
|
LEFT JOIN Subnet s ON al.subnet_id = s.id
|
|
{where_sql}
|
|
ORDER BY al.timestamp DESC
|
|
''', tuple(filter_params))
|
|
rows = cursor.fetchall()
|
|
return csv_attachment(rows, ['Timestamp', 'User', 'Action', 'Details', 'Subnet'], 'audit_log.csv')
|
|
|
|
|
|
@app.route('/api/v2/users', methods=['POST'])
|
|
@require_permission('manage_users')
|
|
def api_add_user():
|
|
data = json_body()
|
|
name = (data.get('name') or '').strip()
|
|
email = (data.get('email') or '').strip()
|
|
password = data.get('password') or ''
|
|
role_id = data.get('role_id')
|
|
if not all([name, email, password]):
|
|
return jsonify({'error': 'Name, email, and password required'}), 400
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
try:
|
|
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, hash_password(password), role_id, api_key))
|
|
conn.commit()
|
|
return jsonify({'id': cursor.lastrowid}), 201
|
|
except mysql.connector.IntegrityError:
|
|
return jsonify({'error': 'Email already exists'}), 400
|
|
|
|
|
|
@app.route('/api/v2/users/<int:user_id>', methods=['PUT'])
|
|
@require_permission('manage_users')
|
|
def api_update_user(user_id):
|
|
data = json_body()
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
if 'name' in data:
|
|
cursor.execute('UPDATE User SET name = %s WHERE id = %s', (data['name'].strip(), user_id))
|
|
if 'email' in data:
|
|
cursor.execute('UPDATE User SET email = %s WHERE id = %s', (data['email'].strip(), user_id))
|
|
if 'password' in data and data['password']:
|
|
cursor.execute('UPDATE User SET password = %s WHERE id = %s', (hash_password(data['password']), user_id))
|
|
if 'role_id' in data:
|
|
cursor.execute('UPDATE User SET role_id = %s WHERE id = %s', (data['role_id'], user_id))
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/users/<int:user_id>', methods=['DELETE'])
|
|
@require_permission('manage_users')
|
|
def api_delete_user(user_id):
|
|
if user_id == get_current_user_id():
|
|
return jsonify({'error': 'Cannot delete your own account'}), 400
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM User WHERE id = %s', (user_id,))
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/users/<int:user_id>/regenerate-api-key', methods=['POST'])
|
|
@require_permission('manage_users')
|
|
def api_regenerate_user_api_key(user_id):
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
new_key = generate_api_key()
|
|
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (new_key, user_id))
|
|
conn.commit()
|
|
return jsonify({'api_key': new_key})
|
|
|
|
|
|
@app.route('/api/v2/roles', methods=['POST'])
|
|
@require_permission('manage_roles')
|
|
def api_add_role():
|
|
data = json_body()
|
|
name = (data.get('name') or '').strip()
|
|
description = (data.get('description') or '').strip()
|
|
permission_ids = data.get('permission_ids') or []
|
|
if not name:
|
|
return jsonify({'error': 'Name required'}), 400
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
try:
|
|
cursor.execute('INSERT INTO Role (name, description) VALUES (%s, %s)', (name, description))
|
|
role_id = cursor.lastrowid
|
|
for pid in permission_ids:
|
|
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)', (role_id, pid))
|
|
conn.commit()
|
|
return jsonify({'id': role_id}), 201
|
|
except mysql.connector.IntegrityError:
|
|
return jsonify({'error': 'Role already exists'}), 400
|
|
|
|
|
|
@app.route('/api/v2/roles/<int:role_id>', methods=['PUT'])
|
|
@require_permission('manage_roles')
|
|
def api_update_role(role_id):
|
|
data = json_body()
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
if 'name' in data:
|
|
cursor.execute('UPDATE Role SET name = %s WHERE id = %s', (data['name'].strip(), role_id))
|
|
if 'description' in data:
|
|
cursor.execute('UPDATE Role SET description = %s WHERE id = %s', (data['description'], role_id))
|
|
if 'require_2fa' in data:
|
|
cursor.execute('UPDATE Role SET require_2fa = %s WHERE id = %s', (bool(data['require_2fa']), role_id))
|
|
if 'permission_ids' in data:
|
|
cursor.execute('DELETE FROM RolePermission WHERE role_id = %s', (role_id,))
|
|
for pid in data['permission_ids']:
|
|
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)', (role_id, pid))
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/roles/<int:role_id>', methods=['DELETE'])
|
|
@require_permission('manage_roles')
|
|
def api_delete_role(role_id):
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT COUNT(*) FROM User WHERE role_id = %s', (role_id,))
|
|
if cursor.fetchone()[0] > 0:
|
|
return jsonify({'error': 'Role is assigned to users'}), 400
|
|
cursor.execute('DELETE FROM RolePermission WHERE role_id = %s', (role_id,))
|
|
cursor.execute('DELETE FROM Role WHERE id = %s', (role_id,))
|
|
conn.commit()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/v2/permissions', methods=['GET'])
|
|
@require_permission('manage_roles')
|
|
def api_permissions():
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT id, name, description, category FROM Permission ORDER BY category, name')
|
|
return items_response(cursor.fetchall())
|
|
|
|
|
|
@app.route('/api/v2/bulk/assign-ips', methods=['POST'])
|
|
@require_permission('add_device_ip')
|
|
def api_bulk_assign_ips():
|
|
data = json_body()
|
|
device_id = data.get('device_id')
|
|
ip_ids = data.get('ip_ids') or []
|
|
results = {'success': [], 'failed': []}
|
|
with get_db_connection(current_app) as conn:
|
|
for ip_id in ip_ids:
|
|
try:
|
|
ip = assign_ip_to_device(conn, device_id, ip_id, get_current_user_id())
|
|
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()
|
|
return jsonify(results)
|
|
|
|
|
|
@app.route('/api/v2/bulk/create-devices', methods=['POST'])
|
|
@require_permission('add_device')
|
|
def api_bulk_create_devices():
|
|
data = json_body()
|
|
names = data.get('names') or []
|
|
results = {'success': [], 'failed': []}
|
|
user_id = get_current_user_id()
|
|
with get_db_connection(current_app) as conn:
|
|
cursor = conn.cursor()
|
|
for name in names:
|
|
name = name.strip()
|
|
if not name:
|
|
continue
|
|
try:
|
|
cursor.execute('INSERT INTO Device (name) VALUES (%s)', (name,))
|
|
device_id = cursor.lastrowid
|
|
add_audit_log(user_id, 'add_device', f"Added device {name}", conn=conn)
|
|
results['success'].append({'id': device_id, 'name': name})
|
|
except Exception as e:
|
|
results['failed'].append({'name': name, 'reason': str(e)})
|
|
conn.commit()
|
|
return jsonify(results)
|
|
|
|
|
|
@app.route('/api/v2/bulk/assign-tags', methods=['POST'])
|
|
@require_permission('assign_device_tag')
|
|
def api_bulk_assign_tags():
|
|
data = json_body()
|
|
device_ids = data.get('device_ids') or []
|
|
tag_id = data.get('tag_id')
|
|
results = {'success': [], 'failed': []}
|
|
with get_db_connection(current_app) as conn:
|
|
for device_id in device_ids:
|
|
try:
|
|
assign_tag_to_device(conn, device_id, tag_id, get_current_user_id())
|
|
results['success'].append({'device_id': device_id})
|
|
except Exception as e:
|
|
results['failed'].append({'device_id': device_id, 'reason': str(e)})
|
|
conn.commit()
|
|
return jsonify(results)
|
|
|
|
|
|
@app.route('/api/v2/bulk/export-subnets', methods=['POST'])
|
|
@require_permission('export_subnet_csv')
|
|
def api_bulk_export_subnets():
|
|
data = json_body()
|
|
subnet_ids = data.get('subnet_ids') or []
|
|
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 name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
|
subnet = cursor.fetchone()
|
|
if not subnet:
|
|
continue
|
|
writer.writerow([f"Subnet: {subnet[0]} ({subnet[1]})"])
|
|
writer.writerow(['IP Address', 'Hostname', 'Description'])
|
|
writer.writerows(subnet_ip_csv_rows(fetch_subnet_ip_rows(cursor, subnet_id)))
|
|
writer.writerow([])
|
|
output.seek(0)
|
|
return send_file(BytesIO(output.getvalue().encode('utf-8')), mimetype='text/csv',
|
|
as_attachment=True, download_name='bulk_subnet_export.csv')
|
|
|
|
|
|
@app.route('/api/v2/racks/<int:rack_id>/export', methods=['GET'])
|
|
@require_permission('export_rack_csv')
|
|
def api_rack_export(rack_id):
|
|
with get_db_connection(current_app) as conn:
|
|
ctx = load_rack_view(conn, rack_id, side='front', networked_only=False)
|
|
if not ctx:
|
|
return jsonify({'error': 'Rack not found'}), 404
|
|
rack = ctx['rack']
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow([f"Rack: {rack['name']} ({rack['site']})"])
|
|
writer.writerow(['U', 'Side', 'Device'])
|
|
for rd in sorted(ctx['rack_devices'], key=lambda x: (-x['position_u'], x['side'])):
|
|
writer.writerow([rd['position_u'], rd['side'], rd.get('device_name') or rd.get('nonnet_device_name') or ''])
|
|
output.seek(0)
|
|
return send_file(BytesIO(output.getvalue().encode('utf-8')), mimetype='text/csv',
|
|
as_attachment=True, download_name=f"{rack['name']}_rack.csv".replace(' ', '_'))
|
|
|
|
|
|
# ── SPA static files ──────────────────────────────────────────────────────────
|
|
STATIC_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
|
DIST = os.path.join(STATIC_ROOT, 'dist')
|
|
|
|
|
|
@app.route('/favicon.ico')
|
|
def favicon():
|
|
logo = app.config['LOGO_PNG']
|
|
if logo.startswith(('http://', 'https://')):
|
|
return redirect(logo)
|
|
path = logo if os.path.isabs(logo) else os.path.join(os.path.dirname(os.path.abspath(__file__)), logo)
|
|
if os.path.isfile(path):
|
|
return send_file(path, mimetype='image/png')
|
|
abort(404)
|
|
|
|
|
|
@app.route('/assets/<path:sub>')
|
|
def spa_assets(sub):
|
|
return send_from_directory(os.path.join(DIST, 'assets'), sub)
|
|
|
|
|
|
@app.route('/', defaults={'path': ''})
|
|
@app.route('/<path:path>')
|
|
def spa(path):
|
|
if path.startswith('api/'):
|
|
abort(404)
|
|
index = os.path.join(DIST, 'index.html')
|
|
if not os.path.isfile(index):
|
|
return ('Frontend not built. Run: cd frontend && npm ci && npm run build', 503)
|
|
if path:
|
|
try:
|
|
file_path = safe_join(DIST, path)
|
|
except ValueError:
|
|
file_path = None
|
|
if file_path and os.path.isfile(file_path):
|
|
return send_file(file_path)
|
|
return send_from_directory(DIST, 'index.html')
|
|
|
|
|
|
# ── App startup ───────────────────────────────────────────────────────────────
|
|
init_db(app)
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host='0.0.0.0', port=5000, debug=True)
|