import ipaddress import re from datetime import datetime from typing import Annotated, Generic, TypeVar from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator from app.models import AddressStatus, PrefixStatus, Role T = TypeVar("T") def _cidr(v: str) -> str: try: return str(ipaddress.ip_network(v.strip(), strict=True)) except ValueError: raise ValueError("Некорректный CIDR (пример: 10.30.0.0/24, биты хоста должны быть нулевыми)") def _ip(v: str) -> str: try: return str(ipaddress.ip_address(v.strip())) except ValueError: raise ValueError("Некорректный IP-адрес") Cidr = Annotated[str, AfterValidator(_cidr)] IpAddr = Annotated[str, AfterValidator(_ip)] _FQDN = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?)(\.[A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?)*$") _MAC = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$") def _device_name(v: str | None) -> str | None: """Имя устройства (hostname/FQDN); общая для DeviceIn и DeviceUpdate (изменение 024, находка №6). None — поле не задано в PATCH.""" if v is not None and not _FQDN.match(v): raise ValueError("Некорректное имя устройства (hostname)") return v def _device_mac(v: str | None) -> str | None: """MAC: формат и нормализация к AA:BB:CC:DD:EE:FF; общая для DeviceIn и DeviceUpdate. Пустая строка допустима (mac не указан).""" if v is None: return v if v and not _MAC.match(v): raise ValueError("Некорректный MAC-адрес (AA:BB:CC:DD:EE:FF)") return v.upper().replace("-", ":") class Page(BaseModel, Generic[T]): items: list[T] total: int class ORM(BaseModel): model_config = ConfigDict(from_attributes=True) # --- auth class LoginIn(BaseModel): username: str = Field(max_length=100) password: str = Field(max_length=128) class TokenOut(BaseModel): access_token: str token_type: str = "bearer" LOGIN_RE = re.compile(r"^[A-Za-z0-9._-]{3,100}$") RESERVED_LOGINS = {"system", "anonymous"} # заняты служебными акторами журнала (actor_of в app/services.py) def _login(v: str) -> str: v = v.strip() if not LOGIN_RE.match(v): raise ValueError("Логин: 3–100 символов — латиница, цифры, точка, дефис или подчёркивание") if v.lower() in RESERVED_LOGINS: raise ValueError("Логин зарезервирован системой") return v Login = Annotated[str, AfterValidator(_login)] class UserOut(ORM): id: int username: str role: Role is_active: bool = True class UserIn(BaseModel): username: Login password: str = Field(min_length=8, max_length=128) role: Role = Role.viewer # наименьшие права по умолчанию (изменение 016) is_active: bool = True class UserUpdate(BaseModel): role: Role | None = None is_active: bool | None = None password: str | None = Field(None, min_length=8, max_length=128) class PasswordChange(BaseModel): current_password: str new_password: str = Field(min_length=8, max_length=128) @field_validator("new_password") @classmethod def _differs(cls, v: str, info) -> str: if v == info.data.get("current_password"): raise ValueError("Новый пароль совпадает с текущим") return v # --- organizations class OrgIn(BaseModel): name: str = Field(min_length=1, max_length=255) short_name: str = Field("", max_length=100) inn: str = Field(pattern=r"^(\d{10}|\d{12})$") address: str = Field("", max_length=500) contact_person: str = "" phone: str = "" email: EmailStr | str = "" note: str = "" @field_validator("email") @classmethod def _email(cls, v): return str(v) class OrgOut(ORM, OrgIn): id: int prefixes_count: int = 0 addresses_count: int = 0 # --- vrf class VrfIn(BaseModel): organization_id: int name: str = Field(min_length=1, max_length=100) route_target: str = Field("", max_length=50, pattern=r"^(\d+:\d+)?$") note: str = "" def _blank(v): """null в PATCH текстового поля = «очистить» (в колонке NOT NULL хранится пустая строка).""" return "" if v is None else v class VrfUpdate(BaseModel): name: str | None = Field(None, min_length=1, max_length=100) route_target: str | None = Field(None, max_length=50, pattern=r"^(\d+:\d+)?$") note: str | None = None _blank_text = field_validator("route_target", "note")(_blank) class VrfOut(ORM): id: int organization_id: int name: str route_target: str note: str prefixes_count: int = 0 # --- device types / devices class DeviceTypeIn(BaseModel): name: str = Field(min_length=1, max_length=100) class DeviceTypeOut(ORM): id: int name: str is_default: bool devices_count: int = 0 class DeviceIn(BaseModel): name: str = Field(min_length=1, max_length=255) device_type_id: int organization_id: int mac: str = "" note: str = "" _name_valid = field_validator("name")(_device_name) _mac_valid = field_validator("mac")(_device_mac) class DeviceUpdate(BaseModel): name: str | None = None device_type_id: int | None = None mac: str | None = None note: str | None = None _blank_text = field_validator("mac", "note")(_blank) # сначала null → "", затем проверка формата _name_valid = field_validator("name")(_device_name) _mac_valid = field_validator("mac")(_device_mac) class DeviceOut(ORM): id: int name: str device_type_id: int device_type_name: str organization_id: int mac: str note: str ip_addresses: list[str] = [] first_prefix_id: int | None = None all_deprecated: bool = False # --- isp class IspIn(BaseModel): name: str = Field(min_length=1, max_length=255) organization_id: int networks: list[Cidr] = [] hotline: str = Field("", max_length=50) contract_number: str = Field("", max_length=100) note: str = "" class IspOut(ORM): id: int name: str organization_id: int organization_name: str networks: list[str] hotline: str contract_number: str note: str # --- prefixes class PrefixIn(BaseModel): organization_id: int vrf_id: int prefix: Cidr description: str = Field("", max_length=500) status: PrefixStatus = PrefixStatus.active parent_id: int | None = None is_pool: bool = False note: str = "" class SubnetNextIn(BaseModel): length: int = Field(ge=1, le=128) description: str = Field("", max_length=500) status: PrefixStatus = PrefixStatus.active is_pool: bool = False note: str = "" class SubnetPreview(BaseModel): prefix: str | None length_min: int length_max: int class PrefixUpdate(BaseModel): vrf_id: int | None = None description: str | None = Field(None, max_length=500) status: PrefixStatus | None = None is_pool: bool | None = None note: str | None = None _blank_text = field_validator("description", "note")(_blank) class PrefixOut(ORM): id: int organization_id: int vrf_id: int vrf_name: str prefix: str family: int description: str status: PrefixStatus parent_id: int | None depth: int is_pool: bool note: str used: int capacity: int utilization: int addresses_count: int # --- addresses class AddressIn(BaseModel): address: IpAddr status: AddressStatus = AddressStatus.assigned dns_name: str = "" description: str = Field("", max_length=500) device_id: int | None = None note: str = "" @field_validator("dns_name") @classmethod def _dns(cls, v): if v and not _FQDN.match(v): raise ValueError("Некорректное DNS-имя (FQDN)") return v class AddressUpdate(BaseModel): status: AddressStatus | None = None dns_name: str | None = None description: str | None = Field(None, max_length=500) device_id: int | None = None # null — отвязать устройство note: str | None = None _blank_text = field_validator("dns_name", "description", "note")(_blank) @field_validator("status") @classmethod def _status_required(cls, v): if v is None: raise ValueError("Статус нельзя очистить") return v class AddressOut(BaseModel): id: int | None prefix_id: int address: str status: str # assigned | reserved | deprecated | free dns_name: str = "" description: str = "" device_id: int | None = None device_name: str | None = None note: str = "" updated_at: datetime | None = None class AddressSummary(BaseModel): assigned: int reserved: int deprecated: int free: int capacity: int class AddressPage(Page[AddressOut]): summary: AddressSummary class AuditOut(ORM): id: int uid: str short_id: str ts: datetime username: str actor: str entity_type: str entity_id: int | None entity_label: str action: str event_type: str message: str diff: dict | None client_ip: str | None = None meta: dict | None = None @classmethod def from_row(cls, r) -> "AuditOut": return cls( id=r.id, uid=str(r.uid), short_id=str(r.uid)[:8], ts=r.ts, username=r.username, actor=r.username if r.username in ("system", "anonymous") else f"ui:{r.username}", entity_type=r.entity_type, entity_id=r.entity_id, entity_label=r.entity_label, action=r.action, event_type=f"{r.entity_type}.{r.action}", message=r.message, diff=r.diff, client_ip=ip_text(r.client_ip) if r.client_ip is not None else None, meta=r.meta, ) def ip_text(v) -> str: """psycopg отдаёт inet/cidr объектами ipaddress; для хоста убираем /32 и /128.""" text = str(v) if text.endswith("/32") or text.endswith("/128"): return text.rsplit("/", 1)[0] return text