Files
ipam_control/app/services.py
T
ayurishchevandClaude Sonnet 5 cd09ef0805 Задачи 006-010: пользователи, исправление удаления, журнал отказов, групповые операции, автовыделение префиксов
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>
2026-09-26 13:25:33 +03:00

148 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"),
"user": ("Пользователь", "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,
))
BLOCKERS_LIMIT = 20
_GROUPS = {"prefixes": "префиксы", "devices": "устройства", "isps": "операторы", "addresses": "адреса"}
def blockers(db: Session, labels) -> dict | None:
"""Мешающие удалению объекты: `labels` — select одной текстовой колонки; {"total", "items"} (до BLOCKERS_LIMIT), None — если пусто."""
total = count(db, labels)
return {"total": total, "items": list(db.scalars(labels.limit(BLOCKERS_LIMIT)))} if total else None
def refuse_delete(db: Session, user, entity_type: str, entity, label: str, reason: str, blocked_by: dict | None = None):
"""Отказ в удалении (409): фиксируем предупреждение в журнале (`<entity>.delete_blocked`) и отвечаем прежним текстом `reason`."""
blocked_by = {k: v for k, v in (blocked_by or {}).items() if v}
diff = {"reason": reason, **({"blocked_by": blocked_by} if blocked_by else {})}
noun = _NOUNS.get(entity_type, (entity_type, "m"))[0]
tail = ("связанные объекты (" + ", ".join(f"{_GROUPS[k]}: {v['total']}" for k, v in blocked_by.items()) + ")") if blocked_by else reason[:1].lower() + reason[1:]
audit(db, user, entity_type, entity, "delete_blocked", label, diff, message=f"{noun} {label}: удаление отклонено — {tail}")
commit(db) # к этому моменту в транзакции только запись журнала
raise HTTPException(409, reason)
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_subnet(parent: str, length: int, occupied: list[tuple[int, int]]) -> str | None:
"""Первый свободный блок длины `length` внутри `parent`, выровненный по размеру блока.
`occupied` — занятые диапазоны адресов (включительно); без перебора всех подсетей: перескок за конец пересечения."""
net = ipaddress.ip_network(parent)
size = 1 << (net.max_prefixlen - length)
first, last = int(net.network_address), int(net.broadcast_address)
cand = first
for start, end in sorted(occupied):
if end < cand:
continue
if start >= cand + size:
break # блок cand..cand+size-1 свободен
cand = -(-(end + 1) // size) * size # за конец занятого диапазона, с выравниванием
if cand + size - 1 > last:
return None
return str(ipaddress.ip_network((type(net.network_address)(cand), length)))
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