Статус устройства (Активен / Выключен / На обслуживании), назначается вручную на экране устройств: колонка «Статус», выбор в диалоге, смена — в журнал. Миграция 0016, существующие устройства — «Активен». В списке адресов префикса колонки «Тип устройства», «Устройство», «Статус устройства» (join без N+1). pytest не прогнан: учётная запись admin из .env отключена на стенде. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
446 lines
13 KiB
Python
446 lines
13 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
|
|
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
|
|
|
|
|
|
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
|