Files
ipam_control/app/services.py
T

201 lines
9.2 KiB
Python
Raw Normal View History

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
MAX_OFFSET = 10_000_000 # верхняя граница offset во всех списках
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)
CONFLICT_MSG = "Конфликт с существующими данными: проверьте уникальность значений и связанные объекты"
def commit(db: Session, conflict_msg: str = CONFLICT_MSG):
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(409, conflict_msg)
def flush(db: Session, conflict_msg: str = CONFLICT_MSG):
"""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 min(round(used * 100 / cap), 100) if cap else 0 # не больше 100 %, даже если в подсети остались адреса сети/broadcast
def network_role(net, ip) -> str | None:
""""network" / "broadcast" для IPv4 с длиной префикса ≤ 30 (как в capacity()), иначе None."""
if net.version == 4 and net.prefixlen <= 30:
if ip == net.network_address:
return "network"
if ip == net.broadcast_address:
return "broadcast"
return None
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 like_escape(q: str) -> str:
return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def contains(column, q: str):
"""column ILIKE '%q%' с экранированием % и _ (иначе поиск «%» возвращает всё)."""
return column.ilike(f"%{like_escape(q.strip())}%", escape="\\")
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 free_page(net, occupied: list[int], offset: int, limit: int) -> list[str]:
"""Страница свободных адресов сети: `occupied` — отсортированные занятые адреса (int). Пропуск `offset` — арифметикой по промежуткам,
без перебора адресов (безопасно для IPv6). Для IPv4 с длиной ≤ 30 адреса сети и broadcast не считаются."""
first, last = int(net.network_address), int(net.broadcast_address)
if net.version == 4 and net.prefixlen <= 30:
first, last = first + 1, last - 1
cls, out, skip, cur = type(net.network_address), [], offset, first
for ip in [*occupied, last + 1]:
if ip < cur:
continue
gap_end = min(ip - 1, last)
gap = gap_end - cur + 1
if gap > 0:
if skip >= gap:
skip -= gap
else:
start = cur + skip
skip = 0
out.extend(str(cls(a)) for a in range(start, min(gap_end, start + (limit - len(out)) - 1) + 1))
if len(out) >= limit:
break
cur = max(cur, ip + 1)
return out
def next_free_address(net, occupied: list[tuple[int, int]]) -> str | None:
"""Первый свободный адрес сети; `occupied` — занятые диапазоны (включительно), без перебора адресов.
Для IPv4 с длиной ≤ 30 адреса сети и broadcast не выдаются (как в capacity())."""
first, last = int(net.network_address), int(net.broadcast_address)
if net.version == 4 and net.prefixlen <= 30:
first, last = first + 1, last - 1
cand = first
for start, end in sorted(occupied):
if end < cand:
continue
if start > cand:
break
cand = end + 1
return str(type(net.network_address)(cand)) if cand <= last else None