Files
ipam_control/app/schemas.py
T

440 lines
13 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, model_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):
# изменение 037: при включённой 2FA access_token не выдаётся сразу — вместо него mfa_token на второй шаг (/auth/login/2fa)
access_token: str | None = None
token_type: str = "bearer"
mfa_required: bool = False
mfa_token: str | None = None
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
organization_id: int | None = None # изменение 032
is_active: bool = True
last_login_at: datetime | None = None # изменение 035
last_login_ip: str | None = None # изменение 035
totp_enabled: bool = False # изменение 037: вычисляемое — User.totp_enabled (property), totp_enabled_at is not None
totp_enabled_at: datetime | None = None # изменение 037: для статуса «включена с <дата>» в UI
@field_validator("last_login_ip", mode="before")
@classmethod
def _last_login_ip_text(cls, v):
return ip_text(v) if v is not None else None
class UserIn(BaseModel):
username: Login
password: str = Field(min_length=8, max_length=128)
role: Role = Role.viewer # наименьшие права по умолчанию (изменение 016)
organization_id: int | None = None # изменение 032: обязателен для admin/viewer, запрещён для superadmin
is_active: bool = True
@model_validator(mode="after")
def _org_by_role(self) -> "UserIn":
# model_validator (изменение 033, находка №2): срабатывает и когда organization_id не передан явно
if self.role == Role.superadmin and self.organization_id is not None:
raise ValueError("Суперадминистратор не привязан к организации")
if self.role != Role.superadmin and self.organization_id is None:
raise ValueError("Администратор и просмотрщик должны быть привязаны к организации")
return self
class UserUpdate(BaseModel):
role: Role | None = None
organization_id: int | None = None # изменение 032
is_active: bool | None = None
password: str | None = Field(None, min_length=8, max_length=128)
# без валидатора роль/организация (изменение 033, находка №2): без текущего состояния записи
# инвариант не проверить — реконсиляция по итоговому состоянию в update_user (находка №3)
# --- 2FA (изменение 037)
class MfaLoginIn(BaseModel):
mfa_token: str
code: str = Field(max_length=20) # 6 цифр TOTP либо код восстановления вида XXXXX-XXXXX
class TotpSetupIn(BaseModel):
password: str = Field(max_length=128)
class TotpSetupOut(BaseModel):
secret: str
otpauth_uri: str
qr: str # data:image/svg+xml;base64,…
class TotpCodeIn(BaseModel):
code: str = Field(max_length=20)
class TotpDisableIn(BaseModel):
password: str = Field(max_length=128)
code: str = Field(max_length=20)
class RecoveryCodesOut(BaseModel):
recovery_codes: list[str]
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