006 Пользователи: API /users (CRUD, смена своего пароля), раздел UI «Пользователи»,
события журнала user.*, защита от отключения/удаления себя и последнего админа.
007 Исправление удаления организации: VRF удаляются явным DELETE до организации
(без relationship() порядок DELETE не гарантирован → ложный 409).
008 Журнал фиксирует отказы в удалении (<entity>.delete_blocked) со списком
мешающих объектов в «Данных»: организация, VRF, тип устройства, префикс, пользователь.
009 Выбор строк чекбоксами и групповые операции в UI (удаление, смена типа устройств,
статус префиксов и адресов, доступ пользователей); цикл запросов из UI, итог и список отказов.
010 Автовыделение следующего вложенного префикса: POST/GET /prefixes/{id}/subnets/next,
первый свободный выровненный блок; пункт «Добавить вложенный (авто)» в меню префикса.
Тесты: 14 (добавлены сценарии для 006, 007/008, 010); исправлена нестабильность
тестов журнала (IPv6-группы с ведущими нулями нормализуются PostgreSQL).
Документация: README.md, docs/changes/006-010 (планы и итоги).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
359 lines
8.7 KiB
Python
359 lines
8.7 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
|
|
|
|
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}$")
|
|
|
|
|
|
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
|
|
password: str
|
|
|
|
|
|
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.admin
|
|
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 = ""
|
|
|
|
|
|
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
|
|
|
|
|
|
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 = ""
|
|
|
|
@field_validator("name")
|
|
@classmethod
|
|
def _name(cls, v):
|
|
if not _FQDN.match(v):
|
|
raise ValueError("Некорректное имя устройства (hostname)")
|
|
return v
|
|
|
|
@field_validator("mac")
|
|
@classmethod
|
|
def _mac(cls, v):
|
|
if v and not _MAC.match(v):
|
|
raise ValueError("Некорректный MAC-адрес (AA:BB:CC:DD:EE:FF)")
|
|
return v.upper().replace("-", ":")
|
|
|
|
|
|
class DeviceUpdate(BaseModel):
|
|
name: str | None = None
|
|
device_type_id: int | None = None
|
|
mac: str | None = None
|
|
note: str | None = None
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
note: str | None = None
|
|
|
|
|
|
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
|