from datetime import datetime from typing import Optional, List import ipaddress from pydantic import BaseModel, Field, field_validator, model_validator, EmailStr # ==================== User Schemas ==================== class UserBase(BaseModel): username: str = Field(..., min_length=3, max_length=50, description="Username") email: EmailStr = Field(..., description="Email address") full_name: Optional[str] = Field(None, max_length=100, description="Full name") class UserCreate(UserBase): password: str = Field(..., min_length=6, max_length=100, description="Password") class UserUpdate(BaseModel): email: Optional[EmailStr] = None full_name: Optional[str] = Field(None, max_length=100) password: Optional[str] = Field(None, min_length=6, max_length=100) is_active: Optional[bool] = None is_admin: Optional[bool] = None role_id: Optional[int] = None require_password_change: Optional[bool] = None class UserResponse(UserBase): id: int is_active: bool is_admin: bool created_at: datetime last_login: Optional[datetime] = None model_config = { "from_attributes": True } class Token(BaseModel): access_token: str token_type: str = "bearer" class TokenData(BaseModel): username: Optional[str] = None class LoginRequest(BaseModel): username: str = Field(..., description="Username") password: str = Field(..., description="Password") # ==================== Proxmox Server Schemas ==================== class ProxmoxServerBase(BaseModel): name: str = Field(..., min_length=1, max_length=100, description="Server name") hostname: str = Field(..., min_length=1, max_length=255, description="Server hostname/FQDN") ip_address: str = Field(..., min_length=7, max_length=50, description="IP address") port: int = Field(default=8006, ge=1, le=65535, description="Proxmox API port") api_user: str = Field(default="root@pam", max_length=100, description="API username (e.g., root@pam)") api_token_name: Optional[str] = Field(None, max_length=100, description="API token name") api_token_value: Optional[str] = Field(None, max_length=255, description="API token value") use_password: bool = Field(default=False, description="Use password instead of token") password: Optional[str] = Field(None, max_length=255, description="Password (if use_password=True)") verify_ssl: bool = Field(default=False, description="Verify SSL certificate") description: Optional[str] = Field(None, description="Server description") @field_validator('ip_address') @classmethod def validate_ip_address(cls, v): """Validate IP address format""" try: ipaddress.ip_address(v) return v except ValueError: raise ValueError('Invalid IP address format') @field_validator('name') @classmethod def validate_name(cls, v): """Validate server name""" if not v or not v.strip(): raise ValueError('Server name cannot be empty') return v.strip() @field_validator('hostname') @classmethod def validate_hostname(cls, v): """Validate hostname""" if not v or not v.strip(): raise ValueError('Hostname cannot be empty') return v.strip() @model_validator(mode='after') def validate_auth_config(self): """Validate authentication configuration""" if self.use_password: if not self.password: raise ValueError('Password is required when use_password=True') else: if not self.api_token_name or not self.api_token_value: raise ValueError('API token name and value are required when using token auth') return self class ProxmoxServerCreate(BaseModel): """Schema for creating a new Proxmox server""" name: str = Field(..., min_length=1, max_length=100, description="Server name") hostname: str = Field(..., min_length=1, max_length=255, description="Server hostname/FQDN") ip_address: str = Field(..., min_length=7, max_length=50, description="IP address") port: int = Field(default=8006, ge=1, le=65535, description="Proxmox API port") api_user: str = Field(default="root@pam", max_length=100, description="API username (e.g., root@pam)") api_token_name: Optional[str] = Field(None, max_length=100, description="API token name") api_token_value: Optional[str] = Field(None, max_length=255, description="API token value") use_password: bool = Field(default=False, description="Use password instead of token") password: Optional[str] = Field(None, max_length=255, description="Password (if use_password=True)") verify_ssl: bool = Field(default=False, description="Verify SSL certificate") description: Optional[str] = Field(None, description="Server description") @field_validator('ip_address') @classmethod def validate_ip_address(cls, v): """Validate IP address format""" try: ipaddress.ip_address(v) return v except ValueError: raise ValueError('Invalid IP address format') @field_validator('name') @classmethod def validate_name(cls, v): """Validate server name""" if not v or not v.strip(): raise ValueError('Server name cannot be empty') return v.strip() @field_validator('hostname') @classmethod def validate_hostname(cls, v): """Validate hostname""" if not v or not v.strip(): raise ValueError('Hostname cannot be empty') return v.strip() @model_validator(mode='after') def validate_auth_config(self): """Validate authentication configuration""" if self.use_password: if not self.password: raise ValueError('Password is required when use_password=True') else: if not self.api_token_name or not self.api_token_value: raise ValueError('API token name and value are required when using token auth') return self class ProxmoxServerUpdate(BaseModel): """Schema for updating a Proxmox server""" name: Optional[str] = Field(None, min_length=1, max_length=100) hostname: Optional[str] = Field(None, min_length=1, max_length=255) ip_address: Optional[str] = Field(None, min_length=7, max_length=50) port: Optional[int] = Field(None, ge=1, le=65535) api_user: Optional[str] = Field(None, max_length=100) api_token_name: Optional[str] = Field(None, max_length=100) api_token_value: Optional[str] = Field(None, max_length=255) use_password: Optional[bool] = None password: Optional[str] = Field(None, max_length=255) verify_ssl: Optional[bool] = None description: Optional[str] = None class ProxmoxServerResponse(BaseModel): """Schema for Proxmox server response""" id: int name: str hostname: str ip_address: str port: int api_user: str verify_ssl: bool description: Optional[str] = None created_at: datetime updated_at: datetime last_check: Optional[datetime] = None is_online: Optional[bool] = None last_error: Optional[str] = None # Exclude sensitive fields api_token_value: Optional[str] = Field(None, exclude=True) password: Optional[str] = Field(None, exclude=True) model_config = { "from_attributes": True } class ProxmoxVMResponse(BaseModel): """Schema for Proxmox VM/Container response""" vmid: int name: Optional[str] = None status: str type: str # 'qemu' or 'lxc' node: str cpu: Optional[float] = None mem: Optional[int] = None maxmem: Optional[int] = None disk: Optional[int] = None maxdisk: Optional[int] = None uptime: Optional[int] = None # ==================== OS Template Schemas ==================== class OSTemplateGroupBase(BaseModel): """Base schema for OS Template Group""" name: str = Field(..., min_length=1, max_length=100, description="Group name (e.g., Ubuntu, Debian)") icon: Optional[str] = Field(None, max_length=200, description="Icon (emoji, class or HTML)") description: Optional[str] = Field(None, description="Group description") sort_order: int = Field(default=0, description="Sort order") is_active: bool = Field(default=True, description="Is group active") class OSTemplateGroupCreate(OSTemplateGroupBase): """Schema for creating OS Template Group""" pass class OSTemplateGroupUpdate(BaseModel): """Schema for updating OS Template Group""" name: Optional[str] = Field(None, min_length=1, max_length=100) icon: Optional[str] = Field(None, max_length=200) description: Optional[str] = None sort_order: Optional[int] = None is_active: Optional[bool] = None class OSTemplateGroupResponse(OSTemplateGroupBase): """Schema for OS Template Group response""" id: int created_at: datetime updated_at: datetime model_config = { "from_attributes": True } class OSTemplateBase(BaseModel): """Base schema for OS Template""" group_id: int = Field(..., description="Template group ID") server_id: int = Field(..., description="Proxmox server ID") name: str = Field(..., min_length=1, max_length=100, description="Display name") vmid: Optional[int] = Field(None, ge=100, description="Proxmox template VMID (None for vztmpl file templates)") vm_type: str = Field(default="qemu", description="Template type: qemu (KVM) or lxc") volid: Optional[str] = Field(None, max_length=500, description="vztmpl volume id (for LXC file templates)") node: Optional[str] = Field(None, max_length=100, description="Proxmox node name") default_cores: int = Field(default=1, ge=1, le=128, description="Default CPU cores") default_memory: int = Field(default=1024, ge=128, description="Default memory in MB") default_disk: int = Field(default=10, ge=1, description="Default disk size in GB") min_cores: int = Field(default=1, ge=1, description="Minimum CPU cores") min_memory: int = Field(default=512, ge=128, description="Minimum memory in MB") min_disk: int = Field(default=5, ge=1, description="Minimum disk size in GB") description: Optional[str] = Field(None, description="Template description") is_active: bool = Field(default=True, description="Is template active") sort_order: int = Field(default=0, description="Sort order") class OSTemplateCreate(OSTemplateBase): """Schema for creating OS Template""" pass class OSTemplateUpdate(BaseModel): """Schema for updating OS Template""" group_id: Optional[int] = None server_id: Optional[int] = None name: Optional[str] = Field(None, min_length=1, max_length=100) vmid: Optional[int] = Field(None, ge=100) vm_type: Optional[str] = None volid: Optional[str] = Field(None, max_length=500) node: Optional[str] = Field(None, min_length=1, max_length=100) default_cores: Optional[int] = Field(None, ge=1, le=128) default_memory: Optional[int] = Field(None, ge=128) default_disk: Optional[int] = Field(None, ge=1) min_cores: Optional[int] = Field(None, ge=1) min_memory: Optional[int] = Field(None, ge=128) min_disk: Optional[int] = Field(None, ge=1) description: Optional[str] = None is_active: Optional[bool] = None sort_order: Optional[int] = None class OSTemplateResponse(OSTemplateBase): """Schema for OS Template response""" id: int created_at: datetime updated_at: datetime model_config = { "from_attributes": True } class OSTemplateWithGroup(OSTemplateResponse): """Schema for OS Template with group info""" group_name: Optional[str] = None group_icon: Optional[str] = None server_name: Optional[str] = None class VMDeployRequest(BaseModel): """Schema for deploying a new VM from template""" template_id: int = Field(..., description="OS Template ID") name: str = Field(..., min_length=1, max_length=100, description="New VM name") vmid: Optional[int] = Field(None, ge=100, le=999999999, description="Specific VMID to use (optional, for reinstall)") target_node: Optional[str] = Field(None, description="Target node for VM deployment (optional, allows cross-node deployment)") cores: Optional[int] = Field(None, ge=1, le=128, description="CPU cores") memory: Optional[int] = Field(None, ge=128, description="Memory in MB") disk: Optional[int] = Field(None, ge=1, description="Disk size in GB") target_storage: Optional[str] = Field(None, description="Target storage for VM disk (e.g., local-lvm)") start_after_create: bool = Field(default=True, description="Start VM after creation") onboot: bool = Field(default=False, description="Start VM on host boot") # High Availability enable_ha: bool = Field(default=False, description="Enable High Availability for VM (cluster only)") # Network configuration network_bridge: Optional[str] = Field(default="vmbr0", description="Network bridge") ip_address: Optional[str] = Field(None, description="Static IP address") gateway: Optional[str] = Field(None, description="Gateway IP") # IPAM integration ipam_network_id: Optional[int] = Field(None, description="IPAM network ID for auto IP allocation") ipam_pool_id: Optional[int] = Field(None, description="IPAM pool ID (optional)") # Cloud-init (if template supports) cloud_init_user: Optional[str] = Field(None, max_length=50, description="Cloud-init username") cloud_init_password: Optional[str] = Field(None, max_length=100, description="Cloud-init password") ssh_keys: Optional[str] = Field(None, description="SSH public keys") ssh_key_ids: Optional[List[int]] = Field(default=None, description="IDs of saved SSH keys to inject") owner_id: Optional[int] = Field(default=None, description="Admin-only: assign instance to this user (also enables using their saved SSH keys)") class VMDeployResponse(BaseModel): """Schema for VM deploy response""" success: bool vmid: int name: str node: str server_id: int task_upid: Optional[str] = None message: str # ==================== IPAM Schemas ==================== class IPAMNetworkBase(BaseModel): """Base schema for IPAM Network""" name: str = Field(..., min_length=1, max_length=100, description="Network name") description: Optional[str] = Field(None, description="Network description") network: str = Field(..., description="Network CIDR (e.g., 10.10.10.0/24)") gateway: Optional[str] = Field(None, description="Gateway IP") vlan_id: Optional[int] = Field(None, ge=1, le=4094, description="VLAN ID") dns_primary: Optional[str] = Field(None, description="Primary DNS server") dns_secondary: Optional[str] = Field(None, description="Secondary DNS server") dns_domain: Optional[str] = Field(None, max_length=255, description="DNS domain") proxmox_server_id: Optional[int] = Field(None, description="Associated Proxmox server ID") proxmox_node: Optional[str] = Field(None, max_length=100, description="Proxmox node name (e.g., pve1)") proxmox_bridge: Optional[str] = Field(None, max_length=20, description="Proxmox bridge (e.g., vmbr0)") is_active: bool = Field(default=True, description="Is network active") @field_validator('network') @classmethod def validate_network_cidr(cls, v): """Validate network CIDR format""" try: ipaddress.ip_network(v, strict=False) return v except ValueError: raise ValueError('Invalid network CIDR format (e.g., 10.10.10.0/24)') @field_validator('gateway', 'dns_primary', 'dns_secondary') @classmethod def validate_ip(cls, v): """Validate IP address format""" if v is None: return v try: ipaddress.ip_address(v) return v except ValueError: raise ValueError('Invalid IP address format') class IPAMNetworkCreate(IPAMNetworkBase): """Schema for creating IPAM Network""" pass class IPAMNetworkUpdate(BaseModel): """Schema for updating IPAM Network""" name: Optional[str] = Field(None, min_length=1, max_length=100) description: Optional[str] = None gateway: Optional[str] = None vlan_id: Optional[int] = Field(None, ge=1, le=4094) dns_primary: Optional[str] = None dns_secondary: Optional[str] = None dns_domain: Optional[str] = Field(None, max_length=255) proxmox_server_id: Optional[int] = None proxmox_node: Optional[str] = Field(None, max_length=100) proxmox_bridge: Optional[str] = Field(None, max_length=20) is_active: Optional[bool] = None class IPAMNetworkResponse(IPAMNetworkBase): """Schema for IPAM Network response""" id: int created_at: datetime updated_at: datetime # Computed fields (will be added in API) total_ips: Optional[int] = None used_ips: Optional[int] = None available_ips: Optional[int] = None utilization_percent: Optional[float] = None server_name: Optional[str] = None model_config = { "from_attributes": True } class IPAMPoolBase(BaseModel): """Base schema for IPAM Pool""" network_id: int = Field(..., description="Parent network ID") name: str = Field(..., min_length=1, max_length=100, description="Pool name") pool_type: str = Field(default="static", description="Pool type: static, dhcp, reserved") range_start: str = Field(..., description="Range start IP") range_end: str = Field(..., description="Range end IP") auto_assign: bool = Field(default=True, description="Allow auto-assignment from this pool") description: Optional[str] = Field(None, description="Pool description") is_active: bool = Field(default=True, description="Is pool active") @field_validator('pool_type') @classmethod def validate_pool_type(cls, v): """Validate pool type""" allowed = ['static', 'dhcp', 'reserved'] if v not in allowed: raise ValueError(f'Pool type must be one of: {", ".join(allowed)}') return v @field_validator('range_start', 'range_end') @classmethod def validate_range_ip(cls, v): """Validate IP address format""" try: ipaddress.ip_address(v) return v except ValueError: raise ValueError('Invalid IP address format') class IPAMPoolCreate(IPAMPoolBase): """Schema for creating IPAM Pool""" pass class IPAMPoolUpdate(BaseModel): """Schema for updating IPAM Pool""" name: Optional[str] = Field(None, min_length=1, max_length=100) pool_type: Optional[str] = None range_start: Optional[str] = None range_end: Optional[str] = None auto_assign: Optional[bool] = None description: Optional[str] = None is_active: Optional[bool] = None class IPAMPoolResponse(IPAMPoolBase): """Schema for IPAM Pool response""" id: int created_at: datetime updated_at: datetime # Computed fields total_ips: Optional[int] = None used_ips: Optional[int] = None available_ips: Optional[int] = None model_config = { "from_attributes": True } class IPAMAllocationBase(BaseModel): """Base schema for IPAM Allocation""" network_id: int = Field(..., description="Network ID") pool_id: Optional[int] = Field(None, description="Pool ID (optional)") ip_address: str = Field(..., description="IP address") mac_address: Optional[str] = Field(None, max_length=17, description="MAC address") resource_type: Optional[str] = Field(None, description="Resource type: vm, lxc, physical, service, reserved") resource_id: Optional[int] = Field(None, description="Resource ID (e.g., VMID)") resource_name: Optional[str] = Field(None, max_length=100, description="Resource name") proxmox_server_id: Optional[int] = Field(None, description="Proxmox server ID") proxmox_vmid: Optional[int] = Field(None, description="Proxmox VMID") proxmox_node: Optional[str] = Field(None, max_length=100, description="Proxmox node") status: str = Field(default="allocated", description="Status: allocated, reserved, available, conflict") allocation_type: str = Field(default="static", description="Type: static, dhcp, floating") hostname: Optional[str] = Field(None, max_length=255, description="Hostname") fqdn: Optional[str] = Field(None, max_length=255, description="Fully qualified domain name") dns_ptr_record: bool = Field(default=False, description="Create PTR record") expires_at: Optional[datetime] = Field(None, description="Expiration date (for temp allocations)") notes: Optional[str] = Field(None, description="Notes") @field_validator('ip_address') @classmethod def validate_ip_address(cls, v): """Validate IP address format""" try: ipaddress.ip_address(v) return v except ValueError: raise ValueError('Invalid IP address format') @field_validator('mac_address') @classmethod def validate_mac_address(cls, v): """Validate MAC address format""" if v is None: return v import re mac_pattern = re.compile(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$') if not mac_pattern.match(v): raise ValueError('Invalid MAC address format (e.g., AA:BB:CC:DD:EE:FF)') return v.upper() @field_validator('status') @classmethod def validate_status(cls, v): """Validate status""" allowed = ['allocated', 'reserved', 'available', 'conflict'] if v not in allowed: raise ValueError(f'Status must be one of: {", ".join(allowed)}') return v class IPAMAllocationCreate(IPAMAllocationBase): """Schema for creating IPAM Allocation""" allocated_by: Optional[str] = Field(None, max_length=100, description="Allocated by username") class IPAMAllocationUpdate(BaseModel): """Schema for updating IPAM Allocation""" pool_id: Optional[int] = None mac_address: Optional[str] = Field(None, max_length=17) resource_type: Optional[str] = None resource_id: Optional[int] = None resource_name: Optional[str] = Field(None, max_length=100) proxmox_server_id: Optional[int] = None proxmox_vmid: Optional[int] = None proxmox_node: Optional[str] = Field(None, max_length=100) status: Optional[str] = None allocation_type: Optional[str] = None hostname: Optional[str] = Field(None, max_length=255) fqdn: Optional[str] = Field(None, max_length=255) dns_ptr_record: Optional[bool] = None expires_at: Optional[datetime] = None notes: Optional[str] = None class IPAMAllocationResponse(IPAMAllocationBase): """Schema for IPAM Allocation response""" id: int allocated_by: Optional[str] = None allocated_at: datetime last_seen: Optional[datetime] = None created_at: datetime updated_at: datetime # Extended info network_name: Optional[str] = None pool_name: Optional[str] = None model_config = { "from_attributes": True } class IPAMHistoryResponse(BaseModel): """Schema for IPAM History response""" id: int ip_address: str network_id: Optional[int] = None action: str old_value: Optional[dict] = None new_value: Optional[dict] = None resource_type: Optional[str] = None resource_id: Optional[int] = None resource_name: Optional[str] = None performed_by: Optional[str] = None performed_at: datetime notes: Optional[str] = None model_config = { "from_attributes": True } class IPAMAutoAllocateRequest(BaseModel): """Schema for auto-allocating an IP from a network/pool""" network_id: int = Field(..., description="Network ID to allocate from") pool_id: Optional[int] = Field(None, description="Specific pool ID (optional)") resource_type: Optional[str] = Field(None, description="Resource type") resource_id: Optional[int] = Field(None, description="Resource ID") resource_name: Optional[str] = Field(None, max_length=100, description="Resource name") hostname: Optional[str] = Field(None, max_length=255, description="Hostname") notes: Optional[str] = Field(None, description="Notes") class IPAMScanRequest(BaseModel): """Schema for network scan request""" network_id: int = Field(..., description="Network ID to scan") scan_type: str = Field(default="ping", description="Scan type: ping, arp, full") update_last_seen: bool = Field(default=True, description="Update last_seen for found IPs") detect_new: bool = Field(default=True, description="Detect and add new allocations") class IPAMSyncRequest(BaseModel): """Schema for Proxmox sync request""" proxmox_server_id: int = Field(..., description="Proxmox server ID to sync from") network_id: Optional[int] = Field(None, description="Target network ID (optional)") create_allocations: bool = Field(default=True, description="Create allocations for found VMs") update_existing: bool = Field(default=True, description="Update existing allocations") class IPAMNetworkStats(BaseModel): """Schema for network statistics""" network_id: int network_name: str network_cidr: str total_ips: int allocated_ips: int reserved_ips: int available_ips: int utilization_percent: float pools_count: int vms_count: int lxc_count: int physical_count: int other_count: int """ Pydantic schemas for notifications """ from typing import Optional, List, Any from datetime import datetime from pydantic import BaseModel, Field class NotificationCreate(BaseModel): """Schema for creating a notification""" user_id: int type: str = Field(..., description="Type: vm_status, resource_alert, system, ipam, template") level: str = Field(..., description="Level: critical, warning, info, success") title: str = Field(..., max_length=255) message: Optional[str] = None data: Optional[dict] = None link: Optional[str] = None source: Optional[str] = Field(None, description="Source: proxmox, ipam, system, docker") source_id: Optional[str] = None expires_at: Optional[datetime] = None class NotificationUpdate(BaseModel): """Schema for updating a notification""" read: Optional[bool] = None class NotificationResponse(BaseModel): """Schema for notification response""" id: int user_id: int type: str level: str title: str message: Optional[str] data: Optional[dict] link: Optional[str] source: Optional[str] source_id: Optional[str] read: bool read_at: Optional[datetime] created_at: datetime expires_at: Optional[datetime] class Config: from_attributes = True class NotificationListResponse(BaseModel): """Schema for notification list response""" total: int unread_count: int notifications: List[NotificationResponse] class NotificationPreferenceUpdate(BaseModel): """Schema for updating notification preferences""" enabled: Optional[bool] = None email_enabled: Optional[bool] = None email_critical_only: Optional[bool] = None telegram_enabled: Optional[bool] = None telegram_chat_id: Optional[str] = None webhook_url: Optional[str] = None notification_levels: Optional[List[str]] = None notification_types: Optional[List[str]] = None quiet_hours_start: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$") quiet_hours_end: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$") class NotificationPreferenceResponse(BaseModel): """Schema for notification preference response""" id: int user_id: int enabled: bool email_enabled: bool email_critical_only: bool telegram_enabled: bool telegram_chat_id: Optional[str] webhook_url: Optional[str] notification_levels: List[str] notification_types: List[str] quiet_hours_start: Optional[str] quiet_hours_end: Optional[str] created_at: datetime updated_at: datetime class Config: from_attributes = True # Notification schemas added above # ==================== Workspace Schemas ==================== class WorkspaceCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100, description="Workspace name") description: Optional[str] = Field(None, max_length=500) color: Optional[str] = Field("#667eea", max_length=20, description="Accent color (CSS hex)") server_ids: Optional[List[int]] = Field(default_factory=list, description="Initial server IDs") user_ids: Optional[List[int]] = Field(default_factory=list, description="Initial user IDs with access") class WorkspaceUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) description: Optional[str] = Field(None, max_length=500) color: Optional[str] = Field(None, max_length=20) class WorkspaceServerItem(BaseModel): id: int name: str ip_address: str is_online: bool cluster_name: Optional[str] = None model_config = {"from_attributes": True} class WorkspaceUserItem(BaseModel): id: int username: str email: str full_name: Optional[str] = None model_config = {"from_attributes": True} class WorkspaceResponse(BaseModel): id: int name: str description: Optional[str] = None color: Optional[str] = None is_default: bool server_count: int = 0 user_count: int = 0 created_at: datetime model_config = {"from_attributes": True} class WorkspaceDetail(WorkspaceResponse): servers: List[WorkspaceServerItem] = [] users: List[WorkspaceUserItem] = []