Files
ipam_control/app/schemas.py
T

385 lines
10 KiB
Python
Raw Normal View History

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