"""Pydantic schemas for Device, DeviceGroup, and DeviceTag endpoints.""" import uuid from datetime import datetime from typing import Optional from pydantic import BaseModel, field_validator # --------------------------------------------------------------------------- # Device schemas # --------------------------------------------------------------------------- class DeviceCreate(BaseModel): """Schema for creating a new device.""" hostname: str ip_address: str api_port: int = 8728 api_ssl_port: int = 8729 username: str password: str class DeviceUpdate(BaseModel): """Schema for updating an existing device. All fields optional.""" hostname: Optional[str] = None ip_address: Optional[str] = None api_port: Optional[int] = None api_ssl_port: Optional[int] = None username: Optional[str] = None password: Optional[str] = None latitude: Optional[float] = None longitude: Optional[float] = None tls_mode: Optional[str] = None @field_validator("tls_mode") @classmethod def validate_tls_mode(cls, v: Optional[str]) -> Optional[str]: """Validate tls_mode is one of the allowed values.""" if v is None: return v allowed = {"auto", "insecure", "plain", "portal_ca"} if v not in allowed: raise ValueError(f"tls_mode must be one of: {', '.join(sorted(allowed))}") return v class DeviceTagRef(BaseModel): """Minimal tag info embedded in device responses.""" id: uuid.UUID name: str color: Optional[str] = None model_config = {"from_attributes": True} class DeviceGroupRef(BaseModel): """Minimal group info embedded in device responses.""" id: uuid.UUID name: str model_config = {"from_attributes": True} class DeviceResponse(BaseModel): """Device response schema. NEVER includes credential fields.""" id: uuid.UUID hostname: str ip_address: str api_port: int api_ssl_port: int model: Optional[str] = None serial_number: Optional[str] = None firmware_version: Optional[str] = None routeros_version: Optional[str] = None routeros_major_version: Optional[int] = None uptime_seconds: Optional[int] = None last_seen: Optional[datetime] = None latitude: Optional[float] = None longitude: Optional[float] = None status: str tls_mode: str = "auto" tags: list[DeviceTagRef] = [] groups: list[DeviceGroupRef] = [] created_at: datetime model_config = {"from_attributes": True} class DeviceListResponse(BaseModel): """Paginated device list response.""" items: list[DeviceResponse] total: int page: int page_size: int # --------------------------------------------------------------------------- # Subnet scan schemas # --------------------------------------------------------------------------- class SubnetScanRequest(BaseModel): """Request body for a subnet scan.""" cidr: str @field_validator("cidr") @classmethod def validate_cidr(cls, v: str) -> str: """Validate that the value is a valid CIDR notation and RFC 1918 private range.""" import ipaddress try: network = ipaddress.ip_network(v, strict=False) except ValueError as e: raise ValueError(f"Invalid CIDR notation: {e}") from e # Only allow private IP ranges (RFC 1918: 10/8, 172.16/12, 192.168/16) if not network.is_private: raise ValueError( "Only private IP ranges can be scanned (RFC 1918: " "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)" ) # Reject ranges larger than /20 (4096 IPs) to prevent abuse if network.num_addresses > 4096: raise ValueError( f"CIDR range too large ({network.num_addresses} addresses). " "Maximum allowed: /20 (4096 addresses)." ) return v class SubnetScanResult(BaseModel): """A single discovered host from a subnet scan.""" ip_address: str hostname: Optional[str] = None api_port_open: bool = False api_ssl_port_open: bool = False class SubnetScanResponse(BaseModel): """Response for a subnet scan operation.""" cidr: str discovered: list[SubnetScanResult] total_scanned: int total_discovered: int # --------------------------------------------------------------------------- # Bulk add from scan # --------------------------------------------------------------------------- class BulkDeviceAdd(BaseModel): """One device entry within a bulk-add request.""" ip_address: str hostname: Optional[str] = None api_port: int = 8728 api_ssl_port: int = 8729 username: Optional[str] = None password: Optional[str] = None class BulkAddRequest(BaseModel): """ Bulk-add devices selected from a scan result. shared_username / shared_password are used for all devices that do not provide their own credentials. """ devices: list[BulkDeviceAdd] shared_username: Optional[str] = None shared_password: Optional[str] = None class BulkAddResult(BaseModel): """Summary result of a bulk-add operation.""" added: list[DeviceResponse] failed: list[dict] # {ip_address, error} # --------------------------------------------------------------------------- # DeviceGroup schemas # --------------------------------------------------------------------------- class DeviceGroupCreate(BaseModel): """Schema for creating a device group.""" name: str description: Optional[str] = None class DeviceGroupUpdate(BaseModel): """Schema for updating a device group.""" name: Optional[str] = None description: Optional[str] = None class DeviceGroupResponse(BaseModel): """Device group response schema.""" id: uuid.UUID name: str description: Optional[str] = None device_count: int = 0 created_at: datetime model_config = {"from_attributes": True} # --------------------------------------------------------------------------- # DeviceTag schemas # --------------------------------------------------------------------------- class DeviceTagCreate(BaseModel): """Schema for creating a device tag.""" name: str color: Optional[str] = None @field_validator("color") @classmethod def validate_color(cls, v: Optional[str]) -> Optional[str]: """Validate hex color format if provided.""" if v is None: return v import re if not re.match(r"^#[0-9A-Fa-f]{6}$", v): raise ValueError("Color must be a valid 6-digit hex color (e.g. #FF5733)") return v class DeviceTagUpdate(BaseModel): """Schema for updating a device tag.""" name: Optional[str] = None color: Optional[str] = None @field_validator("color") @classmethod def validate_color(cls, v: Optional[str]) -> Optional[str]: if v is None: return v import re if not re.match(r"^#[0-9A-Fa-f]{6}$", v): raise ValueError("Color must be a valid 6-digit hex color (e.g. #FF5733)") return v class DeviceTagResponse(BaseModel): """Device tag response schema.""" id: uuid.UUID name: str color: Optional[str] = None model_config = {"from_attributes": True}