Refactor backend: Split models and proxmox_client into modules, add alembic
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m27s

This commit is contained in:
2026-06-13 03:53:27 +05:00
parent 8a879f36fa
commit bfd92102cf
54 changed files with 6026 additions and 5137 deletions

View File

@@ -0,0 +1,17 @@
from .base import *
from .auth import Role, user_servers, User, ActiveSession, LoginAttempt, BlockedIP, UserSSHKey
from .proxmox import OSTemplateGroup, OSTemplate, ProxmoxServer, VMInstance, VMSnapshotArchive
from .ipam import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
from .tasks import TaskQueue, ProxmoxTask, DeployTask
from .settings import PanelSettings, SecuritySetting, Workspace, WorkspaceServer, WorkspaceUser
from .misc import Notification, NotificationPreference, BackupJob, AuditLog
__all__ = [
'Base', 'utcnow',
'Role', 'user_servers', 'User', 'ActiveSession', 'LoginAttempt', 'BlockedIP', 'UserSSHKey',
'OSTemplateGroup', 'OSTemplate', 'ProxmoxServer', 'VMInstance', 'VMSnapshotArchive',
'IPAMNetwork', 'IPAMPool', 'IPAMAllocation', 'IPAMHistory',
'TaskQueue', 'ProxmoxTask', 'DeployTask',
'PanelSettings', 'SecuritySetting', 'Workspace', 'WorkspaceServer', 'WorkspaceUser',
'Notification', 'NotificationPreference', 'BackupJob', 'AuditLog'
]

204
backend/app/models/auth.py Normal file
View File

@@ -0,0 +1,204 @@
from .base import *
# Association table: User <-> ProxmoxServer (assigned servers)
user_servers = Table(
'user_servers',
Base.metadata,
Column('user_id', Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
Column('server_id', Integer, ForeignKey('proxmox_servers.id', ondelete='CASCADE'), primary_key=True)
)
class Role(Base):
"""User roles with permissions"""
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True, nullable=False, index=True)
display_name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
permissions = Column(JSON, nullable=False, default={})
is_system = Column(Boolean, default=False, nullable=False) # System roles can't be deleted
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
users = relationship("User", back_populates="role")
def has_permission(self, permission: str) -> bool:
"""Check if role has specific permission"""
if not self.permissions:
return False
return self.permissions.get(permission, False)
def __repr__(self):
return f"<Role(id={self.id}, name='{self.name}')>"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(100), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False) # Legacy, use role instead
# Role-based access
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True, index=True)
role = relationship("Role", back_populates="users", lazy="joined")
# SSH Public Key for VM/LXC deployment
ssh_public_key = Column(Text, nullable=True)
# Security fields
failed_login_attempts = Column(Integer, default=0, nullable=False)
locked_until = Column(DateTime(timezone=True), nullable=True)
last_password_change = Column(DateTime(timezone=True), nullable=True)
require_password_change = Column(Boolean, default=False, nullable=False)
two_factor_enabled = Column(Boolean, default=False, nullable=False)
two_factor_secret = Column(String(100), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
last_login = Column(DateTime(timezone=True), nullable=True)
# Relationships
sessions = relationship("ActiveSession", back_populates="user", cascade="all, delete-orphan")
assigned_servers = relationship("ProxmoxServer", secondary=user_servers, back_populates="assigned_users", lazy="select")
def has_permission(self, permission: str) -> bool:
"""
Check if user has specific permission.
Uses new RBAC engine with legacy format support.
"""
try:
from ..rbac import PermissionEngine
return PermissionEngine.has_permission(self, permission)
except ImportError:
if self.is_admin:
return True
if self.role:
return self.role.has_permission(permission)
return False
def get_permissions(self) -> set:
"""Get all effective permissions for this user"""
try:
from ..rbac import PermissionEngine
return PermissionEngine.get_user_permissions(self)
except ImportError:
if self.is_admin:
return set() # Admin has all
if self.role and self.role.permissions:
return {k for k, v in self.role.permissions.items() if v}
return set()
def is_locked(self) -> bool:
"""Check if account is locked"""
if self.locked_until and make_tz_aware(self.locked_until) > utcnow():
return True
return False
@property
def role_name(self) -> str:
"""Get role name"""
if self.role:
return self.role.name
return "admin" if self.is_admin else "user"
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}', is_admin={self.is_admin})>"
class ActiveSession(Base):
"""Active user sessions for single-session enforcement"""
__tablename__ = "active_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
session_token = Column(String(64), unique=True, nullable=False, index=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(String(500), nullable=True)
device_info = Column(String(200), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
last_activity = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
user = relationship("User", back_populates="sessions")
def is_expired(self) -> bool:
"""Check if session is expired"""
from datetime import timezone
now = datetime.now(timezone.utc)
if self.expires_at is None:
return True
return now > make_tz_aware(self.expires_at)
def __repr__(self):
return f"<ActiveSession(id={self.id}, user_id={self.user_id}, active={self.is_active})>"
class LoginAttempt(Base):
"""Login attempts for brute-force protection"""
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(100), nullable=True, index=True)
ip_address = Column(String(45), nullable=False, index=True)
user_agent = Column(String(500), nullable=True)
success = Column(Boolean, default=False, nullable=False)
failure_reason = Column(String(200), nullable=True)
attempted_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
def __repr__(self):
return f"<LoginAttempt(id={self.id}, ip={self.ip_address}, success={self.success})>"
class BlockedIP(Base):
"""Blocked IP addresses"""
__tablename__ = "blocked_ips"
id = Column(Integer, primary_key=True, index=True)
ip_address = Column(String(45), unique=True, nullable=False, index=True)
reason = Column(String(500), nullable=True)
blocked_by = Column(String(100), nullable=True)
blocked_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=True)
is_permanent = Column(Boolean, default=False, nullable=False)
attempts_count = Column(Integer, default=0, nullable=False)
def is_blocked(self) -> bool:
"""Check if IP is currently blocked"""
if self.is_permanent:
return True
if self.expires_at and make_tz_aware(self.expires_at) > utcnow():
return True
return False
def __repr__(self):
return f"<BlockedIP(id={self.id}, ip={self.ip_address})>"
class UserSSHKey(Base):
"""SSH key attached to a user account."""
__tablename__ = "user_ssh_keys"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(100), nullable=False)
public_key = Column(Text, nullable=False)
private_key = Column(EncryptedString(8000), nullable=True)
fingerprint = Column(String(100), nullable=True, index=True)
comment = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user = relationship("User", backref="ssh_keys_pool")
__table_args__ = (
Index('idx_user_ssh_keys_user', 'user_id'),
)
def __repr__(self):
return f"<UserSSHKey(id={self.id}, user_id={self.user_id}, name='{self.name}')>"

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Boolean, Column, Integer, BigInteger, String, DateTime, Text, Index, JSON, ForeignKey, Table
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from typing import Optional
from ..db import Base
from ..crypto import EncryptedString
from ..config import utcnow, make_tz_aware
# Just exposing them for the other models to import easily
__all__ = [
'Base', 'EncryptedString', 'utcnow', 'make_tz_aware',
'Boolean', 'Column', 'Integer', 'BigInteger', 'String', 'DateTime', 'Text', 'Index', 'JSON', 'ForeignKey', 'Table',
'func', 'relationship', 'datetime', 'timezone', 'Optional'
]

139
backend/app/models/ipam.py Normal file
View File

@@ -0,0 +1,139 @@
from .base import *
class IPAMNetwork(Base):
"""Networks/subnets for IPAM"""
__tablename__ = "ipam_networks"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
network = Column(String(18), nullable=False, index=True)
gateway = Column(String(45), nullable=True)
vlan_id = Column(Integer, nullable=True)
dns_primary = Column(String(45), nullable=True)
dns_secondary = Column(String(45), nullable=True)
dns_domain = Column(String(255), nullable=True)
proxmox_server_id = Column(Integer, nullable=True, index=True)
proxmox_node = Column(String(100), nullable=True)
proxmox_bridge = Column(String(20), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_ipam_network_name', 'name'),
Index('idx_ipam_network_active', 'is_active'),
)
def __repr__(self):
return f"<IPAMNetwork(id={self.id}, name='{self.name}', network='{self.network}')>"
class IPAMPool(Base):
"""IP pools inside a network"""
__tablename__ = "ipam_pools"
id = Column(Integer, primary_key=True, index=True)
network_id = Column(Integer, nullable=False, index=True)
name = Column(String(100), nullable=False)
pool_type = Column(String(20), default="static", nullable=False)
range_start = Column(String(45), nullable=False)
range_end = Column(String(45), nullable=False)
auto_assign = Column(Boolean, default=True, nullable=False)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_ipam_pool_network', 'network_id'),
Index('idx_ipam_pool_type', 'pool_type'),
)
def __repr__(self):
return f"<IPAMPool(id={self.id}, name='{self.name}', range='{self.range_start}-{self.range_end}')>"
class IPAMAllocation(Base):
"""Allocated IP addresses"""
__tablename__ = "ipam_allocations"
id = Column(Integer, primary_key=True, index=True)
network_id = Column(Integer, nullable=False, index=True)
pool_id = Column(Integer, nullable=True, index=True)
ip_address = Column(String(45), nullable=False, unique=True, index=True)
mac_address = Column(String(17), nullable=True)
resource_type = Column(String(20), nullable=True)
resource_id = Column(Integer, nullable=True)
resource_name = Column(String(100), nullable=True)
proxmox_server_id = Column(Integer, nullable=True, index=True)
proxmox_vmid = Column(Integer, nullable=True)
proxmox_node = Column(String(100), nullable=True)
status = Column(String(20), default="allocated", nullable=False)
allocation_type = Column(String(20), default="static", nullable=False)
hostname = Column(String(255), nullable=True, index=True)
fqdn = Column(String(255), nullable=True)
dns_ptr_record = Column(Boolean, default=False, nullable=False)
allocated_by = Column(String(100), nullable=True)
allocated_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=True)
last_seen = Column(DateTime(timezone=True), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_ipam_alloc_network', 'network_id'),
Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'),
Index('idx_ipam_alloc_proxmox', 'proxmox_server_id', 'proxmox_vmid'),
Index('idx_ipam_alloc_status', 'status'),
)
def __repr__(self):
return f"<IPAMAllocation(id={self.id}, ip='{self.ip_address}', resource='{self.resource_name}')>"
class IPAMHistory(Base):
"""History of IPAM changes"""
__tablename__ = "ipam_history"
id = Column(Integer, primary_key=True, index=True)
ip_address = Column(String(45), nullable=False, index=True)
network_id = Column(Integer, nullable=True, index=True)
action = Column(String(50), nullable=False)
old_value = Column(JSON, nullable=True)
new_value = Column(JSON, nullable=True)
resource_type = Column(String(20), nullable=True)
resource_id = Column(Integer, nullable=True)
resource_name = Column(String(100), nullable=True)
performed_by = Column(String(100), nullable=True)
performed_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
notes = Column(Text, nullable=True)
__table_args__ = (
Index('idx_ipam_history_ip', 'ip_address'),
Index('idx_ipam_history_action', 'action'),
Index('idx_ipam_history_date', 'performed_at'),
)
def __repr__(self):
return f"<IPAMHistory(id={self.id}, ip='{self.ip_address}', action='{self.action}')>"

200
backend/app/models/misc.py Normal file
View File

@@ -0,0 +1,200 @@
from .base import *
class Notification(Base):
"""User notifications"""
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
type = Column(String(50), nullable=False)
level = Column(String(20), nullable=False)
title = Column(String(255), nullable=False)
message = Column(Text)
data = Column(JSON)
link = Column(String(500))
source = Column(String(50))
source_id = Column(String(100))
read = Column(Boolean, default=False)
read_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True))
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"type": self.type,
"level": self.level,
"title": self.title,
"message": self.message,
"data": self.data,
"link": self.link,
"source": self.source,
"source_id": self.source_id,
"read": self.read,
"read_at": self.read_at.isoformat() if self.read_at else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
}
class NotificationPreference(Base):
"""User notification preferences"""
__tablename__ = "notification_preferences"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
enabled = Column(Boolean, default=True)
email_enabled = Column(Boolean, default=False)
email_critical_only = Column(Boolean, default=True)
telegram_enabled = Column(Boolean, default=False)
telegram_chat_id = Column(String(100))
webhook_url = Column(String(500))
notification_levels = Column(JSON, default=["critical", "warning", "info", "success"])
notification_types = Column(JSON, default=["vm_status", "resource_alert", "system"])
quiet_hours_start = Column(String(5))
quiet_hours_end = Column(String(5))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"enabled": self.enabled,
"email_enabled": self.email_enabled,
"email_critical_only": self.email_critical_only,
"telegram_enabled": self.telegram_enabled,
"telegram_chat_id": self.telegram_chat_id,
"webhook_url": self.webhook_url,
"notification_levels": self.notification_levels,
"notification_types": self.notification_types,
"quiet_hours_start": self.quiet_hours_start,
"quiet_hours_end": self.quiet_hours_end,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class BackupJob(Base):
"""Scheduled backup job"""
__tablename__ = "backup_jobs"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="CASCADE"), nullable=False)
node = Column(String(100), nullable=False)
vmids = Column(JSON, nullable=False, default=[])
storage = Column(String(100), nullable=False)
mode = Column(String(20), default="snapshot", nullable=False)
compress = Column(String(20), default="zstd", nullable=False)
notes = Column(String(500), nullable=True)
keep_last = Column(Integer, default=3, nullable=False)
keep_daily = Column(Integer, default=7, nullable=False)
keep_weekly = Column(Integer, default=4, nullable=False)
keep_monthly = Column(Integer, default=6, nullable=False)
cron_expression = Column(String(100), nullable=False)
enabled = Column(Boolean, default=True, nullable=False)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
last_run_at = Column(DateTime(timezone=True), nullable=True)
last_status = Column(String(20), nullable=True)
last_error = Column(Text, nullable=True)
last_upid = Column(String(200), nullable=True)
server = relationship("ProxmoxServer", backref="backup_jobs")
owner = relationship("User", foreign_keys=[owner_id])
__table_args__ = (
Index('idx_backup_jobs_server', 'server_id'),
Index('idx_backup_jobs_enabled', 'enabled'),
Index('idx_backup_jobs_owner', 'owner_id'),
)
def __repr__(self):
return f"<BackupJob(id={self.id}, server_id={self.server_id}, cron='{self.cron_expression}')>"
def to_dict(self) -> dict:
return {
'id': self.id,
'server_id': self.server_id,
'node': self.node,
'vmids': self.vmids,
'storage': self.storage,
'mode': self.mode,
'compress': self.compress,
'notes': self.notes,
'keep_last': self.keep_last,
'keep_daily': self.keep_daily,
'keep_weekly': self.keep_weekly,
'keep_monthly': self.keep_monthly,
'cron_expression': self.cron_expression,
'enabled': self.enabled,
'owner_id': self.owner_id,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'last_run_at': self.last_run_at.isoformat() if self.last_run_at else None,
'last_status': self.last_status,
'last_error': self.last_error,
'last_upid': self.last_upid,
}
class AuditLog(Base):
"""Логи аудита системы"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
level = Column(String(20), nullable=False, index=True)
category = Column(String(50), nullable=False, index=True)
action = Column(String(100), nullable=False, index=True)
message = Column(Text, nullable=False)
request_id = Column(String(36), nullable=True, index=True)
session_id = Column(String(64), nullable=True, index=True)
user_id = Column(Integer, nullable=True, index=True)
username = Column(String(100), nullable=True, index=True)
ip_address = Column(String(45), nullable=True, index=True)
user_agent = Column(String(500), nullable=True)
geo_location = Column(String(100), nullable=True)
resource_type = Column(String(50), nullable=True, index=True)
resource_id = Column(String(100), nullable=True)
resource_name = Column(String(200), nullable=True)
server_id = Column(Integer, nullable=True, index=True)
server_name = Column(String(100), nullable=True)
node_name = Column(String(100), nullable=True)
details = Column(JSON, nullable=True)
request_body = Column(JSON, nullable=True)
response_body = Column(JSON, nullable=True)
request_method = Column(String(10), nullable=True)
request_path = Column(String(500), nullable=True)
query_params = Column(String(1000), nullable=True)
response_status = Column(Integer, nullable=True)
duration_ms = Column(Integer, nullable=True)
success = Column(Boolean, default=True, nullable=False)
error_message = Column(Text, nullable=True)
error_traceback = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
__table_args__ = (
Index('idx_audit_level_category', 'level', 'category'),
Index('idx_audit_user_time', 'username', 'created_at'),
Index('idx_audit_resource', 'resource_type', 'resource_id'),
Index('idx_audit_created', 'created_at'),
Index('idx_audit_request_id', 'request_id'),
Index('idx_audit_ip', 'ip_address'),
Index('idx_audit_server', 'server_id'),
)
def __repr__(self):
return f"<AuditLog(id={self.id}, level='{self.level}', action='{self.action}')>"

View File

@@ -0,0 +1,220 @@
from .base import *
class OSTemplateGroup(Base):
"""Group of OS templates"""
__tablename__ = "os_template_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False, index=True)
icon = Column(String(200), nullable=True)
description = Column(Text, nullable=True)
sort_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<OSTemplateGroup(id={self.id}, name='{self.name}')>"
class OSTemplate(Base):
"""OS Template"""
__tablename__ = "os_templates"
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, nullable=False, index=True)
server_id = Column(Integer, nullable=False, index=True)
name = Column(String(100), nullable=False)
vmid = Column(Integer, nullable=True)
vm_type = Column(String(10), nullable=False, default="qemu", server_default="qemu")
volid = Column(String(500), nullable=True)
node = Column(String(100), nullable=True)
source_node = Column(String(100), nullable=True)
replicated_nodes = Column(JSON, nullable=True, default={})
default_cores = Column(Integer, nullable=False, default=1)
default_memory = Column(Integer, nullable=False, default=1024)
default_disk = Column(Integer, nullable=False, default=10)
min_cores = Column(Integer, nullable=False, default=1)
min_memory = Column(Integer, nullable=False, default=512)
min_disk = Column(Integer, nullable=False, default=5)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
sort_order = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_os_template_group', 'group_id'),
Index('idx_os_template_server', 'server_id'),
Index('idx_os_template_vmid', 'server_id', 'vmid'),
)
def get_source_node(self) -> str:
return self.source_node or self.node
def get_vmid_for_node(self, node_name: str) -> Optional[int]:
source = self.get_source_node()
if node_name == source:
return self.vmid
if self.replicated_nodes and node_name in self.replicated_nodes:
return self.replicated_nodes[node_name]
return None
def add_replicated_node(self, node_name: str, vmid: int):
if not self.replicated_nodes:
self.replicated_nodes = {}
self.replicated_nodes[node_name] = vmid
def __repr__(self):
return f"<OSTemplate(id={self.id}, name='{self.name}', vmid={self.vmid})>"
class ProxmoxServer(Base):
"""Proxmox VE server"""
__tablename__ = "proxmox_servers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False, index=True)
hostname = Column(String(255), nullable=False, index=True)
ip_address = Column(String(50), nullable=False, index=True)
port = Column(Integer, nullable=False, default=8006)
api_user = Column(String(100), nullable=False, default="root@pam")
api_token_name = Column(String(100), nullable=True)
api_token_value = Column(String(255), nullable=True)
use_password = Column(Boolean, nullable=False, default=False)
password = Column(EncryptedString(255), nullable=True)
verify_ssl = Column(Boolean, nullable=False, default=True)
cluster_name = Column(String(100), nullable=True, index=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
last_check = Column(DateTime(timezone=True), nullable=True)
is_online = Column(Boolean, nullable=True, default=None)
last_error = Column(Text, nullable=True)
__table_args__ = (
Index('idx_proxmox_status', 'is_online', 'last_check'),
)
def __repr__(self):
return f"<ProxmoxServer(id={self.id}, name='{self.name}', ip='{self.ip_address}')>"
@property
def connection_info(self) -> dict:
return {
'id': self.id,
'name': self.name,
'hostname': self.hostname,
'ip_address': self.ip_address,
'port': self.port,
'api_user': self.api_user,
'verify_ssl': self.verify_ssl,
'is_online': self.is_online,
'last_check': self.last_check
}
def update_status(self, is_online: bool, error: str = None):
self.is_online = is_online
self.last_check = utcnow()
if error:
self.last_error = error
elif is_online:
self.last_error = None
assigned_users = relationship("User", secondary="user_servers", back_populates="assigned_servers", lazy="select")
class VMInstance(Base):
"""VM/Container instance cache"""
__tablename__ = "vm_instances"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, nullable=False, index=True)
vmid = Column(Integer, nullable=False, index=True)
node = Column(String(100), nullable=False)
vm_type = Column(String(20), nullable=False)
name = Column(String(100), nullable=False)
owner_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
owner = relationship("User", backref="instances", foreign_keys=[owner_id])
status = Column(String(20), default='unknown')
is_template = Column(Boolean, default=False)
cores = Column(Integer, nullable=True)
memory = Column(BigInteger, nullable=True)
disk_size = Column(BigInteger, nullable=True)
os_type = Column(String(50), nullable=True)
ip_address = Column(String(50), nullable=True)
ip_prefix = Column(Integer, nullable=True, default=24)
gateway = Column(String(50), nullable=True)
nameserver = Column(String(50), nullable=True)
cloud_init_user = Column(String(100), nullable=True)
cloud_init_password = Column(EncryptedString(255), nullable=True)
ssh_keys = Column(Text, nullable=True)
template_id = Column(Integer, nullable=True)
template_name = Column(String(100), nullable=True)
description = Column(Text, nullable=True)
tags = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
deleted_at = Column(DateTime(timezone=True), nullable=True)
last_sync_at = Column(DateTime(timezone=True), nullable=True)
extra_config = Column(JSON, nullable=True)
__table_args__ = (
Index('idx_vm_instance_server_vmid', 'server_id', 'vmid', unique=True),
Index('idx_vm_instance_active', 'server_id', 'deleted_at'),
Index('idx_vm_instance_owner', 'owner_id'),
)
def __repr__(self):
return f"<VMInstance(id={self.id}, server_id={self.server_id}, vmid={self.vmid}, name='{self.name}')>"
class VMSnapshotArchive(Base):
"""Archive of VM/Container snapshots"""
__tablename__ = "vm_snapshot_archives"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, nullable=False, index=True)
server_name = Column(String(100), nullable=True)
vmid = Column(Integer, nullable=False, index=True)
vm_name = Column(String(100), nullable=True)
vm_type = Column(String(20), nullable=False)
node = Column(String(100), nullable=False)
snapname = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
snaptime = Column(BigInteger, nullable=True)
parent = Column(String(100), nullable=True)
vmstate = Column(Boolean, default=False, nullable=False)
snapshot_config = Column(JSON, nullable=True)
deleted_by = Column(String(100), nullable=True)
deletion_reason = Column(Text, nullable=True)
archived_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
__table_args__ = (
Index('idx_snapshot_archive_server_vmid', 'server_id', 'vmid'),
Index('idx_snapshot_archive_archived', 'archived_at'),
)
def __repr__(self):
return f"<VMSnapshotArchive(id={self.id}, vmid={self.vmid}, snapname='{self.snapname}')>"

View File

@@ -0,0 +1,86 @@
from .base import *
class PanelSettings(Base):
"""Panel settings"""
__tablename__ = "panel_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<PanelSettings(id={self.id}, key='{self.key}', value='{self.value}')>"
class SecuritySetting(Base):
"""Security settings"""
__tablename__ = "security_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<SecuritySetting(key='{self.key}', value='{self.value}')>"
class Workspace(Base):
"""Workspace — named group of Proxmox servers"""
__tablename__ = "workspaces"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False, index=True)
description = Column(Text, nullable=True)
color = Column(String(20), nullable=True, default="#667eea")
is_default = Column(Boolean, default=False, nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
creator = relationship("User", foreign_keys=[created_by])
workspace_servers = relationship("WorkspaceServer", back_populates="workspace", cascade="all, delete-orphan")
workspace_users = relationship("WorkspaceUser", back_populates="workspace", cascade="all, delete-orphan")
def __repr__(self):
return f"<Workspace(id={self.id}, name='{self.name}')>"
class WorkspaceServer(Base):
"""Junction table: servers belonging to a workspace"""
__tablename__ = "workspace_servers"
id = Column(Integer, primary_key=True, index=True)
workspace_id = Column(Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False)
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="CASCADE"), nullable=False)
workspace = relationship("Workspace", back_populates="workspace_servers")
server = relationship("ProxmoxServer")
__table_args__ = (
Index('idx_ws_servers_workspace', 'workspace_id'),
Index('idx_ws_servers_server', 'server_id'),
Index('idx_ws_servers_unique', 'workspace_id', 'server_id', unique=True),
)
def __repr__(self):
return f"<WorkspaceServer(workspace_id={self.workspace_id}, server_id={self.server_id})>"
class WorkspaceUser(Base):
"""Junction table: users with access to a workspace"""
__tablename__ = "workspace_users"
id = Column(Integer, primary_key=True, index=True)
workspace_id = Column(Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
workspace = relationship("Workspace", back_populates="workspace_users")
user = relationship("User")
__table_args__ = (
Index('idx_ws_users_workspace', 'workspace_id'),
Index('idx_ws_users_user', 'user_id'),
Index('idx_ws_users_unique', 'workspace_id', 'user_id', unique=True),
)

164
backend/app/models/tasks.py Normal file
View File

@@ -0,0 +1,164 @@
from .base import *
class TaskQueue(Base):
"""Background task queue for bulk operations"""
__tablename__ = "task_queue"
id = Column(Integer, primary_key=True, index=True)
task_type = Column(String(50), nullable=False, index=True)
status = Column(String(20), nullable=False, default='pending', index=True)
user_id = Column(Integer, nullable=False, index=True)
total_items = Column(Integer, nullable=False, default=0)
completed_items = Column(Integer, nullable=False, default=0)
failed_items = Column(Integer, nullable=False, default=0)
task_data = Column(JSON, nullable=False)
results = Column(JSON, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index('idx_task_status_created', 'status', 'created_at'),
Index('idx_task_user_status', 'user_id', 'status'),
)
def __repr__(self):
return f"<TaskQueue(id={self.id}, type='{self.task_type}', status='{self.status}')>"
@property
def progress_percent(self) -> int:
if self.total_items == 0:
return 0
return int((self.completed_items + self.failed_items) / self.total_items * 100)
def to_dict(self) -> dict:
_desc_map = {
'bulk_start': 'Массовый запуск ВМ/LXC',
'bulk_stop': 'Массовая остановка ВМ/LXC',
'bulk_restart': 'Массовый перезапуск ВМ/LXC',
'bulk_delete': 'Массовое удаление ВМ/LXC',
'bulk_shutdown': 'Массовое выключение ВМ/LXC',
}
return {
'id': self.id,
'kind': 'bulk',
'user_id': self.user_id,
'task_type': self.task_type,
'description': _desc_map.get(self.task_type, self.task_type),
'status': self.status,
'total_items': self.total_items,
'completed_items': self.completed_items,
'failed_items': self.failed_items,
'processed_items': self.completed_items + self.failed_items,
'progress_percent': self.progress_percent,
'results': self.results,
'error_message': self.error_message,
'created_at': self.created_at.isoformat() if self.created_at else None,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}
class ProxmoxTask(Base):
"""Tracks long-running Proxmox tasks identified by UPID."""
__tablename__ = "proxmox_tasks"
id = Column(Integer, primary_key=True, index=True)
upid = Column(String(200), nullable=False, index=True)
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="SET NULL"), nullable=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
node = Column(String(100), nullable=True)
vmid = Column(Integer, nullable=True)
vm_type = Column(String(10), nullable=True)
action = Column(String(100), nullable=False)
description = Column(String(500), nullable=True)
status = Column(String(20), nullable=False, default="running", index=True)
exit_status = Column(String(100), nullable=True)
log_text = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
'id': self.id,
'kind': 'proxmox',
'upid': self.upid,
'server_id': self.server_id,
'user_id': self.user_id,
'node': self.node,
'vmid': self.vmid,
'vm_type': self.vm_type,
'action': self.action,
'description': self.description,
'status': self.status,
'exit_status': self.exit_status,
'log_text': self.log_text,
'created_at': self.created_at.isoformat() if self.created_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}
def __repr__(self):
return f"<ProxmoxTask(id={self.id}, upid='{self.upid[:30]}...', status='{self.status}')>"
class DeployTask(Base):
"""Tracks async VM deployment tasks from templates"""
__tablename__ = "deploy_tasks"
id = Column(Integer, primary_key=True, index=True)
status = Column(String(20), nullable=False, default='pending', index=True)
kind = Column(String(30), nullable=False, default='deploy', index=True)
step = Column(String(200), nullable=True)
progress = Column(Integer, nullable=False, default=0)
name = Column(String(100), nullable=False)
template_id = Column(Integer, nullable=True)
server_id = Column(Integer, nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
vmid = Column(Integer, nullable=True)
node = Column(String(100), nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index('idx_deploy_task_user_status', 'user_id', 'status'),
)
def to_dict(self) -> dict:
kind = self.kind or 'deploy'
descriptions = {
'deploy': f"Создание {self.name}",
'reinstall': f"Переустановка {self.name}",
'clone': f"Клонирование {self.name}",
'change_password': f"Смена пароля: {self.name}",
}
return {
'id': self.id,
'kind': kind,
'status': self.status,
'step': self.step,
'current_step': self.step,
'progress': self.progress,
'progress_percent': self.progress,
'description': descriptions.get(kind, self.name),
'name': self.name,
'template_id': self.template_id,
'server_id': self.server_id,
'vmid': self.vmid,
'node': self.node,
'error_message': self.error_message,
'created_at': self.created_at.isoformat() if self.created_at else None,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}
def __repr__(self):
return f"<DeployTask(id={self.id}, kind='{self.kind}', name='{self.name}', status='{self.status}')>"