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>
371 lines
19 KiB
Python
371 lines
19 KiB
Python
import ipaddress
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy import String, and_, cast, func, or_, select, update
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app import schemas as s
|
||
from app.db import get_db
|
||
from app.models import Address, AddressStatus, Device, Organization, Prefix, PrefixStatus, User, Vrf
|
||
from app.security import admin_user, current_user
|
||
from app.services import (
|
||
MAX_CAPACITY, apply_update, audit, blockers, capacity, commit, count, flush, get_or_404, next_free, next_free_subnet, refuse_delete,
|
||
utilization,
|
||
)
|
||
|
||
router = APIRouter(dependencies=[Depends(current_user)], tags=["prefixes"])
|
||
FREE_LISTING_LIMIT = 4096 # «свободные» строки показываем, только если подсеть не больше /20
|
||
|
||
|
||
# -------------------------------------------------------------------- prefixes
|
||
def _usage(db: Session, ids: list[int]) -> dict[int, tuple[int, int]]:
|
||
"""{prefix_id: (assigned, stored)} с учётом вложенных префиксов того же VRF."""
|
||
if not ids:
|
||
return {}
|
||
p, c = Prefix.__table__.alias("p"), Prefix.__table__.alias("c")
|
||
a = Address.__table__
|
||
rows = db.execute(
|
||
select(
|
||
p.c.id,
|
||
func.count().filter(a.c.status == AddressStatus.assigned),
|
||
func.count(a.c.id),
|
||
)
|
||
.select_from(p.join(c, and_(c.c.vrf_id == p.c.vrf_id, c.c.prefix.op("<<=")(p.c.prefix)))
|
||
.join(a, a.c.prefix_id == c.c.id))
|
||
.where(p.c.id.in_(ids))
|
||
.group_by(p.c.id)
|
||
).all()
|
||
return {r[0]: (r[1], r[2]) for r in rows}
|
||
|
||
|
||
def _depths(db: Session, organization_id: int) -> dict[int, int]:
|
||
parents = dict(db.execute(select(Prefix.id, Prefix.parent_id).where(Prefix.organization_id == organization_id)).all())
|
||
|
||
def depth(i: int) -> int:
|
||
d = 0
|
||
while parents.get(i) is not None:
|
||
i, d = parents[i], d + 1
|
||
return d
|
||
|
||
return {i: depth(i) for i in parents}
|
||
|
||
|
||
def _capacities(db: Session, organization_id: int) -> dict[int, int]:
|
||
"""Ёмкость листа — размер подсети; ёмкость родителя — сумма ёмкостей вложенных листьев."""
|
||
rows = db.execute(select(Prefix.id, Prefix.parent_id, Prefix.prefix).where(Prefix.organization_id == organization_id)).all()
|
||
kids: dict[int, list[int]] = {}
|
||
for i, parent, _ in rows:
|
||
if parent is not None:
|
||
kids.setdefault(parent, []).append(i)
|
||
own = {i: capacity(str(cidr)) for i, _, cidr in rows}
|
||
memo: dict[int, int] = {}
|
||
|
||
def cap(i: int) -> int:
|
||
if i not in memo:
|
||
memo[i] = sum(cap(k) for k in kids[i]) if i in kids else own[i]
|
||
return memo[i]
|
||
|
||
return {i: min(cap(i), MAX_CAPACITY) for i in own}
|
||
|
||
|
||
def _prefix_outs(db: Session, rows: list[Prefix]) -> list[s.PrefixOut]:
|
||
usage = _usage(db, [r.id for r in rows])
|
||
depths: dict[int, int] = {}
|
||
caps: dict[int, int] = {}
|
||
for org_id in {r.organization_id for r in rows}:
|
||
depths.update(_depths(db, org_id))
|
||
caps.update(_capacities(db, org_id))
|
||
out = []
|
||
for r in rows:
|
||
used, stored = usage.get(r.id, (0, 0))
|
||
cap = caps.get(r.id, capacity(str(r.prefix)))
|
||
out.append(s.PrefixOut(
|
||
id=r.id, organization_id=r.organization_id, vrf_id=r.vrf_id, vrf_name=r.vrf.name,
|
||
prefix=str(r.prefix), family=ipaddress.ip_network(str(r.prefix)).version,
|
||
description=r.description, status=r.status, parent_id=r.parent_id, depth=depths.get(r.id, 0),
|
||
is_pool=r.is_pool, note=r.note, used=used, capacity=cap,
|
||
utilization=utilization(used, cap), addresses_count=stored,
|
||
))
|
||
return out
|
||
|
||
|
||
def attach_to_tree(db: Session, p: Prefix, keep_parent: bool = False, exclude: frozenset[int] = frozenset()) -> None:
|
||
"""Вписывает префикс в дерево его VRF: находит самого узкого родителя и забирает под себя
|
||
вложенные префиксы, чей текущий родитель шире (exclude — уже подчинённые ему, при переносе поддерева)."""
|
||
cidr = str(p.prefix)
|
||
if not keep_parent:
|
||
p.parent_id = db.scalar(
|
||
select(Prefix.id)
|
||
.where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op(">>")(cidr))
|
||
.order_by(func.masklen(Prefix.prefix).desc()).limit(1)
|
||
)
|
||
plen = ipaddress.ip_network(cidr).prefixlen
|
||
inner = db.scalars(
|
||
select(Prefix).where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op("<<")(cidr))
|
||
).all()
|
||
for c in inner:
|
||
if c.id in exclude:
|
||
continue
|
||
cur = db.get(Prefix, c.parent_id) if c.parent_id else None
|
||
if cur is None or ipaddress.ip_network(str(cur.prefix)).prefixlen < plen:
|
||
c.parent_id = p.id
|
||
|
||
|
||
def _subtree(db: Session, root: Prefix) -> list[Prefix]:
|
||
"""Префикс и все вложенные по цепочке parent_id."""
|
||
result, frontier = [root], [root.id]
|
||
while frontier:
|
||
kids = db.scalars(select(Prefix).where(Prefix.parent_id.in_(frontier))).all()
|
||
result += kids
|
||
frontier = [k.id for k in kids]
|
||
return result
|
||
|
||
|
||
def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]:
|
||
"""Переносит префикс с поддеревом в другой VRF той же организации; возвращает (diff, число префиксов)."""
|
||
vrf = get_or_404(db, Vrf, vrf_id, "VRF")
|
||
if vrf.organization_id != p.organization_id:
|
||
raise HTTPException(422, "Целевой VRF принадлежит другой организации")
|
||
subtree = _subtree(db, p)
|
||
taken = db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == vrf.id, Prefix.prefix.in_([str(m.prefix) for m in subtree]))).all()
|
||
if taken:
|
||
raise HTTPException(409, f"В VRF «{vrf.name}» уже есть: {', '.join(str(x) for x in taken)}")
|
||
old_name = p.vrf.name
|
||
for m in subtree:
|
||
m.vrf = vrf
|
||
p.parent_id = None
|
||
db.flush()
|
||
attach_to_tree(db, p, exclude=frozenset(m.id for m in subtree))
|
||
return {"vrf": f"{old_name} → {vrf.name}", "moved": len(subtree)}, len(subtree)
|
||
|
||
|
||
@router.get("/prefixes", response_model=s.Page[s.PrefixOut])
|
||
def list_prefixes(
|
||
organization_id: int | None = None, vrf_id: int | None = None, status: PrefixStatus | None = None,
|
||
family: int | None = Query(None, ge=4, le=6), q: str = "",
|
||
limit: int = Query(100, le=1000), offset: int = 0, db: Session = Depends(get_db),
|
||
):
|
||
stmt = select(Prefix)
|
||
if organization_id:
|
||
stmt = stmt.where(Prefix.organization_id == organization_id)
|
||
if vrf_id:
|
||
stmt = stmt.where(Prefix.vrf_id == vrf_id)
|
||
if status:
|
||
stmt = stmt.where(Prefix.status == status)
|
||
if family:
|
||
stmt = stmt.where(func.family(Prefix.prefix) == (4 if family == 4 else 6))
|
||
if q:
|
||
stmt = stmt.where(or_(cast(Prefix.prefix, String).ilike(f"%{q.strip()}%"), Prefix.description.ilike(f"%{q.strip()}%")))
|
||
total = count(db, stmt)
|
||
rows = db.scalars(stmt.order_by(Prefix.vrf_id, Prefix.prefix).limit(limit).offset(offset)).all()
|
||
return s.Page(items=_prefix_outs(db, list(rows)), total=total)
|
||
|
||
|
||
@router.get("/prefixes/{id}", response_model=s.PrefixOut)
|
||
def get_prefix(id: int, db: Session = Depends(get_db)):
|
||
return _prefix_outs(db, [get_or_404(db, Prefix, id, "Префикс")])[0]
|
||
|
||
|
||
@router.post("/prefixes", response_model=s.PrefixOut, status_code=201)
|
||
def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||
vrf = get_or_404(db, Vrf, body.vrf_id, "VRF")
|
||
if vrf.organization_id != body.organization_id:
|
||
raise HTTPException(422, "VRF принадлежит другой организации")
|
||
get_or_404(db, Organization, body.organization_id, "Организация")
|
||
if db.scalar(select(Prefix.id).where(Prefix.vrf_id == vrf.id, Prefix.prefix == body.prefix)):
|
||
raise HTTPException(409, "Такой префикс уже есть в этом VRF")
|
||
|
||
parent_id = body.parent_id
|
||
if parent_id is not None:
|
||
parent = get_or_404(db, Prefix, parent_id, "Родительский префикс")
|
||
if parent.vrf_id != vrf.id or not ipaddress.ip_network(body.prefix).subnet_of(ipaddress.ip_network(str(parent.prefix))):
|
||
raise HTTPException(422, "Родительский префикс должен содержать новый и быть в том же VRF")
|
||
p = Prefix(**{**body.model_dump(), "parent_id": parent_id})
|
||
db.add(p)
|
||
flush(db, "Такой префикс уже есть в этом VRF")
|
||
attach_to_tree(db, p, keep_parent=parent_id is not None)
|
||
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name})
|
||
commit(db, "Такой префикс уже есть в этом VRF")
|
||
return _prefix_outs(db, [p])[0]
|
||
|
||
|
||
def _find_subnet(db: Session, parent: Prefix, length: int) -> tuple[str | None, int, int]:
|
||
"""(свободный блок | None, min длина, max длина) для вложенного префикса в parent."""
|
||
net = ipaddress.ip_network(str(parent.prefix))
|
||
lo, hi = net.prefixlen + 1, net.max_prefixlen
|
||
if not lo <= length <= hi:
|
||
raise HTTPException(422, f"Размер вложенного префикса: от /{lo} до /{hi}" if lo <= hi else "Префикс нельзя дробить: это одиночный адрес")
|
||
busy = [(int(n.network_address), int(n.broadcast_address)) for n in
|
||
(ipaddress.ip_network(str(c)) for c in db.scalars(
|
||
select(Prefix.prefix).where(Prefix.vrf_id == parent.vrf_id, Prefix.id != parent.id, Prefix.prefix.op("<<")(str(parent.prefix)))))]
|
||
busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == parent.id))]
|
||
return next_free_subnet(str(parent.prefix), length, busy), lo, hi
|
||
|
||
|
||
@router.get("/prefixes/{id}/subnets/next", response_model=s.SubnetPreview)
|
||
def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Depends(get_db)):
|
||
"""Предпросмотр: какой блок будет выделен, без создания."""
|
||
found, lo, hi = _find_subnet(db, get_or_404(db, Prefix, id, "Префикс"), length)
|
||
return s.SubnetPreview(prefix=found, length_min=lo, length_max=hi)
|
||
|
||
|
||
@router.post("/prefixes/{id}/subnets/next", response_model=s.PrefixOut, status_code=201)
|
||
def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||
"""Создаёт вложенный префикс заданного размера в первом свободном выровненном блоке родителя."""
|
||
parent = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update()) # сериализуем параллельные выделения из одного родителя
|
||
if parent is None:
|
||
raise HTTPException(404, "Префикс не найден")
|
||
found, _, _ = _find_subnet(db, parent, body.length)
|
||
if found is None:
|
||
raise HTTPException(409, f"В префиксе {parent.prefix} нет свободного блока /{body.length}")
|
||
p = Prefix(organization_id=parent.organization_id, vrf_id=parent.vrf_id, prefix=found, parent_id=parent.id,
|
||
description=body.description, status=body.status, is_pool=body.is_pool, note=body.note)
|
||
db.add(p)
|
||
flush(db, "Такой префикс уже есть в этом VRF, повторите запрос")
|
||
attach_to_tree(db, p, keep_parent=True)
|
||
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix)})
|
||
commit(db, "Такой префикс уже есть в этом VRF, повторите запрос")
|
||
return _prefix_outs(db, [p])[0]
|
||
|
||
|
||
@router.patch("/prefixes/{id}", response_model=s.PrefixOut)
|
||
def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||
p = get_or_404(db, Prefix, id, "Префикс")
|
||
data = body.model_dump(exclude_unset=True, exclude_none=True)
|
||
new_vrf = data.pop("vrf_id", None)
|
||
changed = {k: str(v) for k, v in apply_update(p, data).items()}
|
||
if new_vrf is not None and new_vrf != p.vrf_id:
|
||
changed.update(_move_to_vrf(db, p, new_vrf)[0])
|
||
audit(db, user, "prefix", p, "updated", str(p.prefix), changed)
|
||
commit(db)
|
||
return _prefix_outs(db, [p])[0]
|
||
|
||
|
||
@router.delete("/prefixes/{id}", status_code=204)
|
||
def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||
p = get_or_404(db, Prefix, id, "Префикс")
|
||
used = None if force else blockers(db, select(func.host(Address.address)).where(Address.prefix_id == id).order_by(Address.address))
|
||
if used:
|
||
refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used})
|
||
db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id))
|
||
audit(db, user, "prefix", p, "deleted", str(p.prefix))
|
||
db.delete(p)
|
||
commit(db)
|
||
|
||
|
||
# ------------------------------------------------------------------- addresses
|
||
def _addr_out(a: Address, device_name: str | None = None) -> s.AddressOut:
|
||
return s.AddressOut(
|
||
id=a.id, prefix_id=a.prefix_id, address=s.ip_text(a.address), status=a.status.value,
|
||
dns_name=a.dns_name, description=a.description, device_id=a.device_id, device_name=device_name,
|
||
note=a.note, updated_at=a.updated_at,
|
||
)
|
||
|
||
|
||
def _check_device(db: Session, prefix: Prefix, device_id: int | None):
|
||
if device_id is not None:
|
||
d = get_or_404(db, Device, device_id, "Устройство")
|
||
if d.organization_id != prefix.organization_id:
|
||
raise HTTPException(422, "Устройство принадлежит другой организации")
|
||
|
||
|
||
@router.get("/prefixes/{id}/addresses", response_model=s.AddressPage)
|
||
def list_addresses(
|
||
id: int, status: str = "", q: str = "", limit: int = Query(100, le=500), offset: int = 0,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей)."""
|
||
p = get_or_404(db, Prefix, id, "Префикс")
|
||
cap = capacity(str(p.prefix))
|
||
counts = dict(db.execute(select(Address.status, func.count()).where(Address.prefix_id == id).group_by(Address.status)).all())
|
||
stored = sum(counts.values())
|
||
summary = s.AddressSummary(
|
||
assigned=counts.get(AddressStatus.assigned, 0), reserved=counts.get(AddressStatus.reserved, 0),
|
||
deprecated=counts.get(AddressStatus.deprecated, 0), free=max(cap - stored, 0), capacity=cap,
|
||
)
|
||
if status and status not in {"free", *(x.value for x in AddressStatus)}:
|
||
raise HTTPException(422, "Неизвестный статус")
|
||
|
||
stmt = select(Address, Device.name).outerjoin(Device, Device.id == Address.device_id).where(Address.prefix_id == id)
|
||
if status and status != "free":
|
||
stmt = stmt.where(Address.status == AddressStatus(status))
|
||
if q:
|
||
like = f"%{q.strip()}%"
|
||
stmt = stmt.where(or_(func.host(Address.address).ilike(like), Address.dns_name.ilike(like), Address.description.ilike(like)))
|
||
rows = [_addr_out(a, dn) for a, dn in db.execute(stmt.order_by(Address.address)).all()] if status != "free" else []
|
||
|
||
net = ipaddress.ip_network(str(p.prefix))
|
||
want_free = status == "free" or (not status and not q and cap <= FREE_LISTING_LIMIT)
|
||
if want_free:
|
||
used = {ipaddress.ip_address(s.ip_text(a)) for a in db.scalars(select(Address.address).where(Address.prefix_id == id))}
|
||
hosts = net.hosts() if net.version == 4 and net.prefixlen <= 30 else iter(net)
|
||
need = offset + limit if status == "free" else cap
|
||
free = []
|
||
for ip in hosts:
|
||
if ip not in used:
|
||
free.append(s.AddressOut(id=None, prefix_id=id, address=str(ip), status="free"))
|
||
if len(free) >= need:
|
||
break
|
||
rows = sorted(rows + free, key=lambda r: ipaddress.ip_address(r.address))
|
||
total = summary.free if status == "free" else (len(rows) if want_free or q or status else stored)
|
||
return s.AddressPage(items=rows[offset:offset + limit], total=total, summary=summary)
|
||
|
||
|
||
@router.post("/prefixes/{id}/addresses", response_model=s.AddressOut, status_code=201)
|
||
def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||
p = get_or_404(db, Prefix, id, "Префикс")
|
||
if ipaddress.ip_address(body.address) not in ipaddress.ip_network(str(p.prefix)):
|
||
raise HTTPException(422, f"Адрес {body.address} не принадлежит префиксу {p.prefix}")
|
||
_check_device(db, p, body.device_id)
|
||
a = Address(prefix_id=id, **body.model_dump())
|
||
db.add(a)
|
||
flush(db, "Адрес уже есть в этом префиксе")
|
||
audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address)
|
||
commit(db, "Адрес уже есть в этом префиксе")
|
||
db.refresh(a)
|
||
return _addr_out(a)
|
||
|
||
|
||
@router.post("/prefixes/{id}/addresses/next", response_model=s.AddressOut, status_code=201)
|
||
def allocate_next(
|
||
id: int, body: s.AddressUpdate | None = None, db: Session = Depends(get_db), user: User = Depends(admin_user)
|
||
):
|
||
"""Автоназначение первого свободного адреса; только для префиксов с флагом is_pool."""
|
||
p = get_or_404(db, Prefix, id, "Префикс")
|
||
if not p.is_pool:
|
||
raise HTTPException(422, "Префикс не является пулом для автоназначения")
|
||
ip = next_free(db, id, str(p.prefix))
|
||
if ip is None:
|
||
raise HTTPException(409, "В префиксе нет свободных адресов")
|
||
data = body.model_dump(exclude_unset=True, exclude_none=True) if body else {}
|
||
_check_device(db, p, data.get("device_id"))
|
||
a = Address(prefix_id=id, address=ip, **data)
|
||
db.add(a)
|
||
flush(db, "Адрес уже занят, повторите запрос")
|
||
audit(db, user, "address", a, "assigned", ip)
|
||
commit(db, "Адрес уже занят, повторите запрос")
|
||
db.refresh(a)
|
||
return _addr_out(a)
|
||
|
||
|
||
@router.patch("/addresses/{id}", response_model=s.AddressOut)
|
||
def update_address(id: int, body: s.AddressUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||
a = get_or_404(db, Address, id, "Адрес")
|
||
data = body.model_dump(exclude_unset=True)
|
||
if data.get("dns_name"):
|
||
s.AddressIn(address=s.ip_text(a.address), dns_name=data["dns_name"]) # валидация FQDN
|
||
_check_device(db, db.get(Prefix, a.prefix_id), data.get("device_id"))
|
||
changed = apply_update(a, data)
|
||
audit(db, user, "address", a, "updated", s.ip_text(a.address), {k: str(v) for k, v in changed.items()})
|
||
commit(db)
|
||
db.refresh(a)
|
||
return _addr_out(a)
|
||
|
||
|
||
@router.delete("/addresses/{id}", status_code=204)
|
||
def delete_address(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||
a = get_or_404(db, Address, id, "Адрес")
|
||
audit(db, user, "address", a, "deleted", s.ip_text(a.address))
|
||
db.delete(a)
|
||
commit(db)
|