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): фиксируем предупреждение в журнале (`.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