Add complete project structure and all files
This commit is contained in:
22
backend/app/api/__init__.py
Normal file
22
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# API routers
|
||||
from . import auth
|
||||
from . import dashboard
|
||||
from . import proxmox
|
||||
from . import templates
|
||||
from . import ipam
|
||||
from . import logs
|
||||
from . import settings
|
||||
from . import notifications
|
||||
from . import users
|
||||
|
||||
__all__ = [
|
||||
"auth",
|
||||
"dashboard",
|
||||
"proxmox",
|
||||
"templates",
|
||||
"ipam",
|
||||
"logs",
|
||||
"settings",
|
||||
"notifications",
|
||||
"users"
|
||||
]
|
||||
473
backend/app/api/auth.py
Normal file
473
backend/app/api/auth.py
Normal file
@@ -0,0 +1,473 @@
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import User
|
||||
from ..schemas import UserCreate, UserResponse, LoginRequest, Token
|
||||
from ..auth import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
get_current_active_admin,
|
||||
get_client_ip,
|
||||
get_current_session_token
|
||||
)
|
||||
from ..config import settings, utcnow
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
from ..services.security_service import SecurityService
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||
def login_page(request: Request):
|
||||
"""Login page"""
|
||||
context = {
|
||||
"request": request,
|
||||
"panel_name": settings.PANEL_NAME,
|
||||
"version": settings.VERSION,
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("login.html", context)
|
||||
|
||||
|
||||
@router.post("/api/auth/login", response_model=Token)
|
||||
def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Authenticate user and return JWT token with session management
|
||||
"""
|
||||
# Get client info
|
||||
client_ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent", "")[:500]
|
||||
|
||||
# Check if IP is blocked
|
||||
is_blocked, block_reason = SecurityService.is_ip_blocked(db, client_ip)
|
||||
if is_blocked:
|
||||
logger.warning(f"Blocked IP {client_ip} attempted login: {block_reason}")
|
||||
SecurityService.record_login_attempt(
|
||||
db, client_ip, login_data.username, False, "IP blocked", user_agent
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied: {block_reason}"
|
||||
)
|
||||
|
||||
# Check for brute force
|
||||
should_block, reason = SecurityService.check_brute_force(db, client_ip, login_data.username)
|
||||
if should_block:
|
||||
block_duration = SecurityService.get_setting_int(db, "ip_block_duration_minutes", 60)
|
||||
SecurityService.block_ip(db, client_ip, reason, "system", block_duration)
|
||||
SecurityService.record_login_attempt(
|
||||
db, client_ip, login_data.username, False, "Brute force detected", user_agent
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Too many failed attempts. IP blocked temporarily."
|
||||
)
|
||||
|
||||
# Find user
|
||||
user = db.query(User).filter(User.username == login_data.username).first()
|
||||
|
||||
if not user:
|
||||
SecurityService.record_login_attempt(
|
||||
db, client_ip, login_data.username, False, "User not found", user_agent
|
||||
)
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="login_failed",
|
||||
username=login_data.username,
|
||||
ip_address=client_ip,
|
||||
success=False,
|
||||
error_message="User not found",
|
||||
user_agent=user_agent
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if account is locked
|
||||
is_locked, locked_until = SecurityService.is_account_locked(user)
|
||||
if is_locked:
|
||||
SecurityService.record_login_attempt(
|
||||
db, client_ip, login_data.username, False, "Account locked", user_agent
|
||||
)
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="login_blocked",
|
||||
username=login_data.username,
|
||||
ip_address=client_ip,
|
||||
success=False,
|
||||
error_message="Account locked",
|
||||
user_agent=user_agent,
|
||||
user_id=user.id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Account is locked until {locked_until.strftime('%H:%M:%S')}"
|
||||
)
|
||||
|
||||
# Verify password
|
||||
if not verify_password(login_data.password, user.hashed_password):
|
||||
SecurityService.record_login_attempt(
|
||||
db, client_ip, login_data.username, False, "Wrong password", user_agent
|
||||
)
|
||||
|
||||
# Increment failed attempts
|
||||
was_locked = SecurityService.increment_failed_attempts(db, user)
|
||||
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="login_failed",
|
||||
username=login_data.username,
|
||||
ip_address=client_ip,
|
||||
success=False,
|
||||
error_message="Incorrect password" + (" - account locked" if was_locked else ""),
|
||||
user_agent=user_agent,
|
||||
user_id=user.id
|
||||
)
|
||||
|
||||
if was_locked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account locked due to too many failed attempts"
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if user is active
|
||||
if not user.is_active:
|
||||
SecurityService.record_login_attempt(
|
||||
db, client_ip, login_data.username, False, "Account disabled", user_agent
|
||||
)
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="login_blocked",
|
||||
username=login_data.username,
|
||||
ip_address=client_ip,
|
||||
success=False,
|
||||
error_message="Account disabled",
|
||||
user_agent=user_agent,
|
||||
user_id=user.id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled"
|
||||
)
|
||||
|
||||
# SUCCESS - Reset failed attempts
|
||||
SecurityService.reset_failed_attempts(db, user)
|
||||
|
||||
# Create session
|
||||
session, was_other_terminated = SecurityService.create_session(
|
||||
db, user, client_ip, user_agent
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.last_login = utcnow()
|
||||
db.commit()
|
||||
|
||||
# Create access token with session binding
|
||||
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
|
||||
access_token_expires = timedelta(minutes=session_timeout)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username},
|
||||
session_token=session.session_token,
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
# Record successful login
|
||||
SecurityService.record_login_attempt(
|
||||
db, client_ip, login_data.username, True, None, user_agent
|
||||
)
|
||||
|
||||
# Log successful login
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="login",
|
||||
username=user.username,
|
||||
ip_address=client_ip,
|
||||
success=True,
|
||||
user_agent=user_agent,
|
||||
user_id=user.id,
|
||||
details={"session_id": session.id, "other_session_terminated": was_other_terminated}
|
||||
)
|
||||
|
||||
logger.info(f"User {user.username} logged in from {client_ip}" +
|
||||
(" (other session terminated)" if was_other_terminated else ""))
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"session_id": session.id,
|
||||
"other_session_terminated": was_other_terminated
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/auth/logout")
|
||||
def logout(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session_token: str = Depends(get_current_session_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Logout user and terminate current session only"""
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Terminate only current session (allow multiple sessions)
|
||||
if session_token:
|
||||
SecurityService.terminate_session(db, session_token)
|
||||
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="logout",
|
||||
username=current_user.username,
|
||||
ip_address=client_ip,
|
||||
success=True,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
logger.info(f"User {current_user.username} logged out")
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@router.get("/api/auth/me")
|
||||
def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user information with permissions"""
|
||||
permissions = {}
|
||||
if current_user.role:
|
||||
permissions = current_user.role.permissions or {}
|
||||
elif current_user.is_admin:
|
||||
# Admin has all permissions
|
||||
from ..services.security_service import SecurityService
|
||||
all_perms = SecurityService.get_all_permissions()
|
||||
for category, perms in all_perms.items():
|
||||
for perm in perms:
|
||||
permissions[perm] = True
|
||||
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"full_name": current_user.full_name,
|
||||
"is_admin": current_user.is_admin,
|
||||
"is_active": current_user.is_active,
|
||||
"role": {
|
||||
"id": current_user.role.id if current_user.role else None,
|
||||
"name": current_user.role.name if current_user.role else ("admin" if current_user.is_admin else "user"),
|
||||
"display_name": current_user.role.display_name if current_user.role else ("Administrator" if current_user.is_admin else "User")
|
||||
},
|
||||
"permissions": permissions,
|
||||
"require_password_change": current_user.require_password_change,
|
||||
"last_login": current_user.last_login.isoformat() if current_user.last_login else None,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/auth/sessions")
|
||||
def get_my_sessions(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""Get current user's active sessions"""
|
||||
sessions = SecurityService.get_user_sessions(db, current_user.id)
|
||||
return [{
|
||||
"id": s.id,
|
||||
"ip_address": s.ip_address,
|
||||
"device_info": s.device_info,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"last_activity": s.last_activity.isoformat() if s.last_activity else None,
|
||||
"expires_at": s.expires_at.isoformat() if s.expires_at else None
|
||||
} for s in sessions]
|
||||
|
||||
|
||||
@router.post("/api/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register_user(
|
||||
user_data: UserCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_active_admin)
|
||||
):
|
||||
"""
|
||||
Register a new user (admin only)
|
||||
"""
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Check if username already exists
|
||||
if db.query(User).filter(User.username == user_data.username).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered"
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Validate password
|
||||
is_valid, errors = SecurityService.validate_password(db, user_data.password)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="; ".join(errors)
|
||||
)
|
||||
|
||||
# Create new user
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
new_user = User(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
full_name=user_data.full_name,
|
||||
hashed_password=hashed_password,
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
last_password_change=utcnow()
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="user_created",
|
||||
username=current_admin.username,
|
||||
ip_address=client_ip,
|
||||
success=True,
|
||||
user_id=current_admin.id,
|
||||
details={"new_user": new_user.username}
|
||||
)
|
||||
|
||||
logger.info(f"New user registered: {new_user.username} by admin {current_admin.username}")
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
@router.get("/api/users", response_model=list[UserResponse])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_active_admin)
|
||||
):
|
||||
"""
|
||||
List all users (admin only)
|
||||
"""
|
||||
users = db.query(User).all()
|
||||
return users
|
||||
|
||||
|
||||
@router.delete("/api/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_active_admin)
|
||||
):
|
||||
"""
|
||||
Delete a user (admin only)
|
||||
"""
|
||||
client_ip = get_client_ip(request)
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Prevent deleting yourself
|
||||
if user.id == current_admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete your own account"
|
||||
)
|
||||
|
||||
username = user.username
|
||||
|
||||
# Terminate sessions before deletion
|
||||
SecurityService.terminate_all_user_sessions(db, user_id)
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="user_deleted",
|
||||
username=current_admin.username,
|
||||
ip_address=client_ip,
|
||||
success=True,
|
||||
user_id=current_admin.id,
|
||||
details={"deleted_user": username}
|
||||
)
|
||||
|
||||
logger.info(f"User {username} deleted by admin {current_admin.username}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/api/auth/change-password")
|
||||
def change_password(
|
||||
old_password: str = Form(...),
|
||||
new_password: str = Form(...),
|
||||
request: Request = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Change user password
|
||||
"""
|
||||
client_ip = get_client_ip(request) if request else "unknown"
|
||||
|
||||
# Verify old password
|
||||
if not verify_password(old_password, current_user.hashed_password):
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="password_change_failed",
|
||||
username=current_user.username,
|
||||
ip_address=client_ip,
|
||||
success=False,
|
||||
error_message="Incorrect current password",
|
||||
user_id=current_user.id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect password"
|
||||
)
|
||||
|
||||
# Validate new password
|
||||
is_valid, errors = SecurityService.validate_password(db, new_password)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="; ".join(errors)
|
||||
)
|
||||
|
||||
# Update password
|
||||
current_user.hashed_password = get_password_hash(new_password)
|
||||
current_user.last_password_change = utcnow()
|
||||
current_user.require_password_change = False
|
||||
db.commit()
|
||||
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="password_changed",
|
||||
username=current_user.username,
|
||||
ip_address=client_ip,
|
||||
success=True,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
logger.info(f"User {current_user.username} changed password")
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
53
backend/app/api/dashboard.py
Normal file
53
backend/app/api/dashboard.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import ProxmoxServer
|
||||
from ..template_helpers import add_i18n_context
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
total_servers = db.query(ProxmoxServer).count()
|
||||
online_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == True).count()
|
||||
offline_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == False).count()
|
||||
|
||||
recent_servers = db.query(ProxmoxServer).order_by(ProxmoxServer.id.desc()).limit(6).all()
|
||||
|
||||
stats = {
|
||||
"total_servers": total_servers,
|
||||
"online_servers": online_servers,
|
||||
"offline_servers": offline_servers,
|
||||
}
|
||||
|
||||
lang = request.cookies.get("language", "en")
|
||||
from ..i18n import t
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"stats": stats,
|
||||
"recent_servers": recent_servers,
|
||||
"page_title": t('nav_dashboard', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", context)
|
||||
|
||||
|
||||
@router.get("/containers", response_class=HTMLResponse, include_in_schema=False)
|
||||
def containers(request: Request):
|
||||
lang = request.cookies.get("language", "en")
|
||||
from ..i18n import t
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_docker', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("containers.html", context)
|
||||
898
backend/app/api/ipam.py
Normal file
898
backend/app/api/ipam.py
Normal file
@@ -0,0 +1,898 @@
|
||||
"""
|
||||
IPAM API Routes
|
||||
IP Address Management endpoints for networks, pools, and allocations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, ProxmoxServer, User, VMInstance
|
||||
from ..schemas import (
|
||||
IPAMNetworkCreate, IPAMNetworkUpdate, IPAMNetworkResponse, IPAMNetworkStats,
|
||||
IPAMPoolCreate, IPAMPoolUpdate, IPAMPoolResponse,
|
||||
IPAMAllocationCreate, IPAMAllocationUpdate, IPAMAllocationResponse,
|
||||
IPAMHistoryResponse, IPAMAutoAllocateRequest
|
||||
)
|
||||
from ..ipam_service import IPAMService
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..template_helpers import add_i18n_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def ipam_dashboard(request: Request):
|
||||
"""IPAM Dashboard page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_dashboard.html", context)
|
||||
|
||||
|
||||
@router.get("/networks", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def ipam_networks_page(request: Request):
|
||||
"""IPAM Networks management page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_networks.html", context)
|
||||
|
||||
|
||||
@router.get("/network/{network_id}", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def ipam_network_detail(request: Request, network_id: int):
|
||||
"""IPAM Network detail page with IP grid"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"network_id": network_id,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_network_detail.html", context)
|
||||
|
||||
|
||||
@router.get("/allocations", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def ipam_allocations_page(request: Request):
|
||||
"""IPAM Allocations page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_allocations.html", context)
|
||||
|
||||
|
||||
@router.get("/history", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def ipam_history_page(request: Request):
|
||||
"""IPAM History page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_history.html", context)
|
||||
|
||||
|
||||
# ==================== Network API ====================
|
||||
|
||||
@router.get("/api/networks", response_model=List[IPAMNetworkResponse])
|
||||
async def get_networks(
|
||||
is_active: Optional[bool] = None,
|
||||
proxmox_server_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get all IPAM networks"""
|
||||
query = db.query(IPAMNetwork)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(IPAMNetwork.is_active == is_active)
|
||||
|
||||
if proxmox_server_id:
|
||||
query = query.filter(IPAMNetwork.proxmox_server_id == proxmox_server_id)
|
||||
|
||||
networks = query.order_by(IPAMNetwork.name).all()
|
||||
|
||||
# Get server names for networks
|
||||
server_ids = [n.proxmox_server_id for n in networks if n.proxmox_server_id]
|
||||
servers = {}
|
||||
if server_ids:
|
||||
server_records = db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids)).all()
|
||||
servers = {s.id: s.name for s in server_records}
|
||||
|
||||
# Add computed stats to each network
|
||||
ipam = IPAMService(db)
|
||||
result = []
|
||||
for network in networks:
|
||||
stats = ipam.get_network_stats(network.id)
|
||||
network_dict = {
|
||||
**network.__dict__,
|
||||
'total_ips': stats.get('total_ips', 0),
|
||||
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
|
||||
'available_ips': stats.get('available_ips', 0),
|
||||
'utilization_percent': stats.get('utilization_percent', 0),
|
||||
'server_name': servers.get(network.proxmox_server_id) if network.proxmox_server_id else None
|
||||
}
|
||||
result.append(network_dict)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
|
||||
async def get_network(
|
||||
network_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get a specific IPAM network"""
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="Network not found")
|
||||
|
||||
ipam = IPAMService(db)
|
||||
stats = ipam.get_network_stats(network_id)
|
||||
|
||||
return {
|
||||
**network.__dict__,
|
||||
'total_ips': stats.get('total_ips', 0),
|
||||
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
|
||||
'available_ips': stats.get('available_ips', 0),
|
||||
'utilization_percent': stats.get('utilization_percent', 0)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/networks/{network_id}/stats", response_model=IPAMNetworkStats)
|
||||
async def get_network_stats(
|
||||
network_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get detailed statistics for a network"""
|
||||
ipam = IPAMService(db)
|
||||
stats = ipam.get_network_stats(network_id)
|
||||
|
||||
if not stats:
|
||||
raise HTTPException(status_code=404, detail="Network not found")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/api/networks/{network_id}/ip-map")
|
||||
async def get_network_ip_map(
|
||||
network_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get IP address map for visualization"""
|
||||
ipam = IPAMService(db)
|
||||
ip_map = ipam.get_network_ip_map(network_id)
|
||||
|
||||
if not ip_map:
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="Network not found")
|
||||
|
||||
return {"network_id": network_id, "ip_map": ip_map}
|
||||
|
||||
|
||||
@router.post("/api/networks", response_model=IPAMNetworkResponse)
|
||||
async def create_network(
|
||||
network_data: IPAMNetworkCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Create a new IPAM network"""
|
||||
# Check for duplicate network CIDR
|
||||
existing = db.query(IPAMNetwork).filter(IPAMNetwork.network == network_data.network).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"Network {network_data.network} already exists")
|
||||
|
||||
network = IPAMNetwork(**network_data.model_dump())
|
||||
db.add(network)
|
||||
db.commit()
|
||||
db.refresh(network)
|
||||
|
||||
logger.info(f"User {current_user.username} created network {network.name} ({network.network})")
|
||||
return network
|
||||
|
||||
|
||||
@router.put("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
|
||||
async def update_network(
|
||||
network_id: int,
|
||||
network_data: IPAMNetworkUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Update an IPAM network"""
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="Network not found")
|
||||
|
||||
update_data = network_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(network, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(network)
|
||||
|
||||
logger.info(f"User {current_user.username} updated network {network.name}")
|
||||
return network
|
||||
|
||||
|
||||
@router.delete("/api/networks/{network_id}")
|
||||
async def delete_network(
|
||||
network_id: int,
|
||||
force: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Delete an IPAM network"""
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="Network not found")
|
||||
|
||||
# Check for existing allocations
|
||||
alloc_count = db.query(IPAMAllocation).filter(IPAMAllocation.network_id == network_id).count()
|
||||
if alloc_count > 0 and not force:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Network has {alloc_count} allocations. Use force=true to delete anyway."
|
||||
)
|
||||
|
||||
# Delete related data
|
||||
db.query(IPAMAllocation).filter(IPAMAllocation.network_id == network_id).delete()
|
||||
db.query(IPAMPool).filter(IPAMPool.network_id == network_id).delete()
|
||||
db.query(IPAMHistory).filter(IPAMHistory.network_id == network_id).delete()
|
||||
db.delete(network)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"User {current_user.username} deleted network {network.name}")
|
||||
return {"message": f"Network {network.name} deleted"}
|
||||
|
||||
|
||||
# ==================== Pool API ====================
|
||||
|
||||
@router.get("/api/pools", response_model=List[IPAMPoolResponse])
|
||||
async def get_pools(
|
||||
network_id: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get all IP pools"""
|
||||
query = db.query(IPAMPool)
|
||||
|
||||
if network_id:
|
||||
query = query.filter(IPAMPool.network_id == network_id)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(IPAMPool.is_active == is_active)
|
||||
|
||||
pools = query.order_by(IPAMPool.network_id, IPAMPool.range_start).all()
|
||||
|
||||
# Add stats to each pool
|
||||
ipam = IPAMService(db)
|
||||
result = []
|
||||
for pool in pools:
|
||||
stats = ipam.get_pool_stats(pool.id)
|
||||
pool_dict = {
|
||||
**pool.__dict__,
|
||||
'total_ips': stats.get('total_ips', 0),
|
||||
'used_ips': stats.get('used_ips', 0),
|
||||
'available_ips': stats.get('available_ips', 0)
|
||||
}
|
||||
result.append(pool_dict)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/pools", response_model=IPAMPoolResponse)
|
||||
async def create_pool(
|
||||
pool_data: IPAMPoolCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Create a new IP pool"""
|
||||
# Validate network exists
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == pool_data.network_id).first()
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="Network not found")
|
||||
|
||||
# Validate range
|
||||
ipam = IPAMService(db)
|
||||
is_valid, error = ipam.validate_pool_range(
|
||||
pool_data.network_id,
|
||||
pool_data.range_start,
|
||||
pool_data.range_end
|
||||
)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
pool = IPAMPool(**pool_data.model_dump())
|
||||
db.add(pool)
|
||||
db.commit()
|
||||
db.refresh(pool)
|
||||
|
||||
logger.info(f"User {current_user.username} created pool {pool.name} in network {network.name}")
|
||||
return pool
|
||||
|
||||
|
||||
@router.put("/api/pools/{pool_id}", response_model=IPAMPoolResponse)
|
||||
async def update_pool(
|
||||
pool_id: int,
|
||||
pool_data: IPAMPoolUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Update an IP pool"""
|
||||
pool = db.query(IPAMPool).filter(IPAMPool.id == pool_id).first()
|
||||
if not pool:
|
||||
raise HTTPException(status_code=404, detail="Pool not found")
|
||||
|
||||
# Validate new range if provided
|
||||
update_data = pool_data.model_dump(exclude_unset=True)
|
||||
if 'range_start' in update_data or 'range_end' in update_data:
|
||||
ipam = IPAMService(db)
|
||||
is_valid, error = ipam.validate_pool_range(
|
||||
pool.network_id,
|
||||
update_data.get('range_start', pool.range_start),
|
||||
update_data.get('range_end', pool.range_end),
|
||||
exclude_pool_id=pool_id
|
||||
)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(pool, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(pool)
|
||||
|
||||
logger.info(f"User {current_user.username} updated pool {pool.name}")
|
||||
return pool
|
||||
|
||||
|
||||
@router.delete("/api/pools/{pool_id}")
|
||||
async def delete_pool(
|
||||
pool_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Delete an IP pool"""
|
||||
pool = db.query(IPAMPool).filter(IPAMPool.id == pool_id).first()
|
||||
if not pool:
|
||||
raise HTTPException(status_code=404, detail="Pool not found")
|
||||
|
||||
# Update allocations to remove pool reference
|
||||
db.query(IPAMAllocation).filter(IPAMAllocation.pool_id == pool_id).update({IPAMAllocation.pool_id: None})
|
||||
db.delete(pool)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"User {current_user.username} deleted pool {pool.name}")
|
||||
return {"message": f"Pool {pool.name} deleted"}
|
||||
|
||||
|
||||
# ==================== Allocation API ====================
|
||||
|
||||
@router.get("/api/allocations", response_model=List[IPAMAllocationResponse])
|
||||
async def get_allocations(
|
||||
network_id: Optional[int] = None,
|
||||
pool_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = Query(default=100, le=1000),
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get IP allocations with filtering"""
|
||||
query = db.query(IPAMAllocation)
|
||||
|
||||
if network_id:
|
||||
query = query.filter(IPAMAllocation.network_id == network_id)
|
||||
|
||||
if pool_id:
|
||||
query = query.filter(IPAMAllocation.pool_id == pool_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(IPAMAllocation.status == status)
|
||||
|
||||
if resource_type:
|
||||
query = query.filter(IPAMAllocation.resource_type == resource_type)
|
||||
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
query = query.filter(
|
||||
(IPAMAllocation.ip_address.like(search_filter)) |
|
||||
(IPAMAllocation.resource_name.like(search_filter)) |
|
||||
(IPAMAllocation.hostname.like(search_filter))
|
||||
)
|
||||
|
||||
allocations = query.order_by(IPAMAllocation.ip_address).offset(offset).limit(limit).all()
|
||||
|
||||
# Add network/pool names
|
||||
networks = {n.id: n.name for n in db.query(IPAMNetwork).all()}
|
||||
pools = {p.id: p.name for p in db.query(IPAMPool).all()}
|
||||
|
||||
result = []
|
||||
for alloc in allocations:
|
||||
alloc_dict = {
|
||||
**alloc.__dict__,
|
||||
'network_name': networks.get(alloc.network_id),
|
||||
'pool_name': pools.get(alloc.pool_id) if alloc.pool_id else None
|
||||
}
|
||||
result.append(alloc_dict)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
|
||||
async def get_allocation(
|
||||
allocation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get a specific allocation"""
|
||||
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
|
||||
if not allocation:
|
||||
raise HTTPException(status_code=404, detail="Allocation not found")
|
||||
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == allocation.network_id).first()
|
||||
pool = db.query(IPAMPool).filter(IPAMPool.id == allocation.pool_id).first() if allocation.pool_id else None
|
||||
|
||||
return {
|
||||
**allocation.__dict__,
|
||||
'network_name': network.name if network else None,
|
||||
'pool_name': pool.name if pool else None
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/allocations", response_model=IPAMAllocationResponse)
|
||||
async def create_allocation(
|
||||
alloc_data: IPAMAllocationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Manually create an IP allocation"""
|
||||
ipam = IPAMService(db)
|
||||
allocation, error = ipam.allocate_ip(
|
||||
ip_address=alloc_data.ip_address,
|
||||
network_id=alloc_data.network_id,
|
||||
pool_id=alloc_data.pool_id,
|
||||
resource_type=alloc_data.resource_type,
|
||||
resource_id=alloc_data.resource_id,
|
||||
resource_name=alloc_data.resource_name,
|
||||
hostname=alloc_data.hostname,
|
||||
fqdn=alloc_data.fqdn,
|
||||
mac_address=alloc_data.mac_address,
|
||||
proxmox_server_id=alloc_data.proxmox_server_id,
|
||||
proxmox_vmid=alloc_data.proxmox_vmid,
|
||||
proxmox_node=alloc_data.proxmox_node,
|
||||
allocation_type=alloc_data.allocation_type,
|
||||
status=alloc_data.status,
|
||||
allocated_by=current_user.username,
|
||||
notes=alloc_data.notes
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
return allocation
|
||||
|
||||
|
||||
@router.post("/api/allocations/auto", response_model=IPAMAllocationResponse)
|
||||
async def auto_allocate_ip(
|
||||
request: IPAMAutoAllocateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Automatically allocate the next available IP"""
|
||||
ipam = IPAMService(db)
|
||||
allocation, error = ipam.auto_allocate_ip(
|
||||
network_id=request.network_id,
|
||||
pool_id=request.pool_id,
|
||||
resource_type=request.resource_type,
|
||||
resource_id=request.resource_id,
|
||||
resource_name=request.resource_name,
|
||||
hostname=request.hostname,
|
||||
allocated_by=current_user.username,
|
||||
notes=request.notes
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
return allocation
|
||||
|
||||
|
||||
@router.put("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
|
||||
async def update_allocation(
|
||||
allocation_id: int,
|
||||
alloc_data: IPAMAllocationUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Update an allocation"""
|
||||
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
|
||||
if not allocation:
|
||||
raise HTTPException(status_code=404, detail="Allocation not found")
|
||||
|
||||
ipam = IPAMService(db)
|
||||
updated, error = ipam.update_allocation(
|
||||
allocation.ip_address,
|
||||
updated_by=current_user.username,
|
||||
**alloc_data.model_dump(exclude_unset=True)
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/api/allocations/{allocation_id}")
|
||||
async def delete_allocation(
|
||||
allocation_id: int,
|
||||
reason: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""Release/delete an IP allocation"""
|
||||
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
|
||||
if not allocation:
|
||||
raise HTTPException(status_code=404, detail="Allocation not found")
|
||||
|
||||
ipam = IPAMService(db)
|
||||
success, error = ipam.release_ip(
|
||||
allocation.ip_address,
|
||||
released_by=current_user.username,
|
||||
reason=reason
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
return {"message": f"IP {allocation.ip_address} released"}
|
||||
|
||||
|
||||
@router.get("/api/allocations/next-available/{network_id}")
|
||||
async def get_next_available_ip(
|
||||
network_id: int,
|
||||
pool_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get the next available IP in a network (without allocating)"""
|
||||
ipam = IPAMService(db)
|
||||
ip = ipam.get_next_available_ip(network_id, pool_id)
|
||||
|
||||
if not ip:
|
||||
raise HTTPException(status_code=404, detail="No available IP addresses")
|
||||
|
||||
return {"next_available_ip": ip}
|
||||
|
||||
|
||||
# ==================== History API ====================
|
||||
|
||||
@router.get("/api/history", response_model=List[IPAMHistoryResponse])
|
||||
async def get_history(
|
||||
network_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
limit: int = Query(default=100, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get IPAM history"""
|
||||
query = db.query(IPAMHistory)
|
||||
|
||||
if network_id:
|
||||
query = query.filter(IPAMHistory.network_id == network_id)
|
||||
|
||||
if ip_address:
|
||||
query = query.filter(IPAMHistory.ip_address == ip_address)
|
||||
|
||||
if action:
|
||||
query = query.filter(IPAMHistory.action == action)
|
||||
|
||||
return query.order_by(IPAMHistory.performed_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/api/history/ip/{ip_address}", response_model=List[IPAMHistoryResponse])
|
||||
async def get_ip_history(
|
||||
ip_address: str,
|
||||
limit: int = Query(default=50, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get history for a specific IP address"""
|
||||
ipam = IPAMService(db)
|
||||
return ipam.get_ip_history(ip_address, limit)
|
||||
|
||||
|
||||
# ==================== Utility API ====================
|
||||
|
||||
@router.get("/api/conflicts")
|
||||
async def detect_conflicts(
|
||||
network_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Detect IP address conflicts"""
|
||||
ipam = IPAMService(db)
|
||||
conflicts = ipam.detect_conflicts(network_id)
|
||||
return {"conflicts": conflicts, "count": len(conflicts)}
|
||||
|
||||
|
||||
@router.get("/api/summary")
|
||||
async def get_ipam_summary(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.view"))
|
||||
):
|
||||
"""Get overall IPAM summary"""
|
||||
networks_count = db.query(IPAMNetwork).filter(IPAMNetwork.is_active == True).count()
|
||||
pools_count = db.query(IPAMPool).filter(IPAMPool.is_active == True).count()
|
||||
|
||||
total_allocated = db.query(IPAMAllocation).filter(IPAMAllocation.status == 'allocated').count()
|
||||
total_reserved = db.query(IPAMAllocation).filter(IPAMAllocation.status == 'reserved').count()
|
||||
|
||||
# Count by resource type
|
||||
vm_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'vm').count()
|
||||
lxc_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'lxc').count()
|
||||
physical_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'physical').count()
|
||||
|
||||
# Recent activity
|
||||
recent_allocations = db.query(IPAMHistory).filter(
|
||||
IPAMHistory.action == 'allocated'
|
||||
).order_by(IPAMHistory.performed_at.desc()).limit(5).all()
|
||||
|
||||
return {
|
||||
'networks_count': networks_count,
|
||||
'pools_count': pools_count,
|
||||
'total_allocated': total_allocated,
|
||||
'total_reserved': total_reserved,
|
||||
'vm_count': vm_count,
|
||||
'lxc_count': lxc_count,
|
||||
'physical_count': physical_count,
|
||||
'recent_allocations': [
|
||||
{
|
||||
'ip': h.ip_address,
|
||||
'resource': h.resource_name,
|
||||
'by': h.performed_by,
|
||||
'at': h.performed_at.isoformat() if h.performed_at else None
|
||||
} for h in recent_allocations
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ==================== Maintenance API ====================
|
||||
|
||||
@router.get("/api/orphans")
|
||||
async def get_orphan_allocations(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""
|
||||
Get orphan IPAM allocations - IPs allocated to VMs that no longer exist.
|
||||
"""
|
||||
orphans = []
|
||||
|
||||
# 1. Allocations with proxmox_vmid but VM doesn't exist or is deleted
|
||||
allocations_with_vmid = db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_vmid.isnot(None),
|
||||
IPAMAllocation.proxmox_server_id.isnot(None),
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).all()
|
||||
|
||||
for alloc in allocations_with_vmid:
|
||||
vm = db.query(VMInstance).filter(
|
||||
VMInstance.vmid == alloc.proxmox_vmid,
|
||||
VMInstance.server_id == alloc.proxmox_server_id,
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not vm:
|
||||
orphans.append({
|
||||
'id': alloc.id,
|
||||
'ip_address': alloc.ip_address,
|
||||
'resource_name': alloc.resource_name,
|
||||
'resource_type': alloc.resource_type,
|
||||
'proxmox_vmid': alloc.proxmox_vmid,
|
||||
'proxmox_server_id': alloc.proxmox_server_id,
|
||||
'reason': 'VM/LXC not found or deleted'
|
||||
})
|
||||
|
||||
# 2. Allocations to non-existent servers
|
||||
allocations_with_server = db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_server_id.isnot(None),
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).all()
|
||||
|
||||
server_ids = {a.proxmox_server_id for a in allocations_with_server}
|
||||
existing_servers = {s.id for s in db.query(ProxmoxServer.id).filter(ProxmoxServer.id.in_(server_ids)).all()}
|
||||
|
||||
for alloc in allocations_with_server:
|
||||
if alloc.proxmox_server_id not in existing_servers:
|
||||
# Check if not already in orphans
|
||||
if not any(o['id'] == alloc.id for o in orphans):
|
||||
orphans.append({
|
||||
'id': alloc.id,
|
||||
'ip_address': alloc.ip_address,
|
||||
'resource_name': alloc.resource_name,
|
||||
'resource_type': alloc.resource_type,
|
||||
'proxmox_vmid': alloc.proxmox_vmid,
|
||||
'proxmox_server_id': alloc.proxmox_server_id,
|
||||
'reason': f'Server {alloc.proxmox_server_id} not found'
|
||||
})
|
||||
|
||||
return {
|
||||
'orphans': orphans,
|
||||
'count': len(orphans)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/cleanup-orphans")
|
||||
async def cleanup_orphan_allocations(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""
|
||||
Clean up orphan IPAM allocations - release IPs for VMs that no longer exist.
|
||||
"""
|
||||
ipam = IPAMService(db)
|
||||
released = []
|
||||
errors = []
|
||||
|
||||
# Get orphans
|
||||
orphans_result = await get_orphan_allocations(db, current_user)
|
||||
orphans = orphans_result['orphans']
|
||||
|
||||
for orphan in orphans:
|
||||
try:
|
||||
success, error = ipam.release_ip(
|
||||
ip_address=orphan['ip_address'],
|
||||
released_by=current_user.username,
|
||||
reason=f"Orphan cleanup: {orphan['reason']}"
|
||||
)
|
||||
if success:
|
||||
released.append(orphan['ip_address'])
|
||||
logger.info(f"Released orphan IP {orphan['ip_address']} by {current_user.username}")
|
||||
else:
|
||||
errors.append({'ip': orphan['ip_address'], 'error': error})
|
||||
except Exception as e:
|
||||
errors.append({'ip': orphan['ip_address'], 'error': str(e)})
|
||||
|
||||
return {
|
||||
'released': released,
|
||||
'released_count': len(released),
|
||||
'errors': errors,
|
||||
'error_count': len(errors)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/unlinked")
|
||||
async def get_unlinked_allocations(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""
|
||||
Get IPAM allocations without proxmox_vmid that can be linked to existing VMs by name.
|
||||
"""
|
||||
unlinked = []
|
||||
|
||||
# Allocations without proxmox_vmid
|
||||
allocations = db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_vmid.is_(None),
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).all()
|
||||
|
||||
for alloc in allocations:
|
||||
# Try to find matching VM by name
|
||||
vm = db.query(VMInstance).filter(
|
||||
VMInstance.name.ilike(alloc.resource_name),
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
unlinked.append({
|
||||
'id': alloc.id,
|
||||
'ip_address': alloc.ip_address,
|
||||
'resource_name': alloc.resource_name,
|
||||
'resource_type': alloc.resource_type,
|
||||
'can_link': vm is not None,
|
||||
'suggested_vmid': vm.vmid if vm else None,
|
||||
'suggested_server_id': vm.server_id if vm else None,
|
||||
'suggested_server_name': None # Will be filled below
|
||||
})
|
||||
|
||||
if vm:
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == vm.server_id).first()
|
||||
unlinked[-1]['suggested_server_name'] = server.name if server else None
|
||||
|
||||
return {
|
||||
'unlinked': unlinked,
|
||||
'count': len(unlinked),
|
||||
'linkable_count': sum(1 for u in unlinked if u['can_link'])
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/link-allocations")
|
||||
async def link_allocations_to_vms(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam.manage"))
|
||||
):
|
||||
"""
|
||||
Link IPAM allocations to real VMs by matching resource_name.
|
||||
"""
|
||||
linked = []
|
||||
not_found = []
|
||||
|
||||
# Get unlinked allocations
|
||||
allocations = db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_vmid.is_(None),
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).all()
|
||||
|
||||
for alloc in allocations:
|
||||
# Find VM by name (case-insensitive)
|
||||
vm = db.query(VMInstance).filter(
|
||||
VMInstance.name.ilike(alloc.resource_name),
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if vm:
|
||||
# Update allocation with VM info
|
||||
alloc.proxmox_vmid = vm.vmid
|
||||
alloc.proxmox_server_id = vm.server_id
|
||||
alloc.resource_id = vm.vmid
|
||||
|
||||
linked.append({
|
||||
'ip_address': alloc.ip_address,
|
||||
'resource_name': alloc.resource_name,
|
||||
'vmid': vm.vmid,
|
||||
'server_id': vm.server_id
|
||||
})
|
||||
logger.info(f"Linked IP {alloc.ip_address} to VM {vm.vmid} on server {vm.server_id}")
|
||||
else:
|
||||
not_found.append({
|
||||
'ip_address': alloc.ip_address,
|
||||
'resource_name': alloc.resource_name
|
||||
})
|
||||
|
||||
if linked:
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'linked': linked,
|
||||
'linked_count': len(linked),
|
||||
'not_found': not_found,
|
||||
'not_found_count': len(not_found)
|
||||
}
|
||||
|
||||
226
backend/app/api/logs.py
Normal file
226
backend/app/api/logs.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Logs API endpoints
|
||||
View and manage audit logs
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..db import get_db
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..models import User, AuditLog
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== Schemas ====================
|
||||
|
||||
class LogResponse(BaseModel):
|
||||
id: int
|
||||
level: str
|
||||
category: str
|
||||
action: str
|
||||
message: str
|
||||
username: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
resource_type: Optional[str] = None
|
||||
resource_id: Optional[str] = None
|
||||
resource_name: Optional[str] = None
|
||||
details: Optional[dict] = None
|
||||
request_method: Optional[str] = None
|
||||
request_path: Optional[str] = None
|
||||
response_status: Optional[int] = None
|
||||
duration_ms: Optional[int] = None
|
||||
success: bool
|
||||
error_message: Optional[str] = None
|
||||
created_at: datetime
|
||||
# Enhanced logging fields
|
||||
request_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
geo_location: Optional[str] = None
|
||||
server_id: Optional[int] = None
|
||||
server_name: Optional[str] = None
|
||||
node_name: Optional[str] = None
|
||||
request_body: Optional[dict] = None
|
||||
response_body: Optional[dict] = None
|
||||
query_params: Optional[str] = None
|
||||
error_traceback: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LogsListResponse(BaseModel):
|
||||
logs: List[LogResponse]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
pages: int
|
||||
|
||||
|
||||
class LogStatsResponse(BaseModel):
|
||||
period_hours: int
|
||||
total: int
|
||||
by_level: dict
|
||||
by_category: dict
|
||||
errors_count: int
|
||||
failed_logins: int
|
||||
recent_errors: List[dict]
|
||||
|
||||
|
||||
# ==================== HTML Endpoints ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def logs_page(request: Request):
|
||||
"""Render logs page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "ru")
|
||||
context = {
|
||||
"request": request,
|
||||
"title": t("nav_logs", lang),
|
||||
"page_title": f"📋 {t('system_logs', lang)}"
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("logs.html", context)
|
||||
|
||||
|
||||
# ==================== API Endpoints ====================
|
||||
|
||||
@router.get("/api/logs", response_model=LogsListResponse)
|
||||
async def get_logs(
|
||||
level: Optional[str] = Query(None, description="Filter by log level"),
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
username: Optional[str] = Query(None, description="Filter by username"),
|
||||
ip: Optional[str] = Query(None, description="Filter by IP address"),
|
||||
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
|
||||
resource_id: Optional[str] = Query(None, description="Filter by resource ID"),
|
||||
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)"),
|
||||
search: Optional[str] = Query(None, description="Search in message"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=500, description="Items per page"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("logs.view"))
|
||||
):
|
||||
"""Get logs with filtering and pagination"""
|
||||
|
||||
# Parse dates
|
||||
parsed_date_from = None
|
||||
parsed_date_to = None
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
parsed_date_from = datetime.strptime(date_from, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date_from format. Use YYYY-MM-DD")
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
parsed_date_to = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1) # Include the whole day
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date_to format. Use YYYY-MM-DD")
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Get logs
|
||||
logs, total = LoggingService.get_logs(
|
||||
db=db,
|
||||
level=level,
|
||||
category=category,
|
||||
username=username,
|
||||
ip_address=ip,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
date_from=parsed_date_from,
|
||||
date_to=parsed_date_to,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
# Calculate total pages
|
||||
pages = (total + limit - 1) // limit if total > 0 else 1
|
||||
|
||||
return LogsListResponse(
|
||||
logs=[LogResponse.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
page=page,
|
||||
limit=limit,
|
||||
pages=pages
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/stats", response_model=LogStatsResponse)
|
||||
async def get_log_stats(
|
||||
hours: int = Query(24, ge=1, le=720, description="Period in hours"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("logs.view"))
|
||||
):
|
||||
"""Get log statistics for the specified period"""
|
||||
stats = LoggingService.get_stats(db, hours=hours)
|
||||
return LogStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.delete("/api/logs")
|
||||
async def cleanup_logs(
|
||||
days: int = Query(30, ge=1, le=365, description="Delete logs older than this many days"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("logs.delete"))
|
||||
):
|
||||
"""Delete old logs (admin only)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admins can delete logs")
|
||||
|
||||
deleted = LoggingService.cleanup_old_logs(db, days=days)
|
||||
|
||||
# Log this action
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.WARNING,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="logs_cleanup",
|
||||
message=f"Удалено {deleted} логов старше {days} дней",
|
||||
username=current_user.username
|
||||
)
|
||||
|
||||
return {"deleted": deleted, "message": f"Удалено {deleted} логов старше {days} дней"}
|
||||
|
||||
|
||||
@router.get("/api/levels")
|
||||
async def get_log_levels():
|
||||
"""Get available log levels"""
|
||||
return {
|
||||
"levels": [
|
||||
{"value": "debug", "label": "Debug", "color": "#6c757d"},
|
||||
{"value": "info", "label": "Info", "color": "#0dcaf0"},
|
||||
{"value": "warning", "label": "Warning", "color": "#ffc107"},
|
||||
{"value": "error", "label": "Error", "color": "#dc3545"},
|
||||
{"value": "critical", "label": "Critical", "color": "#dc3545"}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/categories")
|
||||
async def get_log_categories():
|
||||
"""Get available log categories"""
|
||||
return {
|
||||
"categories": [
|
||||
{"value": "auth", "label": "🔐 Авторизация"},
|
||||
{"value": "proxmox", "label": "🖥️ Proxmox"},
|
||||
{"value": "ipam", "label": "🌐 IPAM"},
|
||||
{"value": "system", "label": "⚙️ Система"},
|
||||
{"value": "api", "label": "🔌 API"},
|
||||
{"value": "docker", "label": "🐳 Docker"}
|
||||
]
|
||||
}
|
||||
253
backend/app/api/notifications.py
Normal file
253
backend/app/api/notifications.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
API routes for notifications
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..models import User
|
||||
from ..services.notification_service import NotificationService
|
||||
from ..schemas import (
|
||||
NotificationResponse,
|
||||
NotificationListResponse,
|
||||
NotificationUpdate,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferenceUpdate
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("/", response_model=NotificationListResponse)
|
||||
async def get_notifications(
|
||||
unread_only: bool = Query(False, description="Only unread notifications"),
|
||||
level: Optional[str] = Query(None, description="Filter by level: critical, warning, info, success"),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get user notifications with filters
|
||||
|
||||
- **unread_only**: Return only unread notifications
|
||||
- **level**: Filter by notification level
|
||||
- **limit**: Maximum number of notifications (1-100)
|
||||
- **offset**: Offset for pagination
|
||||
"""
|
||||
notifications, total = NotificationService.get_user_notifications(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
unread_only=unread_only,
|
||||
level=level,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
unread_count = NotificationService.get_unread_count(db, current_user.id)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"unread_count": unread_count,
|
||||
"notifications": [n.to_dict() for n in notifications]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def get_unread_count(
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get count of unread notifications"""
|
||||
count = NotificationService.get_unread_count(db, current_user.id)
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=NotificationResponse)
|
||||
async def mark_notification_as_read(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark notification as read"""
|
||||
notification = NotificationService.mark_as_read(
|
||||
db,
|
||||
notification_id=notification_id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
if not notification:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
|
||||
return notification.to_dict()
|
||||
|
||||
|
||||
@router.post("/mark-all-read")
|
||||
async def mark_all_notifications_as_read(
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark all notifications as read"""
|
||||
count = NotificationService.mark_all_as_read(db, current_user.id)
|
||||
return {"message": f"{count} notifications marked as read"}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
async def delete_notification(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a notification"""
|
||||
success = NotificationService.delete_notification(
|
||||
db,
|
||||
notification_id=notification_id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
|
||||
return {"message": "Notification deleted"}
|
||||
|
||||
|
||||
@router.delete("/read/all")
|
||||
async def delete_all_read_notifications(
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete all read notifications"""
|
||||
count = NotificationService.delete_all_read(db, current_user.id)
|
||||
return {"message": f"{count} notifications deleted"}
|
||||
|
||||
|
||||
# Preferences endpoints
|
||||
|
||||
@router.get("/preferences", response_model=NotificationPreferenceResponse)
|
||||
async def get_notification_preferences(
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user notification preferences"""
|
||||
prefs = NotificationService.get_user_preferences(db, current_user.id)
|
||||
return prefs.to_dict()
|
||||
|
||||
|
||||
@router.put("/preferences", response_model=NotificationPreferenceResponse)
|
||||
async def update_notification_preferences(
|
||||
prefs_data: NotificationPreferenceUpdate,
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update user notification preferences"""
|
||||
prefs = NotificationService.update_user_preferences(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
prefs_data=prefs_data
|
||||
)
|
||||
return prefs.to_dict()
|
||||
|
||||
|
||||
# ============================================
|
||||
# Channel management endpoints
|
||||
# ============================================
|
||||
|
||||
@router.get("/channels/status")
|
||||
async def get_channels_status(
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get status of all notification channels
|
||||
|
||||
Returns configuration and status for:
|
||||
- Email (SMTP)
|
||||
- Telegram
|
||||
- In-app notifications
|
||||
"""
|
||||
status = await NotificationService.get_channels_status(db, current_user.id)
|
||||
return status
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def send_test_notification(
|
||||
channel: str = Query("all", description="Channel to test: all, email, telegram, inapp"),
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Send a test notification through specified channel(s)
|
||||
|
||||
- **channel**: Which channel to test (all, email, telegram, inapp)
|
||||
"""
|
||||
if channel not in ["all", "email", "telegram", "inapp"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid channel. Use: all, email, telegram, inapp")
|
||||
|
||||
results = await NotificationService.send_test_notification(db, current_user.id, channel)
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/telegram/verify")
|
||||
async def verify_telegram_chat(
|
||||
chat_id: str = Query(..., description="Telegram chat ID to verify"),
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Verify Telegram chat ID and save to preferences
|
||||
|
||||
Steps to get your chat_id:
|
||||
1. Start a conversation with @userinfobot in Telegram
|
||||
2. It will tell you your chat ID
|
||||
3. Make sure you've sent /start to your notification bot first
|
||||
"""
|
||||
from ..services.notification_channels import get_telegram_channel
|
||||
|
||||
tg_ch = get_telegram_channel()
|
||||
if not tg_ch.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Telegram бот не настроен. Добавьте токен в Настройки → Уведомления."
|
||||
)
|
||||
|
||||
# Try to send test message
|
||||
success = await tg_ch.verify_chat_id(chat_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Failed to send message to this chat_id. Make sure you've started the bot with /start"
|
||||
)
|
||||
|
||||
# Save chat_id to preferences
|
||||
prefs = NotificationService.get_user_preferences(db, current_user.id)
|
||||
prefs.telegram_chat_id = chat_id
|
||||
prefs.telegram_enabled = True
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Telegram verified and enabled!",
|
||||
"chat_id": chat_id
|
||||
}
|
||||
|
||||
|
||||
@router.get("/telegram/bot-info")
|
||||
async def get_telegram_bot_info(
|
||||
current_user: User = Depends(PermissionChecker("notifications.manage"))
|
||||
):
|
||||
"""Get information about the configured Telegram bot"""
|
||||
from ..services.notification_channels import get_telegram_channel
|
||||
|
||||
tg_ch = get_telegram_channel()
|
||||
if not tg_ch.is_configured():
|
||||
return {"configured": False, "bot_info": None}
|
||||
|
||||
bot_info = await tg_ch.get_bot_info()
|
||||
return {
|
||||
"configured": True,
|
||||
"bot_info": bot_info
|
||||
}
|
||||
5418
backend/app/api/proxmox.py
Normal file
5418
backend/app/api/proxmox.py
Normal file
File diff suppressed because it is too large
Load Diff
754
backend/app/api/settings.py
Normal file
754
backend/app/api/settings.py
Normal file
@@ -0,0 +1,754 @@
|
||||
"""
|
||||
Settings API endpoints
|
||||
User account settings and panel configuration
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import User, PanelSettings
|
||||
from ..auth import get_current_user, get_current_user_optional, get_password_hash, verify_password, PermissionChecker
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
from ..i18n import I18nService, t
|
||||
from ..services.logo_service import LogoService
|
||||
from ..services.update_service import (
|
||||
get_current_version,
|
||||
check_for_updates,
|
||||
perform_update,
|
||||
get_update_status,
|
||||
reset_update_status
|
||||
)
|
||||
|
||||
|
||||
def get_local_time() -> str:
|
||||
"""Get current local time formatted string"""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== Helper Functions ====================
|
||||
|
||||
def get_setting(db: Session, key: str, default: str = None) -> str:
|
||||
"""Get setting value from database"""
|
||||
setting = db.query(PanelSettings).filter(PanelSettings.key == key).first()
|
||||
if setting:
|
||||
return setting.value
|
||||
return default
|
||||
|
||||
|
||||
def set_setting(db: Session, key: str, value: str, description: str = None):
|
||||
"""Set setting value in database"""
|
||||
setting = db.query(PanelSettings).filter(PanelSettings.key == key).first()
|
||||
if setting:
|
||||
setting.value = value
|
||||
if description:
|
||||
setting.description = description
|
||||
else:
|
||||
setting = PanelSettings(key=key, value=value, description=description)
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ==================== Schemas ====================
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
full_name: Optional[str] = Field(None, max_length=100)
|
||||
email: Optional[EmailStr] = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(..., min_length=1)
|
||||
new_password: str = Field(..., min_length=6)
|
||||
confirm_password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class PanelSettingsResponse(BaseModel):
|
||||
panel_name: str
|
||||
refresh_interval: int # seconds
|
||||
log_retention_days: int
|
||||
language: str # ru or en
|
||||
|
||||
|
||||
class UpdatePanelSettingsRequest(BaseModel):
|
||||
refresh_interval: Optional[int] = Field(None, ge=1, le=60)
|
||||
log_retention_days: Optional[int] = Field(None, ge=1, le=365)
|
||||
language: Optional[str] = Field(None, pattern='^(ru|en)$')
|
||||
|
||||
|
||||
# ==================== Logo Protection ====================
|
||||
|
||||
@router.get("/api/logo.png")
|
||||
async def get_protected_logo():
|
||||
"""
|
||||
Get protected logo image.
|
||||
Returns the verified original logo or a fallback if tampered.
|
||||
"""
|
||||
logo_bytes = LogoService.get_logo_bytes()
|
||||
return Response(
|
||||
content=logo_bytes,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"X-Logo-Protected": "true"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def settings_page(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional)
|
||||
):
|
||||
"""Render settings page"""
|
||||
from ..template_helpers import get_language
|
||||
lang = get_language(request)
|
||||
context = {
|
||||
"request": request,
|
||||
"title": t("nav_settings", lang),
|
||||
"page_title": f"⚙️ {t('nav_settings', lang)}",
|
||||
"current_user": current_user
|
||||
}
|
||||
# Add i18n context
|
||||
context = add_i18n_context(request, context)
|
||||
|
||||
return templates.TemplateResponse("settings.html", context)
|
||||
|
||||
|
||||
# ==================== User Account Settings ====================
|
||||
|
||||
@router.get("/api/profile")
|
||||
async def get_profile(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get current user profile"""
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"full_name": current_user.full_name,
|
||||
"is_admin": current_user.is_admin,
|
||||
"is_active": current_user.is_active,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
"last_login": current_user.last_login.isoformat() if current_user.last_login else None
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/profile")
|
||||
async def update_profile(
|
||||
data: UpdateProfileRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update user profile"""
|
||||
updated_fields = []
|
||||
|
||||
if data.full_name is not None:
|
||||
current_user.full_name = data.full_name
|
||||
updated_fields.append("full_name")
|
||||
|
||||
if data.email is not None:
|
||||
# Check if email is already taken by another user
|
||||
existing = db.query(User).filter(
|
||||
User.email == data.email,
|
||||
User.id != current_user.id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email уже используется другим пользователем"
|
||||
)
|
||||
current_user.email = data.email
|
||||
updated_fields.append("email")
|
||||
|
||||
if updated_fields:
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
# Log profile update
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="profile_update",
|
||||
message=f"Пользователь {current_user.username} обновил профиль: {', '.join(updated_fields)}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Профиль обновлён",
|
||||
"updated_fields": updated_fields
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/change-password")
|
||||
async def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Change user password"""
|
||||
|
||||
# Verify current password
|
||||
if not verify_password(data.current_password, current_user.hashed_password):
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.WARNING,
|
||||
category=LoggingService.AUTH,
|
||||
action="password_change_failed",
|
||||
message=f"Неудачная попытка смены пароля (неверный текущий пароль)",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
success=False
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Неверный текущий пароль"
|
||||
)
|
||||
|
||||
# Check new password confirmation
|
||||
if data.new_password != data.confirm_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Новые пароли не совпадают"
|
||||
)
|
||||
|
||||
# Update password
|
||||
current_user.hashed_password = get_password_hash(data.new_password)
|
||||
db.commit()
|
||||
|
||||
# Log password change
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="password_change",
|
||||
message=f"Пользователь {current_user.username} изменил пароль",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
success=True
|
||||
)
|
||||
|
||||
return {"message": "Пароль успешно изменён"}
|
||||
|
||||
|
||||
# ==================== Panel Settings ====================
|
||||
|
||||
@router.get("/api/panel", response_model=PanelSettingsResponse)
|
||||
async def get_panel_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.view"))
|
||||
):
|
||||
"""Get panel settings"""
|
||||
from ..config import settings
|
||||
|
||||
# Get settings from database or use defaults
|
||||
refresh_interval = get_setting(db, "refresh_interval", "5")
|
||||
log_retention_days = get_setting(db, "log_retention_days", "30")
|
||||
language = get_setting(db, "language", "ru")
|
||||
|
||||
return {
|
||||
"panel_name": settings.PANEL_NAME,
|
||||
"refresh_interval": int(refresh_interval),
|
||||
"log_retention_days": int(log_retention_days),
|
||||
"language": language
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/panel")
|
||||
async def update_panel_settings(
|
||||
data: UpdatePanelSettingsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.panel"))
|
||||
):
|
||||
"""Update panel settings (requires settings.panel permission)"""
|
||||
updated_fields = []
|
||||
|
||||
# Save settings to database
|
||||
if data.refresh_interval is not None:
|
||||
set_setting(db, "refresh_interval", str(data.refresh_interval), "Интервал обновления данных (секунды)")
|
||||
updated_fields.append(f"refresh_interval={data.refresh_interval}")
|
||||
|
||||
if data.log_retention_days is not None:
|
||||
set_setting(db, "log_retention_days", str(data.log_retention_days), "Срок хранения логов (дни)")
|
||||
updated_fields.append(f"log_retention_days={data.log_retention_days}")
|
||||
|
||||
if data.language is not None:
|
||||
set_setting(db, "language", data.language, "Язык интерфейса панели")
|
||||
updated_fields.append(f"language={data.language}")
|
||||
|
||||
if updated_fields:
|
||||
# Log settings change
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="panel_settings_update",
|
||||
message=f"Администратор {current_user.username} обновил настройки панели: {', '.join(updated_fields)}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
details=data.dict(exclude_none=True)
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Настройки панели обновлены",
|
||||
"updated_fields": updated_fields
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/cleanup-logs")
|
||||
async def cleanup_old_logs(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Manually cleanup old logs (admin only)"""
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Только администраторы могут очищать логи"
|
||||
)
|
||||
|
||||
deleted = LoggingService.cleanup_old_logs(db, days=days)
|
||||
|
||||
# Log the cleanup action
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.WARNING,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="logs_cleanup",
|
||||
message=f"Администратор {current_user.username} очистил {deleted} логов старше {days} дней",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
details={"deleted_count": deleted, "older_than_days": days}
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Удалено {deleted} логов старше {days} дней",
|
||||
"deleted": deleted
|
||||
}
|
||||
|
||||
|
||||
# ==================== Translations API ====================
|
||||
|
||||
@router.get("/api/translations/{lang}")
|
||||
async def get_translations(lang: str):
|
||||
"""Get all translations for specific language"""
|
||||
if lang not in ["ru", "en"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Unsupported language. Use 'ru' or 'en'"
|
||||
)
|
||||
|
||||
return I18nService.get_all(lang)
|
||||
|
||||
|
||||
# ==================== Security Settings API ====================
|
||||
|
||||
class SecuritySettingsRequest(BaseModel):
|
||||
single_session_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/api/security-settings")
|
||||
async def get_security_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get security settings (admin only)"""
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Только администраторы могут просматривать настройки безопасности"
|
||||
)
|
||||
|
||||
from ..services.security_service import SecurityService
|
||||
|
||||
return {
|
||||
"single_session_enabled": SecurityService.get_setting_bool(db, "single_session_enabled", False)
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/security-settings")
|
||||
async def update_security_settings(
|
||||
data: SecuritySettingsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update security settings (admin only)"""
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Только администраторы могут изменять настройки безопасности"
|
||||
)
|
||||
|
||||
from ..services.security_service import SecurityService
|
||||
|
||||
updated_fields = []
|
||||
|
||||
if data.single_session_enabled is not None:
|
||||
SecurityService.update_setting(db, "single_session_enabled", str(data.single_session_enabled).lower())
|
||||
updated_fields.append(f"single_session_enabled={data.single_session_enabled}")
|
||||
|
||||
if updated_fields:
|
||||
# Log settings change
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="security_settings_update",
|
||||
message=f"Администратор {current_user.username} обновил настройки безопасности: {', '.join(updated_fields)}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
details=data.model_dump(exclude_none=True)
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Настройки безопасности обновлены",
|
||||
"updated_fields": updated_fields
|
||||
}
|
||||
|
||||
|
||||
# ==================== Notification Channel Settings API ====================
|
||||
|
||||
class SMTPSettingsRequest(BaseModel):
|
||||
smtp_host: Optional[str] = Field(None, max_length=255)
|
||||
smtp_port: Optional[int] = Field(None, ge=1, le=65535)
|
||||
smtp_user: Optional[str] = Field(None, max_length=255)
|
||||
smtp_password: Optional[str] = Field(None, max_length=255)
|
||||
smtp_from: Optional[str] = Field(None, max_length=255)
|
||||
smtp_tls: Optional[bool] = None
|
||||
|
||||
|
||||
class TelegramSettingsRequest(BaseModel):
|
||||
telegram_bot_token: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
|
||||
@router.get("/api/notification-channels")
|
||||
async def get_notification_channel_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get notification channel settings (admin only)"""
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Только администраторы могут просматривать настройки уведомлений"
|
||||
)
|
||||
|
||||
return {
|
||||
"smtp": {
|
||||
"host": get_setting(db, "smtp_host", ""),
|
||||
"port": int(get_setting(db, "smtp_port", "587") or "587"),
|
||||
"user": get_setting(db, "smtp_user", ""),
|
||||
"password": "***" if get_setting(db, "smtp_password") else "",
|
||||
"from": get_setting(db, "smtp_from", ""),
|
||||
"tls": get_setting(db, "smtp_tls", "true").lower() == "true"
|
||||
},
|
||||
"telegram": {
|
||||
"bot_token": "***" if get_setting(db, "telegram_bot_token") else "",
|
||||
"configured": bool(get_setting(db, "telegram_bot_token"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/notification-channels/smtp")
|
||||
async def update_smtp_settings(
|
||||
data: SMTPSettingsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update SMTP settings (admin only)"""
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Только администраторы могут изменять настройки SMTP"
|
||||
)
|
||||
|
||||
updated_fields = []
|
||||
|
||||
if data.smtp_host is not None:
|
||||
set_setting(db, "smtp_host", data.smtp_host, "SMTP сервер")
|
||||
updated_fields.append("smtp_host")
|
||||
|
||||
if data.smtp_port is not None:
|
||||
set_setting(db, "smtp_port", str(data.smtp_port), "SMTP порт")
|
||||
updated_fields.append("smtp_port")
|
||||
|
||||
if data.smtp_user is not None:
|
||||
set_setting(db, "smtp_user", data.smtp_user, "SMTP пользователь")
|
||||
updated_fields.append("smtp_user")
|
||||
|
||||
if data.smtp_password is not None:
|
||||
set_setting(db, "smtp_password", data.smtp_password, "SMTP пароль")
|
||||
updated_fields.append("smtp_password")
|
||||
|
||||
if data.smtp_from is not None:
|
||||
set_setting(db, "smtp_from", data.smtp_from, "Email отправителя")
|
||||
updated_fields.append("smtp_from")
|
||||
|
||||
if data.smtp_tls is not None:
|
||||
set_setting(db, "smtp_tls", str(data.smtp_tls).lower(), "Использовать TLS")
|
||||
updated_fields.append("smtp_tls")
|
||||
|
||||
if updated_fields:
|
||||
# Reload notification channels
|
||||
from ..services.notification_channels import reload_notification_channels
|
||||
reload_notification_channels()
|
||||
|
||||
# Log settings change
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="smtp_settings_update",
|
||||
message=f"Администратор {current_user.username} обновил настройки SMTP: {', '.join(updated_fields)}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Настройки SMTP обновлены",
|
||||
"updated_fields": updated_fields
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/notification-channels/telegram")
|
||||
async def update_telegram_settings(
|
||||
data: TelegramSettingsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update Telegram bot settings (admin only)"""
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Только администраторы могут изменять настройки Telegram"
|
||||
)
|
||||
|
||||
updated_fields = []
|
||||
|
||||
if data.telegram_bot_token is not None:
|
||||
set_setting(db, "telegram_bot_token", data.telegram_bot_token, "Токен Telegram бота")
|
||||
updated_fields.append("telegram_bot_token")
|
||||
|
||||
if updated_fields:
|
||||
# Reload notification channels
|
||||
from ..services.notification_channels import reload_notification_channels
|
||||
reload_notification_channels()
|
||||
|
||||
# Log settings change
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="telegram_settings_update",
|
||||
message=f"Администратор {current_user.username} обновил настройки Telegram",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Настройки Telegram обновлены",
|
||||
"updated_fields": updated_fields
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/notification-channels/smtp/test")
|
||||
async def test_smtp_connection(
|
||||
test_email: str = Query(..., description="Email для отправки тестового письма"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Test SMTP connection by sending a test email (admin only)"""
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Только администраторы могут тестировать SMTP"
|
||||
)
|
||||
|
||||
from ..services.notification_channels import get_email_channel
|
||||
|
||||
email_channel = get_email_channel()
|
||||
if not email_channel.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="SMTP не настроен. Заполните все необходимые поля."
|
||||
)
|
||||
|
||||
success = await email_channel.send(
|
||||
to_email=test_email,
|
||||
subject="🔧 Тестовое письмо - PVEmanager",
|
||||
body=f"Тестовое письмо от PVEmanager.\n\nВремя: {get_local_time()}\nОтправлено пользователем: {current_user.username}",
|
||||
html_body=f"""
|
||||
<div style="font-family: Arial, sans-serif; padding: 20px; background: #1a1a2e; color: #fff;">
|
||||
<h2 style="color: #667eea;">🔧 Тестовое письмо</h2>
|
||||
<p>SMTP настройки работают корректно!</p>
|
||||
<p>Время: {get_local_time()}</p>
|
||||
<p>Отправлено пользователем: {current_user.username}</p>
|
||||
<hr style="border-color: #333;">
|
||||
<p style="color: #888; font-size: 12px;">PVEmanager</p>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
if success:
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="smtp_test",
|
||||
message=f"Администратор {current_user.username} отправил тестовое письмо на {test_email}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id
|
||||
)
|
||||
return {"success": True, "message": f"Тестовое письмо отправлено на {test_email}"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Не удалось отправить тестовое письмо. Проверьте настройки SMTP."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/notification-channels/telegram/test")
|
||||
async def test_telegram_bot(
|
||||
chat_id: str = Query(..., description="Telegram Chat ID для отправки тестового сообщения"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Test Telegram bot by sending a test message (admin only)"""
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Только администраторы могут тестировать Telegram бота"
|
||||
)
|
||||
|
||||
from ..services.notification_channels import get_telegram_channel, get_local_time
|
||||
|
||||
tg_channel = get_telegram_channel()
|
||||
if not tg_channel.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Telegram бот не настроен. Укажите токен бота."
|
||||
)
|
||||
|
||||
success = await tg_channel.send(
|
||||
chat_id=chat_id,
|
||||
message=f"🔧 <b>Тестовое сообщение</b>\n\nTelegram бот работает корректно!\n\n🕐 Время: {get_local_time()}\n👤 Отправлено: {current_user.username}\n\n🖥️ <i>PVEmanager</i>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if success:
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="telegram_test",
|
||||
message=f"Администратор {current_user.username} отправил тестовое сообщение в Telegram (chat_id: {chat_id})",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id
|
||||
)
|
||||
return {"success": True, "message": f"Тестовое сообщение отправлено в Telegram (chat_id: {chat_id})"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Не удалось отправить тестовое сообщение. Проверьте токен бота и chat_id."
|
||||
)
|
||||
|
||||
|
||||
# ==================== System Updates ====================
|
||||
|
||||
@router.get("/api/version")
|
||||
async def get_version():
|
||||
"""Get current panel version"""
|
||||
return {"version": get_current_version()}
|
||||
|
||||
|
||||
@router.get("/api/updates/check")
|
||||
async def check_updates(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.manage"))
|
||||
):
|
||||
"""Check for available updates (admin only)"""
|
||||
result = await check_for_updates()
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="update_check",
|
||||
message=f"Проверка обновлений: текущая {result['current_version']}, последняя {result.get('latest_version', 'unknown')}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/updates/status")
|
||||
async def updates_status():
|
||||
"""Get current update status (no auth required for banner check)"""
|
||||
return get_update_status()
|
||||
|
||||
|
||||
@router.post("/api/updates/perform")
|
||||
async def perform_system_update(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.manage"))
|
||||
):
|
||||
"""Perform system update (admin only)"""
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.WARNING,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="update_start",
|
||||
message=f"Администратор {current_user.username} запустил обновление системы",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
result = await perform_update()
|
||||
|
||||
if not result["success"]:
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.ERROR,
|
||||
category=LoggingService.SYSTEM,
|
||||
action="update_failed",
|
||||
message=f"Ошибка обновления: {result.get('error')}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/updates/reset")
|
||||
async def reset_update(
|
||||
current_user: User = Depends(PermissionChecker("settings.manage"))
|
||||
):
|
||||
"""Reset update status (admin only)"""
|
||||
reset_update_status()
|
||||
return {"success": True, "message": "Update status reset"}
|
||||
|
||||
926
backend/app/api/templates.py
Normal file
926
backend/app/api/templates.py
Normal file
@@ -0,0 +1,926 @@
|
||||
"""
|
||||
API endpoints for OS Templates management
|
||||
Allows creating VM templates, groups, and deploying VMs from templates
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, status
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
from typing import List
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import ProxmoxServer, OSTemplateGroup, OSTemplate, VMInstance, User
|
||||
from ..schemas import (
|
||||
OSTemplateGroupCreate, OSTemplateGroupUpdate, OSTemplateGroupResponse,
|
||||
OSTemplateCreate, OSTemplateUpdate, OSTemplateResponse, OSTemplateWithGroup,
|
||||
VMDeployRequest, VMDeployResponse
|
||||
)
|
||||
from ..proxmox_client import ProxmoxClient
|
||||
from ..api.proxmox import get_next_vmid, save_vm_instance, get_vm_instance
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..ipam_service import IPAMService
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
def templates_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Страница управления OS шаблонами"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
proxmox_servers = db.query(ProxmoxServer).all()
|
||||
template_groups = db.query(OSTemplateGroup).order_by(OSTemplateGroup.sort_order).all()
|
||||
os_templates = db.query(OSTemplate).order_by(OSTemplate.sort_order).all()
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"proxmox_servers": proxmox_servers,
|
||||
"template_groups": template_groups,
|
||||
"os_templates": os_templates,
|
||||
"page_title": t('nav_templates', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("os_templates.html", context)
|
||||
|
||||
|
||||
# ==================== Template Groups CRUD ====================
|
||||
|
||||
@router.get("/api/groups", response_model=List[OSTemplateGroupResponse])
|
||||
def list_template_groups(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.view"))
|
||||
):
|
||||
"""Получить список всех групп шаблонов"""
|
||||
groups = db.query(OSTemplateGroup).order_by(OSTemplateGroup.sort_order).all()
|
||||
return groups
|
||||
|
||||
|
||||
@router.post("/api/groups", response_model=OSTemplateGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_template_group(
|
||||
group_data: OSTemplateGroupCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.manage"))
|
||||
):
|
||||
"""Создать новую группу шаблонов"""
|
||||
# Check if group with same name exists
|
||||
existing = db.query(OSTemplateGroup).filter(OSTemplateGroup.name == group_data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Группа с именем '{group_data.name}' уже существует"
|
||||
)
|
||||
|
||||
group = OSTemplateGroup(**group_data.model_dump())
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
|
||||
logger.info(f"User {current_user.username} created template group: {group.name}")
|
||||
return group
|
||||
|
||||
|
||||
@router.get("/api/groups/{group_id}", response_model=OSTemplateGroupResponse)
|
||||
def get_template_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.view"))
|
||||
):
|
||||
"""Получить группу шаблонов по ID"""
|
||||
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
|
||||
return group
|
||||
|
||||
|
||||
@router.put("/api/groups/{group_id}", response_model=OSTemplateGroupResponse)
|
||||
def update_template_group(
|
||||
group_id: int,
|
||||
group_data: OSTemplateGroupUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.manage"))
|
||||
):
|
||||
"""Обновить группу шаблонов"""
|
||||
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
|
||||
|
||||
update_data = group_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(group, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
|
||||
logger.info(f"User {current_user.username} updated template group: {group.name}")
|
||||
return group
|
||||
|
||||
|
||||
@router.delete("/api/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_template_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.manage"))
|
||||
):
|
||||
"""Удалить группу шаблонов"""
|
||||
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
|
||||
|
||||
# Check if there are templates in this group
|
||||
templates_count = db.query(OSTemplate).filter(OSTemplate.group_id == group_id).count()
|
||||
if templates_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Невозможно удалить группу: в ней {templates_count} шаблон(ов)"
|
||||
)
|
||||
|
||||
logger.info(f"User {current_user.username} deleted template group: {group.name}")
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ==================== OS Templates CRUD ====================
|
||||
|
||||
@router.get("/api/templates", response_model=List[OSTemplateWithGroup])
|
||||
def list_os_templates(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.view")),
|
||||
group_id: int = None,
|
||||
server_id: int = None,
|
||||
active_only: bool = True
|
||||
):
|
||||
"""Получить список всех OS шаблонов с информацией о группе"""
|
||||
query = db.query(OSTemplate)
|
||||
|
||||
if group_id:
|
||||
query = query.filter(OSTemplate.group_id == group_id)
|
||||
if server_id:
|
||||
query = query.filter(OSTemplate.server_id == server_id)
|
||||
if active_only:
|
||||
query = query.filter(OSTemplate.is_active == True)
|
||||
|
||||
os_templates = query.order_by(OSTemplate.sort_order).all()
|
||||
|
||||
# Enrich with group and server info
|
||||
result = []
|
||||
for template in os_templates:
|
||||
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
|
||||
|
||||
template_dict = {
|
||||
**template.__dict__,
|
||||
'group_name': group.name if group else None,
|
||||
'group_icon': group.icon if group else None,
|
||||
'server_name': server.name if server else None,
|
||||
}
|
||||
# Remove SQLAlchemy internal state
|
||||
template_dict.pop('_sa_instance_state', None)
|
||||
result.append(template_dict)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates", response_model=OSTemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_os_template(
|
||||
template_data: OSTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.manage"))
|
||||
):
|
||||
"""Создать новый OS шаблон"""
|
||||
# Validate group exists
|
||||
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template_data.group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
|
||||
|
||||
# Validate server exists
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template_data.server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
|
||||
# Check if template with same VMID on same server exists
|
||||
existing = db.query(OSTemplate).filter(
|
||||
OSTemplate.server_id == template_data.server_id,
|
||||
OSTemplate.vmid == template_data.vmid
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Шаблон с VMID {template_data.vmid} уже существует для этого сервера"
|
||||
)
|
||||
|
||||
template = OSTemplate(**template_data.model_dump())
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
logger.info(f"User {current_user.username} created OS template: {template.name} (VMID: {template.vmid})")
|
||||
return template
|
||||
|
||||
|
||||
@router.get("/api/templates/{template_id}", response_model=OSTemplateWithGroup)
|
||||
def get_os_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.view"))
|
||||
):
|
||||
"""Получить OS шаблон по ID"""
|
||||
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
|
||||
|
||||
template_dict = {
|
||||
**template.__dict__,
|
||||
'group_name': group.name if group else None,
|
||||
'group_icon': group.icon if group else None,
|
||||
'server_name': server.name if server else None,
|
||||
}
|
||||
template_dict.pop('_sa_instance_state', None)
|
||||
|
||||
return template_dict
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}", response_model=OSTemplateResponse)
|
||||
def update_os_template(
|
||||
template_id: int,
|
||||
template_data: OSTemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.manage"))
|
||||
):
|
||||
"""Обновить OS шаблон"""
|
||||
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
update_data = template_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate group if changing
|
||||
if 'group_id' in update_data:
|
||||
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == update_data['group_id']).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
|
||||
|
||||
# Validate server if changing
|
||||
if 'server_id' in update_data:
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == update_data['server_id']).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(template, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
logger.info(f"User {current_user.username} updated OS template: {template.name}")
|
||||
return template
|
||||
|
||||
|
||||
@router.delete("/api/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_os_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.manage"))
|
||||
):
|
||||
"""Удалить OS шаблон"""
|
||||
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
logger.info(f"User {current_user.username} deleted OS template: {template.name} (VMID: {template.vmid})")
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ==================== Proxmox Templates Discovery ====================
|
||||
|
||||
@router.get("/api/discover/{server_id}")
|
||||
def discover_proxmox_templates(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.view"))
|
||||
):
|
||||
"""
|
||||
Обнаружить все VM-шаблоны на Proxmox сервере.
|
||||
Возвращает список шаблонов, которые можно добавить.
|
||||
"""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
|
||||
try:
|
||||
if server.use_password:
|
||||
client = ProxmoxClient(
|
||||
host=server.ip_address,
|
||||
user=server.api_user,
|
||||
password=server.password,
|
||||
verify_ssl=server.verify_ssl
|
||||
)
|
||||
else:
|
||||
client = ProxmoxClient(
|
||||
host=server.ip_address,
|
||||
user=server.api_user,
|
||||
token_name=server.api_token_name,
|
||||
token_value=server.api_token_value,
|
||||
verify_ssl=server.verify_ssl
|
||||
)
|
||||
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
|
||||
|
||||
# Get templates - фильтруем по hostname ноды если это кластер
|
||||
node_filter = None
|
||||
if server.hostname and server.hostname != server.ip_address:
|
||||
node_filter = server.hostname
|
||||
|
||||
proxmox_templates = client.get_templates(node=node_filter)
|
||||
|
||||
# Get already added templates для ЭТОГО сервера
|
||||
existing_vmids = set(
|
||||
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for tpl in proxmox_templates:
|
||||
result.append({
|
||||
'vmid': tpl.get('vmid'),
|
||||
'name': tpl.get('name', f"Template-{tpl.get('vmid')}"),
|
||||
'node': tpl.get('node'),
|
||||
'status': tpl.get('status'),
|
||||
'maxmem': tpl.get('maxmem', 0),
|
||||
'maxdisk': tpl.get('maxdisk', 0),
|
||||
'already_added': tpl.get('vmid') in existing_vmids
|
||||
})
|
||||
|
||||
return JSONResponse(content={
|
||||
'server_id': server_id,
|
||||
'server_name': server.name,
|
||||
'templates': result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error discovering templates on {server.name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обнаружения шаблонов: {str(e)}")
|
||||
|
||||
|
||||
# ==================== Auto-Import Templates ====================
|
||||
|
||||
# Маппинг ключевых слов на группы
|
||||
# Иконки используют CSS классы для адаптации к теме
|
||||
OS_GROUP_MAPPING = {
|
||||
'ubuntu': {'name': 'Ubuntu', 'icon': '<i class="fa-brands fa-ubuntu os-icon os-icon-ubuntu"></i>', 'order': 1},
|
||||
'debian': {'name': 'Debian', 'icon': '<i class="fa-brands fa-debian os-icon os-icon-debian"></i>', 'order': 2},
|
||||
'centos': {'name': 'CentOS', 'icon': '<i class="fa-brands fa-centos os-icon os-icon-centos"></i>', 'order': 3},
|
||||
'rocky': {'name': 'Rocky Linux', 'icon': '<span class="os-icon-rocky-svg"></span>', 'order': 4},
|
||||
'alma': {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5},
|
||||
'almalinux': {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5},
|
||||
'fedora': {'name': 'Fedora', 'icon': '<i class="fa-brands fa-fedora os-icon os-icon-fedora"></i>', 'order': 6},
|
||||
'rhel': {'name': 'RHEL', 'icon': '<i class="fa-brands fa-redhat os-icon os-icon-rhel"></i>', 'order': 7},
|
||||
'oracle': {'name': 'Oracle Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-oracle"></i>', 'order': 8},
|
||||
'suse': {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9},
|
||||
'opensuse': {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9},
|
||||
'arch': {'name': 'Arch Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-arch"></i>', 'order': 10},
|
||||
'gentoo': {'name': 'Gentoo', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-gentoo"></i>', 'order': 11},
|
||||
'alpine': {'name': 'Alpine', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-alpine"></i>', 'order': 12},
|
||||
'windows': {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20},
|
||||
'win': {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20},
|
||||
'freebsd': {'name': 'FreeBSD', 'icon': '<i class="fa-brands fa-freebsd os-icon os-icon-freebsd"></i>', 'order': 30},
|
||||
'openbsd': {'name': 'OpenBSD', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-openbsd"></i>', 'order': 31},
|
||||
'other': {'name': 'Other', 'icon': '<i class="fa-solid fa-compact-disc os-icon os-icon-other"></i>', 'order': 99},
|
||||
}
|
||||
|
||||
|
||||
def detect_os_group(template_name: str) -> dict:
|
||||
"""Определить группу ОС по имени шаблона"""
|
||||
name_lower = template_name.lower()
|
||||
|
||||
for keyword, group_info in OS_GROUP_MAPPING.items():
|
||||
if keyword in name_lower:
|
||||
return group_info
|
||||
|
||||
return OS_GROUP_MAPPING['other']
|
||||
|
||||
|
||||
@router.post("/api/auto-import/{server_id}")
|
||||
def auto_import_templates(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.manage"))
|
||||
):
|
||||
"""
|
||||
Автоматический импорт шаблонов с Proxmox сервера.
|
||||
Автоматически определяет группу ОС и назначает порядок сортировки.
|
||||
Глобальная сортировка: Ubuntu 0-3, CentOS 4-5 и т.д.
|
||||
"""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
|
||||
try:
|
||||
# Подключаемся к Proxmox
|
||||
if server.use_password:
|
||||
client = ProxmoxClient(
|
||||
host=server.ip_address,
|
||||
user=server.api_user,
|
||||
password=server.password,
|
||||
verify_ssl=server.verify_ssl
|
||||
)
|
||||
else:
|
||||
client = ProxmoxClient(
|
||||
host=server.ip_address,
|
||||
user=server.api_user,
|
||||
token_name=server.api_token_name,
|
||||
token_value=server.api_token_value,
|
||||
verify_ssl=server.verify_ssl
|
||||
)
|
||||
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
|
||||
|
||||
# Фильтруем по ноде если это кластер
|
||||
node_filter = None
|
||||
if server.hostname and server.hostname != server.ip_address:
|
||||
node_filter = server.hostname
|
||||
|
||||
# Получаем шаблоны только с этой ноды
|
||||
proxmox_templates = client.get_templates(node=node_filter)
|
||||
|
||||
# Получаем уже добавленные шаблоны для ЭТОГО сервера
|
||||
existing_vmids = set(
|
||||
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
|
||||
)
|
||||
|
||||
# Кеш групп: name -> group
|
||||
groups_cache = {}
|
||||
for group in db.query(OSTemplateGroup).all():
|
||||
groups_cache[group.name] = group
|
||||
|
||||
# Группируем шаблоны по ОС
|
||||
templates_by_group = {} # group_name -> [templates]
|
||||
|
||||
for tpl in proxmox_templates:
|
||||
vmid = tpl.get('vmid')
|
||||
if vmid in existing_vmids:
|
||||
continue # Пропускаем уже добавленные
|
||||
|
||||
template_name = tpl.get('name', f"Template-{vmid}")
|
||||
group_info = detect_os_group(template_name)
|
||||
group_name = group_info['name']
|
||||
|
||||
if group_name not in templates_by_group:
|
||||
templates_by_group[group_name] = {
|
||||
'info': group_info,
|
||||
'templates': []
|
||||
}
|
||||
|
||||
templates_by_group[group_name]['templates'].append({
|
||||
'vmid': vmid,
|
||||
'name': template_name,
|
||||
'node': tpl.get('node'),
|
||||
'maxmem': tpl.get('maxmem', 0),
|
||||
'maxdisk': tpl.get('maxdisk', 0),
|
||||
})
|
||||
|
||||
# Создаём группы и шаблоны с ГЛОБАЛЬНОЙ сортировкой
|
||||
imported = []
|
||||
global_sort_order = 0 # Глобальный счётчик для всех шаблонов
|
||||
|
||||
# Сортируем группы по их порядку (Ubuntu=1, Debian=2, CentOS=3...)
|
||||
sorted_groups = sorted(
|
||||
templates_by_group.items(),
|
||||
key=lambda x: x[1]['info']['order']
|
||||
)
|
||||
|
||||
for group_name, group_data in sorted_groups:
|
||||
group_info = group_data['info']
|
||||
group_templates = group_data['templates']
|
||||
|
||||
# Получаем или создаём группу
|
||||
if group_name not in groups_cache:
|
||||
# Получаем максимальный sort_order для групп
|
||||
max_group_order = db.query(OSTemplateGroup).count()
|
||||
|
||||
new_group = OSTemplateGroup(
|
||||
name=group_name,
|
||||
icon=group_info['icon'],
|
||||
description=f"Шаблоны {group_name}",
|
||||
sort_order=group_info['order'], # Используем порядок из маппинга
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_group)
|
||||
db.flush()
|
||||
groups_cache[group_name] = new_group
|
||||
logger.info(f"Created new group: {group_name}")
|
||||
|
||||
group = groups_cache[group_name]
|
||||
|
||||
# Сортируем шаблоны по имени для консистентности
|
||||
group_templates.sort(key=lambda x: x['name'])
|
||||
|
||||
# Добавляем шаблоны с ГЛОБАЛЬНЫМ sort_order
|
||||
for idx, tpl in enumerate(group_templates):
|
||||
# Определяем дефолтные значения из размера шаблона
|
||||
default_memory = max(1024, (tpl.get('maxmem', 0) // (1024 * 1024)) or 1024)
|
||||
default_disk = max(10, (tpl.get('maxdisk', 0) // (1024 * 1024 * 1024)) or 10)
|
||||
|
||||
template = OSTemplate(
|
||||
group_id=group.id,
|
||||
server_id=server_id,
|
||||
name=tpl['name'],
|
||||
vmid=tpl['vmid'],
|
||||
node=tpl.get('node'),
|
||||
source_node=tpl.get('node'),
|
||||
default_cores=2,
|
||||
default_memory=default_memory,
|
||||
default_disk=default_disk,
|
||||
min_cores=1,
|
||||
min_memory=512,
|
||||
min_disk=5,
|
||||
is_active=True,
|
||||
sort_order=global_sort_order # ГЛОБАЛЬНАЯ сортировка
|
||||
)
|
||||
|
||||
db.add(template)
|
||||
imported.append({
|
||||
'vmid': tpl['vmid'],
|
||||
'name': tpl['name'],
|
||||
'group': group_name,
|
||||
'sort_order': global_sort_order
|
||||
})
|
||||
global_sort_order += 1 # Увеличиваем глобальный счётчик
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"User {current_user.username} auto-imported {len(imported)} templates from {server.name}")
|
||||
|
||||
return JSONResponse(content={
|
||||
'success': True,
|
||||
'server_id': server_id,
|
||||
'server_name': server.name,
|
||||
'imported_count': len(imported),
|
||||
'templates': imported,
|
||||
'groups_created': [g for g in templates_by_group.keys() if g not in groups_cache]
|
||||
})
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error auto-importing templates from {server.name}: {e}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка импорта шаблонов: {str(e)}")
|
||||
|
||||
|
||||
# ==================== VM Deployment ====================
|
||||
|
||||
@router.post("/api/deploy", response_model=VMDeployResponse)
|
||||
async def deploy_vm_from_template(
|
||||
deploy_data: VMDeployRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vms.create"))
|
||||
):
|
||||
"""
|
||||
Развернуть новую VM из шаблона.
|
||||
Клонирует шаблон и настраивает параметры VM.
|
||||
Поддерживает развертывание на любую ноду кластера (cross-node deployment).
|
||||
"""
|
||||
# Get template
|
||||
template = db.query(OSTemplate).filter(OSTemplate.id == deploy_data.template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
if not template.is_active:
|
||||
raise HTTPException(status_code=400, detail="Шаблон неактивен")
|
||||
|
||||
# Get server
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
|
||||
# Validate resources
|
||||
cores = deploy_data.cores or template.default_cores
|
||||
memory = deploy_data.memory or template.default_memory
|
||||
disk = deploy_data.disk or template.default_disk
|
||||
|
||||
if cores < template.min_cores:
|
||||
raise HTTPException(status_code=400, detail=f"Минимум {template.min_cores} ядер CPU")
|
||||
if memory < template.min_memory:
|
||||
raise HTTPException(status_code=400, detail=f"Минимум {template.min_memory} MB памяти")
|
||||
if disk < template.min_disk:
|
||||
raise HTTPException(status_code=400, detail=f"Минимум {template.min_disk} GB диска")
|
||||
|
||||
try:
|
||||
# Connect to Proxmox
|
||||
if server.use_password:
|
||||
client = ProxmoxClient(
|
||||
host=server.ip_address,
|
||||
user=server.api_user,
|
||||
password=server.password,
|
||||
verify_ssl=server.verify_ssl
|
||||
)
|
||||
else:
|
||||
client = ProxmoxClient(
|
||||
host=server.ip_address,
|
||||
user=server.api_user,
|
||||
token_name=server.api_token_name,
|
||||
token_value=server.api_token_value,
|
||||
verify_ssl=server.verify_ssl
|
||||
)
|
||||
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
|
||||
|
||||
# Determine source node (where template exists) and target node (where to deploy)
|
||||
source_node = template.get_source_node()
|
||||
target_node = deploy_data.target_node or source_node
|
||||
|
||||
# Check if template exists on source node
|
||||
template_vmid = template.vmid
|
||||
template_status = client.get_vm_status(source_node, template_vmid)
|
||||
|
||||
if not template_status:
|
||||
# Try to find the template on other nodes
|
||||
found = client.find_template_on_nodes(template_vmid)
|
||||
if found:
|
||||
source_node = found['node']
|
||||
logger.info(f"Template {template_vmid} found on alternative node: {source_node}")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Шаблон VMID {template_vmid} не найден. Проверьте настройки шаблона."
|
||||
)
|
||||
|
||||
# For shared storage: template is accessible from all nodes
|
||||
# For local storage: template needs to be on the target node or replicated
|
||||
#
|
||||
# In a cluster with shared storage, we can clone from source_node
|
||||
# but specify target_node for the new VM
|
||||
clone_from_node = source_node
|
||||
clone_template_vmid = template_vmid
|
||||
|
||||
# Log cross-node deployment
|
||||
if target_node and target_node != source_node:
|
||||
logger.info(f"Cross-node deployment: template on {source_node}, target node {target_node}")
|
||||
|
||||
# Get next VMID from database (range 10000-19999) or use provided VMID
|
||||
if deploy_data.vmid:
|
||||
# Используем указанный VMID (для переустановки)
|
||||
new_vmid = deploy_data.vmid
|
||||
logger.info(f"Using provided VMID: {new_vmid} for reinstall")
|
||||
else:
|
||||
# Генерируем новый случайный VMID для новой VM
|
||||
new_vmid = get_next_vmid(db, server.id)
|
||||
logger.info(f"Generated new VMID: {new_vmid} for server {server.id}")
|
||||
|
||||
# Clone VM from template
|
||||
# For shared storage: clone from source node, target specifies where VM will run
|
||||
clone_upid = client.clone_vm_from_template(
|
||||
node=clone_from_node,
|
||||
template_vmid=clone_template_vmid,
|
||||
new_vmid=new_vmid,
|
||||
name=deploy_data.name,
|
||||
full_clone=True,
|
||||
description=f"Deployed from template {template.name} by {current_user.username}",
|
||||
target_storage=deploy_data.target_storage,
|
||||
target_node=target_node if target_node != clone_from_node else None
|
||||
)
|
||||
|
||||
if not clone_upid:
|
||||
raise HTTPException(status_code=500, detail="Ошибка клонирования VM")
|
||||
|
||||
# Wait for clone to complete
|
||||
clone_success = client.wait_for_task(clone_from_node, clone_upid, timeout=300)
|
||||
if not clone_success:
|
||||
raise HTTPException(status_code=500, detail="Таймаут клонирования VM")
|
||||
|
||||
# Use target_node for all subsequent operations
|
||||
deploy_node = target_node
|
||||
|
||||
# IPAM Integration - Auto-allocate IP if network specified but no IP provided
|
||||
allocated_ip = deploy_data.ip_address
|
||||
ipam_allocation = None
|
||||
|
||||
if deploy_data.ipam_network_id and not deploy_data.ip_address:
|
||||
ipam = IPAMService(db)
|
||||
ipam_allocation, error = ipam.auto_allocate_ip(
|
||||
network_id=deploy_data.ipam_network_id,
|
||||
pool_id=deploy_data.ipam_pool_id,
|
||||
resource_type='vm',
|
||||
resource_name=deploy_data.name,
|
||||
proxmox_server_id=server.id,
|
||||
proxmox_vmid=new_vmid,
|
||||
proxmox_node=deploy_node,
|
||||
allocated_by=current_user.username,
|
||||
notes=f"Auto-allocated for VM deployment from template {template.name}"
|
||||
)
|
||||
if error:
|
||||
logger.error(f"IPAM auto-allocation failed: {error}")
|
||||
# Continue without IPAM - let user know
|
||||
else:
|
||||
allocated_ip = ipam_allocation.ip_address
|
||||
logger.info(f"IPAM allocated IP {allocated_ip} for VM {deploy_data.name}")
|
||||
# Get gateway from network if not provided
|
||||
if not deploy_data.gateway:
|
||||
from ..models import IPAMNetwork
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == deploy_data.ipam_network_id).first()
|
||||
if network and network.gateway:
|
||||
deploy_data.gateway = network.gateway
|
||||
elif deploy_data.ip_address and deploy_data.ipam_network_id:
|
||||
# Register manually specified IP in IPAM
|
||||
ipam = IPAMService(db)
|
||||
ipam_allocation, error = ipam.allocate_ip(
|
||||
ip_address=deploy_data.ip_address,
|
||||
network_id=deploy_data.ipam_network_id,
|
||||
pool_id=deploy_data.ipam_pool_id,
|
||||
resource_type='vm',
|
||||
resource_name=deploy_data.name,
|
||||
proxmox_server_id=server.id,
|
||||
proxmox_vmid=new_vmid,
|
||||
proxmox_node=deploy_node,
|
||||
allocated_by=current_user.username,
|
||||
notes=f"Manually assigned for VM deployment from template {template.name}"
|
||||
)
|
||||
if error:
|
||||
logger.warning(f"IPAM registration failed for manually specified IP: {error}")
|
||||
|
||||
# Configure VM
|
||||
ip_config = None
|
||||
if allocated_ip:
|
||||
if deploy_data.gateway:
|
||||
ip_config = f"ip={allocated_ip}/24,gw={deploy_data.gateway}"
|
||||
else:
|
||||
ip_config = f"ip={allocated_ip}/24"
|
||||
elif allocated_ip is None:
|
||||
ip_config = "ip=dhcp"
|
||||
|
||||
config_success = client.configure_vm(
|
||||
node=deploy_node,
|
||||
vmid=new_vmid,
|
||||
cores=cores,
|
||||
memory=memory,
|
||||
disk_size=disk, # configure_vm will compare with current disk size
|
||||
network_bridge=deploy_data.network_bridge,
|
||||
cloud_init_user=deploy_data.cloud_init_user,
|
||||
cloud_init_password=deploy_data.cloud_init_password,
|
||||
ssh_keys=deploy_data.ssh_keys,
|
||||
ip_config=ip_config,
|
||||
onboot=deploy_data.onboot
|
||||
)
|
||||
|
||||
if not config_success:
|
||||
logger.warning(f"VM {new_vmid} created but configuration may be incomplete")
|
||||
|
||||
# Start VM if requested
|
||||
if deploy_data.start_after_create:
|
||||
client.start_vm(deploy_node, new_vmid)
|
||||
|
||||
# Enable High Availability if requested (cluster only)
|
||||
ha_enabled = False
|
||||
if deploy_data.enable_ha:
|
||||
try:
|
||||
if client.is_cluster():
|
||||
ha_result = client.add_to_ha(vmid=new_vmid, vm_type='vm', max_restart=3, max_relocate=3)
|
||||
if ha_result.get('success'):
|
||||
ha_enabled = True
|
||||
logger.info(f"HA enabled for VM {new_vmid}")
|
||||
else:
|
||||
logger.warning(f"Failed to enable HA for VM {new_vmid}: {ha_result.get('error')}")
|
||||
else:
|
||||
logger.warning(f"HA requested but server {server.name} is not in a cluster")
|
||||
except Exception as ha_error:
|
||||
logger.error(f"Error enabling HA for VM {new_vmid}: {ha_error}")
|
||||
|
||||
# Save VM configuration to database with owner
|
||||
save_vm_instance(
|
||||
db=db,
|
||||
server_id=server.id,
|
||||
vmid=new_vmid,
|
||||
node=deploy_node,
|
||||
vm_type='qemu',
|
||||
name=deploy_data.name,
|
||||
cores=cores,
|
||||
memory=memory,
|
||||
disk_size=disk,
|
||||
ip_address=allocated_ip,
|
||||
ip_prefix=24,
|
||||
gateway=deploy_data.gateway,
|
||||
cloud_init_user=deploy_data.cloud_init_user,
|
||||
cloud_init_password=deploy_data.cloud_init_password,
|
||||
ssh_keys=deploy_data.ssh_keys,
|
||||
template_id=template.id,
|
||||
template_name=template.name,
|
||||
description=f"Deployed from template {template.name}",
|
||||
owner_id=current_user.id # VPS-style user isolation
|
||||
)
|
||||
|
||||
logger.info(f"User {current_user.username} deployed VM {new_vmid} ({deploy_data.name}) from template {template.name}")
|
||||
|
||||
# Log VM deployment to audit
|
||||
LoggingService.log_proxmox_action(
|
||||
db=db,
|
||||
action="create",
|
||||
resource_type="vm",
|
||||
resource_id=new_vmid,
|
||||
username=current_user.username,
|
||||
resource_name=deploy_data.name,
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
node_name=deploy_node,
|
||||
details={
|
||||
"template_id": template.id,
|
||||
"template_name": template.name,
|
||||
"source_node": source_node if source_node != deploy_node else None,
|
||||
"cores": cores,
|
||||
"memory": memory,
|
||||
"disk": disk,
|
||||
"ip_address": allocated_ip,
|
||||
"ha_enabled": ha_enabled
|
||||
},
|
||||
success=True
|
||||
)
|
||||
|
||||
return VMDeployResponse(
|
||||
success=True,
|
||||
vmid=new_vmid,
|
||||
name=deploy_data.name,
|
||||
node=deploy_node,
|
||||
server_id=server.id,
|
||||
task_upid=clone_upid,
|
||||
message=f"VM {deploy_data.name} успешно создана (VMID: {new_vmid})"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log failed deployment
|
||||
LoggingService.log_proxmox_action(
|
||||
db=db,
|
||||
action="create",
|
||||
resource_type="vm",
|
||||
resource_id=deploy_data.vmid,
|
||||
username=current_user.username,
|
||||
resource_name=deploy_data.name,
|
||||
server_id=server.id if server else None,
|
||||
server_name=server.name if server else None,
|
||||
node_name=deploy_node if 'deploy_node' in locals() else None,
|
||||
details={
|
||||
"template_id": template.id if template else None,
|
||||
"template_name": template.name if template else None
|
||||
},
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
logger.error(f"Error deploying VM from template {template.id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка развертывания VM: {str(e)}")
|
||||
|
||||
|
||||
# ==================== Quick Deploy (for UI) ====================
|
||||
|
||||
@router.get("/api/quick-deploy/{template_id}")
|
||||
def get_quick_deploy_info(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("templates.view"))
|
||||
):
|
||||
"""Получить информацию для быстрого развертывания VM"""
|
||||
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
|
||||
|
||||
return JSONResponse(content={
|
||||
'template': {
|
||||
'id': template.id,
|
||||
'name': template.name,
|
||||
'vmid': template.vmid,
|
||||
'node': template.node,
|
||||
'description': template.description,
|
||||
'default_cores': template.default_cores,
|
||||
'default_memory': template.default_memory,
|
||||
'default_disk': template.default_disk,
|
||||
'min_cores': template.min_cores,
|
||||
'min_memory': template.min_memory,
|
||||
'min_disk': template.min_disk,
|
||||
},
|
||||
'group': {
|
||||
'name': group.name if group else None,
|
||||
'icon': group.icon if group else None,
|
||||
},
|
||||
'server': {
|
||||
'id': server.id if server else None,
|
||||
'name': server.name if server else None,
|
||||
}
|
||||
})
|
||||
986
backend/app/api/users.py
Normal file
986
backend/app/api/users.py
Normal file
@@ -0,0 +1,986 @@
|
||||
"""
|
||||
Users and Roles Management API
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from loguru import logger
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import User, Role
|
||||
from ..auth import (
|
||||
get_password_hash,
|
||||
get_current_user,
|
||||
get_current_active_admin,
|
||||
PermissionChecker,
|
||||
get_client_ip
|
||||
)
|
||||
from ..config import utcnow
|
||||
from ..services.security_service import SecurityService
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/users", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def users_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Users management page (admin only) - auth checked on frontend"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_users', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("users.html", context)
|
||||
|
||||
|
||||
# ==================== Schemas ====================
|
||||
|
||||
class RoleBase(BaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=50)
|
||||
display_name: str = Field(..., min_length=2, max_length=100)
|
||||
description: Optional[str] = None
|
||||
permissions: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
pass
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
display_name: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
description: Optional[str] = None
|
||||
permissions: Optional[dict] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
display_name: str
|
||||
description: Optional[str]
|
||||
permissions: dict
|
||||
is_system: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=6)
|
||||
full_name: Optional[str] = Field(None, max_length=100)
|
||||
role_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = Field(None, max_length=100)
|
||||
role_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_admin: Optional[bool] = None
|
||||
require_password_change: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
full_name: Optional[str]
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
role_id: Optional[int]
|
||||
role_name: Optional[str] = None
|
||||
failed_login_attempts: int = 0
|
||||
locked_until: Optional[datetime] = None
|
||||
last_login: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
new_password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class SecuritySettingsUpdate(BaseModel):
|
||||
max_login_attempts: Optional[int] = Field(None, ge=1, le=20)
|
||||
lockout_duration_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
session_timeout_minutes: Optional[int] = Field(None, ge=5, le=1440)
|
||||
ip_block_threshold: Optional[int] = Field(None, ge=1, le=100)
|
||||
ip_block_duration_minutes: Optional[int] = Field(None, ge=1, le=10080)
|
||||
password_min_length: Optional[int] = Field(None, ge=6, le=50)
|
||||
password_require_uppercase: Optional[bool] = None
|
||||
password_require_lowercase: Optional[bool] = None
|
||||
password_require_numbers: Optional[bool] = None
|
||||
password_require_special: Optional[bool] = None
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def users_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(PermissionChecker("users.view"))
|
||||
):
|
||||
"""Render users management page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_users', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("users.html", context)
|
||||
|
||||
|
||||
# ==================== Role API ====================
|
||||
|
||||
@router.get("/api/roles", response_model=List[RoleResponse])
|
||||
async def list_roles(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("roles.view"))
|
||||
):
|
||||
"""Get all roles"""
|
||||
roles = SecurityService.get_all_roles(db, include_inactive=True)
|
||||
return roles
|
||||
|
||||
|
||||
@router.get("/api/roles/{role_id}", response_model=RoleResponse)
|
||||
async def get_role(
|
||||
role_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("roles.view"))
|
||||
):
|
||||
"""Get role by ID"""
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
return role
|
||||
|
||||
|
||||
@router.post("/api/roles", response_model=RoleResponse, status_code=201)
|
||||
async def create_role(
|
||||
role_data: RoleCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("roles.manage"))
|
||||
):
|
||||
"""Create a new role"""
|
||||
# Check if role name exists
|
||||
existing = db.query(Role).filter(Role.name == role_data.name.lower()).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Role name already exists")
|
||||
|
||||
role = SecurityService.create_role(
|
||||
db=db,
|
||||
name=role_data.name,
|
||||
display_name=role_data.display_name,
|
||||
description=role_data.description,
|
||||
permissions=role_data.permissions
|
||||
)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="role_created",
|
||||
message=f"Role '{role.name}' created by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="role",
|
||||
resource_id=str(role.id),
|
||||
resource_name=role.name
|
||||
)
|
||||
|
||||
return role
|
||||
|
||||
|
||||
@router.put("/api/roles/{role_id}", response_model=RoleResponse)
|
||||
async def update_role(
|
||||
role_id: int,
|
||||
role_data: RoleUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("roles.manage"))
|
||||
):
|
||||
"""Update a role"""
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
# Update fields
|
||||
if role_data.display_name is not None:
|
||||
role.display_name = role_data.display_name
|
||||
if role_data.description is not None:
|
||||
role.description = role_data.description
|
||||
if role_data.permissions is not None:
|
||||
role.permissions = role_data.permissions
|
||||
if role_data.is_active is not None:
|
||||
role.is_active = role_data.is_active
|
||||
|
||||
role.updated_at = utcnow()
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="role_updated",
|
||||
message=f"Role '{role.name}' updated by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="role",
|
||||
resource_id=str(role.id),
|
||||
resource_name=role.name
|
||||
)
|
||||
|
||||
return role
|
||||
|
||||
|
||||
@router.delete("/api/roles/{role_id}")
|
||||
async def delete_role(
|
||||
role_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("roles.manage"))
|
||||
):
|
||||
"""Delete a role"""
|
||||
success, message = SecurityService.delete_role(db, role_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.WARNING,
|
||||
category=LoggingService.AUTH,
|
||||
action="role_deleted",
|
||||
message=f"Role ID {role_id} deleted by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="role",
|
||||
resource_id=str(role_id)
|
||||
)
|
||||
|
||||
return {"message": "Role deleted"}
|
||||
|
||||
|
||||
@router.get("/api/permissions")
|
||||
async def get_all_permissions(
|
||||
current_user: User = Depends(PermissionChecker("roles.view"))
|
||||
):
|
||||
"""Get all available permissions (legacy format for backwards compatibility)"""
|
||||
return SecurityService.get_all_permissions()
|
||||
|
||||
|
||||
@router.get("/api/permissions/v2")
|
||||
async def get_all_permissions_v2(
|
||||
current_user: User = Depends(PermissionChecker("roles.view"))
|
||||
):
|
||||
"""
|
||||
Get all available permissions in new format.
|
||||
Returns permissions grouped by category with resource:action codes.
|
||||
"""
|
||||
return SecurityService.get_all_permissions_v2()
|
||||
|
||||
|
||||
# ==================== User API ====================
|
||||
|
||||
@router.get("/api/users", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.view"))
|
||||
):
|
||||
"""Get all users"""
|
||||
users = db.query(User).order_by(User.username).all()
|
||||
|
||||
# Add role_name
|
||||
result = []
|
||||
for user in users:
|
||||
user_dict = {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"is_active": user.is_active,
|
||||
"is_admin": user.is_admin,
|
||||
"role_id": user.role_id,
|
||||
"role_name": user.role.display_name if user.role else ("Administrator" if user.is_admin else "User"),
|
||||
"failed_login_attempts": user.failed_login_attempts or 0,
|
||||
"locked_until": user.locked_until,
|
||||
"last_login": user.last_login,
|
||||
"created_at": user.created_at
|
||||
}
|
||||
result.append(user_dict)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/users/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.view"))
|
||||
):
|
||||
"""Get user by ID"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"is_active": user.is_active,
|
||||
"is_admin": user.is_admin,
|
||||
"role_id": user.role_id,
|
||||
"role_name": user.role.display_name if user.role else None,
|
||||
"failed_login_attempts": user.failed_login_attempts or 0,
|
||||
"locked_until": user.locked_until,
|
||||
"last_login": user.last_login,
|
||||
"created_at": user.created_at
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/users", response_model=UserResponse, status_code=201)
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.create"))
|
||||
):
|
||||
"""Create a new user"""
|
||||
# Check username
|
||||
if db.query(User).filter(User.username == user_data.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
|
||||
# Check email
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already exists")
|
||||
|
||||
# Validate password
|
||||
is_valid, errors = SecurityService.validate_password(db, user_data.password)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||
|
||||
# Validate role
|
||||
if user_data.role_id:
|
||||
role = db.query(Role).filter(Role.id == user_data.role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
full_name=user_data.full_name,
|
||||
role_id=user_data.role_id,
|
||||
is_active=user_data.is_active,
|
||||
is_admin=user_data.is_admin,
|
||||
last_password_change=utcnow()
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="user_created",
|
||||
message=f"User '{user.username}' created by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="user",
|
||||
resource_id=str(user.id),
|
||||
resource_name=user.username
|
||||
)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"is_active": user.is_active,
|
||||
"is_admin": user.is_admin,
|
||||
"role_id": user.role_id,
|
||||
"role_name": user.role.display_name if user.role else None,
|
||||
"failed_login_attempts": 0,
|
||||
"locked_until": None,
|
||||
"last_login": None,
|
||||
"created_at": user.created_at
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/users/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.edit"))
|
||||
):
|
||||
"""Update a user"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent editing admin by non-admin
|
||||
if user.is_admin and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Cannot edit admin user")
|
||||
|
||||
# Update fields
|
||||
if user_data.email is not None:
|
||||
# Check email uniqueness
|
||||
existing = db.query(User).filter(User.email == user_data.email, User.id != user_id).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already exists")
|
||||
user.email = user_data.email
|
||||
|
||||
if user_data.full_name is not None:
|
||||
user.full_name = user_data.full_name
|
||||
|
||||
if user_data.role_id is not None:
|
||||
role = db.query(Role).filter(Role.id == user_data.role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
user.role_id = user_data.role_id
|
||||
|
||||
if user_data.is_active is not None:
|
||||
# Cannot deactivate yourself
|
||||
if user_id == current_user.id and not user_data.is_active:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
||||
user.is_active = user_data.is_active
|
||||
|
||||
# Terminate sessions if deactivating
|
||||
if not user_data.is_active:
|
||||
SecurityService.terminate_all_user_sessions(db, user_id)
|
||||
|
||||
if user_data.require_password_change is not None:
|
||||
user.require_password_change = user_data.require_password_change
|
||||
|
||||
if user_data.is_admin is not None:
|
||||
# Only admin can change admin status
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admin can change admin status")
|
||||
# Cannot remove own admin status
|
||||
if user_id == current_user.id and not user_data.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot remove your own admin status")
|
||||
user.is_admin = user_data.is_admin
|
||||
|
||||
user.updated_at = utcnow()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="user_updated",
|
||||
message=f"User '{user.username}' updated by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="user",
|
||||
resource_id=str(user.id),
|
||||
resource_name=user.username
|
||||
)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"is_active": user.is_active,
|
||||
"is_admin": user.is_admin,
|
||||
"role_id": user.role_id,
|
||||
"role_name": user.role.display_name if user.role else None,
|
||||
"failed_login_attempts": user.failed_login_attempts or 0,
|
||||
"locked_until": user.locked_until,
|
||||
"last_login": user.last_login,
|
||||
"created_at": user.created_at
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.delete"))
|
||||
):
|
||||
"""Delete a user"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Cannot delete yourself
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
# Cannot delete admin
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin user")
|
||||
|
||||
username = user.username
|
||||
|
||||
# Terminate all sessions
|
||||
SecurityService.terminate_all_user_sessions(db, user_id)
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.WARNING,
|
||||
category=LoggingService.AUTH,
|
||||
action="user_deleted",
|
||||
message=f"User '{username}' deleted by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="user",
|
||||
resource_id=str(user_id),
|
||||
resource_name=username
|
||||
)
|
||||
|
||||
return {"message": "User deleted"}
|
||||
|
||||
|
||||
@router.post("/api/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: int,
|
||||
password_data: PasswordChange,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.edit"))
|
||||
):
|
||||
"""Reset user password (admin action)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Validate password
|
||||
is_valid, errors = SecurityService.validate_password(db, password_data.new_password)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||
|
||||
user.hashed_password = get_password_hash(password_data.new_password)
|
||||
user.last_password_change = utcnow()
|
||||
user.require_password_change = True # Force password change on next login
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.commit()
|
||||
|
||||
# Terminate all sessions
|
||||
SecurityService.terminate_all_user_sessions(db, user_id)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.WARNING,
|
||||
category=LoggingService.AUTH,
|
||||
action="password_reset",
|
||||
message=f"Password reset for user '{user.username}' by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="user",
|
||||
resource_id=str(user.id),
|
||||
resource_name=user.username
|
||||
)
|
||||
|
||||
return {"message": "Password reset successfully"}
|
||||
|
||||
|
||||
@router.post("/api/users/{user_id}/unlock")
|
||||
async def unlock_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.edit"))
|
||||
):
|
||||
"""Unlock a locked user account"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.commit()
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="user_unlocked",
|
||||
message=f"User '{user.username}' unlocked by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="user",
|
||||
resource_id=str(user.id),
|
||||
resource_name=user.username
|
||||
)
|
||||
|
||||
return {"message": "User unlocked"}
|
||||
|
||||
|
||||
@router.post("/api/users/{user_id}/terminate-sessions")
|
||||
async def terminate_user_sessions(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.edit"))
|
||||
):
|
||||
"""Terminate all sessions for a user"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
count = SecurityService.terminate_all_user_sessions(db, user_id)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="sessions_terminated",
|
||||
message=f"Terminated {count} sessions for user '{user.username}' by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="user",
|
||||
resource_id=str(user.id),
|
||||
resource_name=user.username
|
||||
)
|
||||
|
||||
return {"message": f"Terminated {count} sessions"}
|
||||
|
||||
|
||||
# ==================== Security Settings API ====================
|
||||
|
||||
@router.get("/api/security/settings")
|
||||
async def get_security_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.security"))
|
||||
):
|
||||
"""Get security settings"""
|
||||
return {
|
||||
"max_login_attempts": SecurityService.get_setting_int(db, "max_login_attempts", 5),
|
||||
"lockout_duration_minutes": SecurityService.get_setting_int(db, "lockout_duration_minutes", 30),
|
||||
"session_timeout_minutes": SecurityService.get_setting_int(db, "session_timeout_minutes", 60),
|
||||
"ip_block_threshold": SecurityService.get_setting_int(db, "ip_block_threshold", 10),
|
||||
"ip_block_duration_minutes": SecurityService.get_setting_int(db, "ip_block_duration_minutes", 60),
|
||||
"password_min_length": SecurityService.get_setting_int(db, "password_min_length", 8),
|
||||
"password_require_uppercase": SecurityService.get_setting_bool(db, "password_require_uppercase", True),
|
||||
"password_require_lowercase": SecurityService.get_setting_bool(db, "password_require_lowercase", True),
|
||||
"password_require_numbers": SecurityService.get_setting_bool(db, "password_require_numbers", True),
|
||||
"password_require_special": SecurityService.get_setting_bool(db, "password_require_special", False),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/security/settings")
|
||||
async def update_security_settings(
|
||||
settings_data: SecuritySettingsUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.security"))
|
||||
):
|
||||
"""Update security settings"""
|
||||
updated = []
|
||||
|
||||
for key, value in settings_data.model_dump(exclude_unset=True).items():
|
||||
SecurityService.update_setting(db, key, str(value).lower() if isinstance(value, bool) else str(value))
|
||||
updated.append(key)
|
||||
|
||||
if updated:
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="security_settings_updated",
|
||||
message=f"Security settings updated by {current_user.username}: {', '.join(updated)}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request)
|
||||
)
|
||||
|
||||
return {"message": "Settings updated", "updated": updated}
|
||||
|
||||
|
||||
# ==================== Blocked IPs API ====================
|
||||
|
||||
@router.get("/api/security/blocked-ips")
|
||||
async def list_blocked_ips(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.security"))
|
||||
):
|
||||
"""Get list of blocked IPs"""
|
||||
from ..models import BlockedIP
|
||||
blocked = db.query(BlockedIP).order_by(BlockedIP.blocked_at.desc()).all()
|
||||
return [{
|
||||
"id": ip.id,
|
||||
"ip_address": ip.ip_address,
|
||||
"reason": ip.reason,
|
||||
"blocked_by": ip.blocked_by,
|
||||
"blocked_at": ip.blocked_at.isoformat() if ip.blocked_at else None,
|
||||
"expires_at": ip.expires_at.isoformat() if ip.expires_at else None,
|
||||
"is_permanent": ip.is_permanent,
|
||||
"attempts_count": ip.attempts_count,
|
||||
"is_active": ip.is_blocked()
|
||||
} for ip in blocked]
|
||||
|
||||
|
||||
@router.delete("/api/security/blocked-ips/{ip_address:path}")
|
||||
async def unblock_ip(
|
||||
ip_address: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.security"))
|
||||
):
|
||||
"""Unblock an IP address"""
|
||||
if SecurityService.unblock_ip(db, ip_address):
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="ip_unblocked",
|
||||
message=f"IP {ip_address} unblocked by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request)
|
||||
)
|
||||
return {"message": f"IP {ip_address} unblocked"}
|
||||
|
||||
raise HTTPException(status_code=404, detail="IP not found in blocked list")
|
||||
|
||||
|
||||
# ==================== Sessions API ====================
|
||||
|
||||
@router.get("/api/sessions")
|
||||
async def list_sessions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.view"))
|
||||
):
|
||||
"""Get list of active sessions"""
|
||||
from ..models import ActiveSession
|
||||
|
||||
sessions = db.query(ActiveSession).filter(
|
||||
ActiveSession.expires_at > utcnow()
|
||||
).order_by(ActiveSession.last_activity.desc()).all()
|
||||
|
||||
result = []
|
||||
for session in sessions:
|
||||
user = db.query(User).filter(User.id == session.user_id).first()
|
||||
result.append({
|
||||
"id": session.id,
|
||||
"user_id": session.user_id,
|
||||
"username": user.username if user else "Unknown",
|
||||
"ip_address": session.ip_address,
|
||||
"device_info": session.device_info,
|
||||
"created_at": session.created_at.isoformat() if session.created_at else None,
|
||||
"last_activity": session.last_activity.isoformat() if session.last_activity else None,
|
||||
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/api/sessions/{session_id}")
|
||||
async def terminate_session(
|
||||
session_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.edit"))
|
||||
):
|
||||
"""Terminate a specific session"""
|
||||
from ..models import ActiveSession
|
||||
|
||||
session = db.query(ActiveSession).filter(ActiveSession.id == session_id).first()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="session_terminated",
|
||||
message=f"Session {session_id} terminated by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request)
|
||||
)
|
||||
|
||||
return {"message": "Session terminated"}
|
||||
|
||||
|
||||
@router.post("/api/sessions/terminate-all")
|
||||
async def terminate_all_sessions(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.edit"))
|
||||
):
|
||||
"""Terminate all sessions except current user's"""
|
||||
from ..models import ActiveSession
|
||||
|
||||
# Delete all sessions except current user's
|
||||
deleted = db.query(ActiveSession).filter(
|
||||
ActiveSession.user_id != current_user.id
|
||||
).delete()
|
||||
db.commit()
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.WARNING,
|
||||
category=LoggingService.AUTH,
|
||||
action="all_sessions_terminated",
|
||||
message=f"All sessions ({deleted}) terminated by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request)
|
||||
)
|
||||
|
||||
return {"message": f"Terminated {deleted} sessions"}
|
||||
|
||||
|
||||
# ==================== Security Events API ====================
|
||||
|
||||
@router.get("/api/security/events")
|
||||
async def list_security_events(
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.security"))
|
||||
):
|
||||
"""Get security events from logs"""
|
||||
from ..models import AuditLog
|
||||
|
||||
# Get auth-related logs
|
||||
events = db.query(AuditLog).filter(
|
||||
AuditLog.category == "auth"
|
||||
).order_by(AuditLog.created_at.desc()).limit(limit).all()
|
||||
|
||||
return [{
|
||||
"id": event.id,
|
||||
"event_type": event.action,
|
||||
"ip_address": event.ip_address,
|
||||
"username": event.username,
|
||||
"message": event.message,
|
||||
"created_at": event.created_at.isoformat() if event.created_at else None,
|
||||
} for event in events]
|
||||
|
||||
|
||||
# ==================== Block IP API ====================
|
||||
|
||||
@router.post("/api/security/blocked-ips")
|
||||
async def block_ip(
|
||||
ip_data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.security"))
|
||||
):
|
||||
"""Block an IP address manually"""
|
||||
from ..models import BlockedIP
|
||||
|
||||
ip_address = ip_data.get("ip_address")
|
||||
reason = ip_data.get("reason", "Manual block")
|
||||
duration_minutes = ip_data.get("duration_minutes")
|
||||
|
||||
if not ip_address:
|
||||
raise HTTPException(status_code=400, detail="IP address is required")
|
||||
|
||||
# Check if already blocked
|
||||
existing = db.query(BlockedIP).filter(BlockedIP.ip_address == ip_address).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="IP is already blocked")
|
||||
|
||||
# Calculate expiry
|
||||
expires_at = None
|
||||
is_permanent = True
|
||||
if duration_minutes:
|
||||
expires_at = utcnow() + timedelta(minutes=int(duration_minutes))
|
||||
is_permanent = False
|
||||
|
||||
blocked_ip = BlockedIP(
|
||||
ip_address=ip_address,
|
||||
reason=reason,
|
||||
blocked_by=current_user.username,
|
||||
blocked_at=utcnow(),
|
||||
expires_at=expires_at,
|
||||
is_permanent=is_permanent
|
||||
)
|
||||
db.add(blocked_ip)
|
||||
db.commit()
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.WARNING,
|
||||
category=LoggingService.AUTH,
|
||||
action="ip_blocked_manual",
|
||||
message=f"IP {ip_address} blocked manually by {current_user.username}: {reason}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request)
|
||||
)
|
||||
|
||||
return {"message": f"IP {ip_address} blocked", "id": blocked_ip.id}
|
||||
|
||||
|
||||
@router.delete("/api/security/blocked-ips/{blocked_id}")
|
||||
async def unblock_ip_by_id(
|
||||
blocked_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("settings.security"))
|
||||
):
|
||||
"""Unblock an IP address by ID"""
|
||||
from ..models import BlockedIP
|
||||
|
||||
blocked_ip = db.query(BlockedIP).filter(BlockedIP.id == blocked_id).first()
|
||||
if not blocked_ip:
|
||||
raise HTTPException(status_code=404, detail="Blocked IP not found")
|
||||
|
||||
ip_address = blocked_ip.ip_address
|
||||
db.delete(blocked_ip)
|
||||
db.commit()
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="ip_unblocked",
|
||||
message=f"IP {ip_address} unblocked by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request)
|
||||
)
|
||||
|
||||
return {"message": f"IP {ip_address} unblocked"}
|
||||
Reference in New Issue
Block a user