Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16): - организации, VRF, префиксы (дерево, использование, автоназначение), адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer; - VRF принадлежит организации (составной FK), смена VRF у префикса переносит поддерево, имя VRF уникально в организации; - журнал аудита: поиск и фильтры, ротация (срок/количество), очистка по паролю с блокировкой, IP клиента и метаданные запроса (X-Forwarded-For только от TRUSTED_PROXIES). UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager», кликабельные строки реестров, локальные шрифты IBM Plex, собственные выпадающие списки. Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004, scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest). Документация: README.md и docs/changes/001-005 (планы и итоги). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
108 lines
4.1 KiB
Python
108 lines
4.1 KiB
Python
import ipaddress
|
||
from types import SimpleNamespace
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.request_context import request_meta
|
||
from app.models import Address, AuditLog, Base
|
||
|
||
MAX_CAPACITY = 2**53 - 1
|
||
|
||
|
||
SYSTEM = SimpleNamespace(username="system")
|
||
ANONYMOUS = SimpleNamespace(username="anonymous")
|
||
_NOUNS = {
|
||
"organization": ("Организация", "f"), "vrf": ("VRF", "m"), "prefix": ("Префикс", "m"), "address": ("Адрес", "m"),
|
||
"device": ("Устройство", "n"), "device_type": ("Тип устройства", "m"), "isp": ("Оператор", "m"),
|
||
}
|
||
_VERBS = {
|
||
"created": ("создан", "создана", "создано"), "updated": ("изменён", "изменена", "изменено"),
|
||
"deleted": ("удалён", "удалена", "удалено"), "assigned": ("назначен", "назначена", "назначено"),
|
||
}
|
||
|
||
|
||
def make_message(entity_type: str, action: str, label: str, diff: dict | None = None) -> str:
|
||
noun, gender = _NOUNS.get(entity_type, (entity_type, "m"))
|
||
verb = _VERBS.get(action, (action,) * 3)["mfn".index(gender)]
|
||
text = f"{noun} {label} {verb}"
|
||
if action == "updated" and diff:
|
||
text += ": " + ", ".join(diff)
|
||
return text
|
||
|
||
|
||
def actor_of(username: str) -> str:
|
||
"""Актор для журнала: служебные — как есть, пользователи UI — с префиксом ui:."""
|
||
return username if username in ("system", "anonymous") else f"ui:{username}"
|
||
|
||
|
||
def audit(db: Session, user, entity_type: str, entity, action: str, label: str, diff: dict | None = None, message: str | None = None):
|
||
ctx = None if user is SYSTEM else request_meta.get() # системные события (ротация) — без IP, даже если запущены из запроса
|
||
db.add(AuditLog(
|
||
username=user.username, entity_type=entity_type, entity_id=getattr(entity, "id", None),
|
||
entity_label=label, action=action, diff=diff,
|
||
message=message or make_message(entity_type, action, label, diff),
|
||
client_ip=ctx["client_ip"] if ctx else None, meta=ctx["meta"] if ctx else None,
|
||
))
|
||
|
||
|
||
def commit(db: Session, conflict_msg: str = "Запись с такими значениями уже существует"):
|
||
try:
|
||
db.commit()
|
||
except IntegrityError:
|
||
db.rollback()
|
||
raise HTTPException(409, conflict_msg)
|
||
|
||
|
||
def flush(db: Session, conflict_msg: str = "Запись с такими значениями уже существует"):
|
||
"""flush с тем же переводом нарушений уникальности в 409, что и commit."""
|
||
try:
|
||
db.flush()
|
||
except IntegrityError:
|
||
db.rollback()
|
||
raise HTTPException(409, conflict_msg)
|
||
|
||
|
||
def get_or_404(db: Session, model: type[Base], id_: int, what: str = "Объект"):
|
||
obj = db.get(model, id_)
|
||
if obj is None:
|
||
raise HTTPException(404, f"{what} не найден")
|
||
return obj
|
||
|
||
|
||
def capacity(prefix: str) -> int:
|
||
net = ipaddress.ip_network(prefix)
|
||
n = net.num_addresses
|
||
if net.version == 4 and net.prefixlen <= 30:
|
||
n -= 2 # сеть и broadcast
|
||
return min(n, MAX_CAPACITY)
|
||
|
||
|
||
def utilization(used: int, cap: int) -> int:
|
||
return round(used * 100 / cap) if cap else 0
|
||
|
||
|
||
def apply_update(obj, data: dict) -> dict:
|
||
changed = {}
|
||
for k, v in data.items():
|
||
if getattr(obj, k) != v:
|
||
changed[k] = v
|
||
setattr(obj, k, v)
|
||
return changed
|
||
|
||
|
||
def count(db: Session, stmt) -> int:
|
||
return db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||
|
||
|
||
def next_free(db: Session, prefix_id: int, prefix: str) -> str | None:
|
||
net = ipaddress.ip_network(prefix)
|
||
used = {ipaddress.ip_address(a) for a in db.scalars(select(Address.address).where(Address.prefix_id == prefix_id))}
|
||
hosts = net.hosts() if net.version == 4 and net.prefixlen <= 30 else iter(net)
|
||
for ip in hosts:
|
||
if ip not in used:
|
||
return str(ip)
|
||
return None
|