Files
ayurishchevandClaude Opus 5.5 d1a2efd683 Задача 042: свёртка свободных адресов в раскрывающиеся диапазоны
В списке адресов префикса подряд идущие свободные адреса сворачиваются в строку
«начало – конец · Свободно: N» (параметр group_free, группировка на сервере — пагинация
по строкам после свёртки); раскрытие — существующим status=free&offset=free_offset
порциями по 500. Без group_free ответ API прежний.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:40:22 +03:00

450 lines
14 KiB
Python

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, DeviceStatus, 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 = ""
status: DeviceStatus = DeviceStatus.active # изменение 039
_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
status: DeviceStatus | None = None # изменение 039
_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
status: DeviceStatus = DeviceStatus.active # изменение 039
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 | free_range (изменение 042: свёрнутый диапазон свободных адресов)
dns_name: str = ""
description: str = ""
device_id: int | None = None
device_name: str | None = None
device_type_name: str | None = None # изменение 039
device_status: str | None = None # изменение 039
note: str = ""
updated_at: datetime | None = None
# изменение 042: заполняются только для status=free_range; address/range_end — первый/последний адрес диапазона
range_end: str | None = None
range_count: int | None = None
free_offset: int | None = None # число свободных адресов подсети до начала диапазона (offset для GET .../addresses?status=free)
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