Files
ipam_control/app/services.py
T
ayurishchevandClaude Sonnet 5 a846d30872 IPAM Manager: API, UI-админка, журнал аудита
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>
2026-09-20 12:27:47 +03:00

108 lines
4.1 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"),
}
_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