В списке адресов префикса подряд идущие свободные адреса сворачиваются в строку «начало – конец · Свободно: N» (параметр group_free, группировка на сервере — пагинация по строкам после свёртки); раскрытие — существующим status=free&offset=free_offset порциями по 500. Без group_free ответ API прежний. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
533 lines
35 KiB
Python
533 lines
35 KiB
Python
import ipaddress
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy import String, and_, cast, func, or_, select, text, 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, DeviceType, Organization, Prefix, PrefixStatus, User, Vrf
|
||
from app.security import admin_user, current_user
|
||
from app.services import (
|
||
MAX_OFFSET, apply_update, audit, blockers, capacity, commit, count, flush, contains, free_page, get_or_404, network_role, next_free_address, next_free_subnet, refuse_delete,
|
||
require_org, scope_org, utilization, # изменение 032
|
||
)
|
||
|
||
router = APIRouter(dependencies=[Depends(current_user)], tags=["prefixes"])
|
||
FREE_LISTING_LIMIT = 4096 # «свободные» строки показываем, только если подсеть не больше /20
|
||
VRF_LOCK_NS = 7033 # advisory-lock пространство: изменения дерева/раскладки адресов одного VRF выполняются по одному (изменение 029, находка №5)
|
||
|
||
|
||
def _lock_vrf(db: Session, *vrf_ids: int) -> None:
|
||
"""Сериализует операции над деревом префиксов и раскладкой адресов одного VRF: без этого параллельные запросы
|
||
(например, ручное создание и автовыделение в одном родителе) читают дерево по разным снимкам транзакции и могут
|
||
оставить неверный parent_id. Несколько VRF (перенос между ними) блокируются в порядке возрастания id — исключает
|
||
взаимную блокировку. Вызывается до любых чтений дерева этого VRF; читатели (GET) не блокируются."""
|
||
for vrf_id in sorted(set(vrf_ids)):
|
||
db.execute(select(func.pg_advisory_xact_lock(VRF_LOCK_NS, vrf_id)))
|
||
|
||
|
||
def _lock_prefix(db: Session, id: int, *extra_vrf_ids: int) -> Prefix:
|
||
"""Читает и блокирует префикс вместе с деревом его VRF, устраняя гонку «vrf_id прочитан до блокировки»
|
||
(изменение 030, ревью 025-029 находка №1): `vrf_id` читается отдельным SELECT, затем блокируется через
|
||
`_lock_vrf` (плюс `extra_vrf_ids` — например, целевой VRF при переносе, одним вызовом), и только потом
|
||
префикс перечитывается под `FOR UPDATE` с `populate_existing`, чтобы не взять устаревший объект из identity map.
|
||
Если под блокировкой `vrf_id` оказался другим (префикс успели перенести в другой VRF) — 409, без повтора:
|
||
повтор накопил бы в транзакции лишнюю блокировку устаревшего VRF, и порядок захвата между попытками перестал
|
||
бы быть строго возрастающим (риск взаимной блокировки с зеркальной операцией). Транзакция откатится при
|
||
закрытии сессии, и клиент повторяет запрос с нуля, без унаследованных блокировок."""
|
||
vrf_id = db.scalar(select(Prefix.vrf_id).where(Prefix.id == id))
|
||
if vrf_id is None:
|
||
raise HTTPException(404, "Префикс не найден")
|
||
_lock_vrf(db, vrf_id, *extra_vrf_ids)
|
||
p = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update().execution_options(populate_existing=True))
|
||
if p is None:
|
||
raise HTTPException(404, "Префикс не найден")
|
||
if p.vrf_id != vrf_id:
|
||
raise HTTPException(409, "Префикс одновременно изменяется, повторите запрос")
|
||
return p
|
||
|
||
|
||
# -------------------------------------------------------------------- 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 _prefix_outs(db: Session, rows: list[Prefix]) -> list[s.PrefixOut]:
|
||
"""Ёмкость префикса — размер его собственной подсети (изменение 025, находка №1): раньше ёмкость родителя
|
||
считалась суммой ёмкостей вложенных листьев, и после частичного разбиения (изменение 010) расходилась
|
||
с экраном адресов того же префикса."""
|
||
usage = _usage(db, [r.id for r in rows])
|
||
depths: dict[int, int] = {}
|
||
vrf_names = dict(db.execute(select(Vrf.id, Vrf.name).where(Vrf.id.in_(list({r.vrf_id for r in rows})))).all()) if rows else {}
|
||
for org_id in {r.organization_id for r in rows}:
|
||
depths.update(_depths(db, org_id))
|
||
out = []
|
||
for r in rows:
|
||
used, stored = usage.get(r.id, (0, 0))
|
||
cap = capacity(str(r.prefix))
|
||
out.append(s.PrefixOut(
|
||
id=r.id, organization_id=r.organization_id, vrf_id=r.vrf_id, vrf_name=vrf_names[r.vrf_id],
|
||
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 _narrowest_prefix(db: Session, vrf_id: int, ip: str) -> Prefix | None:
|
||
"""Самый узкий префикс VRF, содержащий адрес."""
|
||
return db.scalar(select(Prefix).where(Prefix.vrf_id == vrf_id, Prefix.prefix.op(">>=")(ip)).order_by(func.masklen(Prefix.prefix).desc()).limit(1))
|
||
|
||
|
||
def _unusable_after_rehome(db: Session, p: Prefix) -> list[tuple[str, str]]:
|
||
"""(адрес, CIDR целевого префикса) для адресов VRF префикса p (его диапазон), которые при переносе в самый узкий
|
||
целевой префикс (как в rehome_addresses) окажутся его адресом сети или broadcast — такой перенос запрещён
|
||
(изменение 024, находка №2). Целевой префикс — не обязательно p: при переносе VRF им может оказаться уже существующий
|
||
вложенный префикс целевого VRF, поэтому в паре возвращается именно он, а не p."""
|
||
db.flush()
|
||
rows = db.execute(text(
|
||
"SELECT host(a2.address) AS ip, (SELECT x.prefix FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address "
|
||
" ORDER BY masklen(x.prefix) DESC LIMIT 1) AS cidr "
|
||
"FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).all()
|
||
return [(ip, cidr) for ip, cidr in rows if network_role(ipaddress.ip_network(cidr), ipaddress.ip_address(ip))]
|
||
|
||
|
||
def _unusable_message(bad: list[tuple[str, str]]) -> str:
|
||
items = ", ".join(f"{ip} ({cidr})" for ip, cidr in bad)
|
||
return f"Адреса {items} станут адресом сети/broadcast: освободите их или выберите другой префикс"
|
||
|
||
|
||
def rehome_addresses(db: Session, p: Prefix) -> int:
|
||
"""Приводит адреса диапазона префикса к правилу «адрес — в самом узком префиксе VRF»
|
||
(новый вложенный забирает адреса родителя из своего диапазона; перенесённый префикс — адреса целевого VRF). Возвращает число перенесённых."""
|
||
db.flush()
|
||
return db.execute(text(
|
||
"UPDATE addresses a SET prefix_id = t.best FROM ("
|
||
" SELECT a2.id AS aid, (SELECT x.id FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address ORDER BY masklen(x.prefix) DESC LIMIT 1) AS best "
|
||
" FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr) t "
|
||
"WHERE a.id = t.aid AND t.best IS NOT NULL AND t.best <> a.prefix_id"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).rowcount
|
||
|
||
|
||
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, user: User) -> tuple[dict, int]:
|
||
"""Переносит префикс с поддеревом в другой VRF той же организации; возвращает (diff, число префиксов)."""
|
||
vrf = get_or_404(db, Vrf, vrf_id, "VRF")
|
||
require_org(user, vrf.organization_id, "VRF") # изменение 033, находка №11
|
||
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)}")
|
||
clash = db.scalars(
|
||
select(func.host(Address.address)).where(
|
||
Address.prefix_id.in_([m.id for m in subtree]),
|
||
Address.address.in_(select(Address.address).where(Address.vrf_id == vrf.id)),
|
||
).limit(10)
|
||
).all()
|
||
if clash:
|
||
raise HTTPException(409, f"В VRF «{vrf.name}» уже назначены адреса: {', '.join(clash)}")
|
||
old_name = p.vrf.name
|
||
for m in subtree:
|
||
m.vrf = vrf
|
||
p.parent_id = None
|
||
db.flush()
|
||
db.expire_all() # vrf_id адресов обновлён каскадом БД
|
||
attach_to_tree(db, p, exclude=frozenset(m.id for m in subtree))
|
||
bad = _unusable_after_rehome(db, p)
|
||
if bad: # commit ещё не выполнялся — исключение уходит без частичных изменений (rollback при закрытии сессии)
|
||
raise HTTPException(422, _unusable_message(bad))
|
||
rehome_addresses(db, p)
|
||
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, ge=1, le=1000), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
|
||
):
|
||
stmt = select(Prefix)
|
||
if organization_id:
|
||
require_org(user, organization_id, "Организация") # изменение 032
|
||
stmt = stmt.where(Prefix.organization_id == organization_id)
|
||
else:
|
||
stmt = scope_org(stmt, Prefix.organization_id, user) # изменение 032
|
||
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_(contains(cast(Prefix.prefix, String), q), contains(Prefix.description, q)))
|
||
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), user: User = Depends(current_user)): # изменение 032
|
||
p = get_or_404(db, Prefix, id, "Префикс")
|
||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||
return _prefix_outs(db, [p])[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)):
|
||
require_org(user, body.organization_id, "Организация") # изменение 033, находка №11: до _lock_vrf и чтения чужого VRF
|
||
_lock_vrf(db, body.vrf_id) # до любых чтений дерева VRF (изменение 029)
|
||
vrf = get_or_404(db, Vrf, body.vrf_id, "VRF")
|
||
require_org(user, vrf.organization_id, "VRF") # изменение 033, находка №11
|
||
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, "Родительский префикс")
|
||
require_org(user, parent.organization_id, "Родительский префикс") # изменение 033, находка №11
|
||
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)
|
||
bad = _unusable_after_rehome(db, p)
|
||
if bad:
|
||
msg = _unusable_message(bad) # до rollback: после него объект p истекает
|
||
db.rollback()
|
||
raise HTTPException(422, msg)
|
||
moved = rehome_addresses(db, p)
|
||
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name, **({"moved_addresses": moved} if moved else {})}, organization_id=body.organization_id) # изменение 032: organization_id
|
||
commit(db, "Такой префикс уже есть в этом VRF")
|
||
return _prefix_outs(db, [p])[0]
|
||
|
||
|
||
def _busy_ranges(db: Session, p: Prefix) -> list[tuple[int, int]]:
|
||
"""Занятые диапазоны внутри префикса: вложенные префиксы того же VRF (любой глубины) и адреса, записанные в самом префиксе."""
|
||
busy = []
|
||
for c in db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op("<<")(str(p.prefix)))):
|
||
n = ipaddress.ip_network(str(c))
|
||
busy.append((int(n.network_address), int(n.broadcast_address)))
|
||
busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == p.id))]
|
||
return busy
|
||
|
||
|
||
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 "Префикс нельзя дробить: это одиночный адрес")
|
||
return next_free_subnet(str(parent.prefix), length, _busy_ranges(db, parent)), 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), user: User = Depends(current_user)): # изменение 033
|
||
"""Предпросмотр: какой блок будет выделен, без создания."""
|
||
p = get_or_404(db, Prefix, id, "Префикс")
|
||
require_org(user, p.organization_id, "Префикс") # изменение 033, находка №9: не хватало проверки организации
|
||
found, lo, hi = _find_subnet(db, p, 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 = _lock_prefix(db, id) # до любых чтений дерева (изменение 029/030) — сериализует параллельные выделения из одного родителя
|
||
require_org(user, parent.organization_id, "Префикс") # изменение 032
|
||
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)
|
||
moved = rehome_addresses(db, p) # адреса родителя из выделенного блока (учтены и при выборе блока, но вдруг появились параллельно)
|
||
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix), **({"moved_addresses": moved} if moved else {})}, organization_id=parent.organization_id) # изменение 032: organization_id
|
||
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)):
|
||
data = body.model_dump(exclude_unset=True, exclude_none=True)
|
||
new_vrf = data.pop("vrf_id", None)
|
||
# целевой VRF известен из тела запроса до чтения префикса — блокируем исходный и целевой VRF одним вызовом,
|
||
# раньше чем прочитан текущий p.vrf_id (изменение 030, ревью 025-029 находка №1); если new_vrf совпадёт
|
||
# с текущим VRF, лишняя блокировка того же VRF безвредна
|
||
p = _lock_prefix(db, id, new_vrf) if new_vrf is not None else get_or_404(db, Prefix, id, "Префикс")
|
||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||
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, user)[0])
|
||
audit(db, user, "prefix", p, "updated", str(p.prefix), changed, organization_id=p.organization_id) # изменение 032: organization_id
|
||
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 = _lock_prefix(db, id) # до переподвешивания детей: устаревший p.parent_id иначе достанется всем детям (изменение 029/030)
|
||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||
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}, organization_id=p.organization_id) # изменение 033, находка №4
|
||
db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id))
|
||
gone = count(db, select(Address.id).where(Address.prefix_id == id)) if force else 0
|
||
audit(db, user, "prefix", p, "deleted", str(p.prefix), {"force": True, "addresses_deleted": gone} if gone else None, organization_id=p.organization_id) # изменение 032: organization_id
|
||
db.delete(p)
|
||
commit(db)
|
||
|
||
|
||
# ------------------------------------------------------------------- addresses
|
||
def _addr_out(
|
||
a: Address, device_name: str | None = None, device_type_name: str | None = None, device_status: str | None = None,
|
||
) -> s.AddressOut: # изменение 039: device_type_name, device_status
|
||
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,
|
||
device_type_name=device_type_name, device_status=device_status,
|
||
note=a.note, updated_at=a.updated_at,
|
||
)
|
||
|
||
|
||
def _device_info(db: Session, device_id: int | None) -> tuple[str | None, str | None, str | None]:
|
||
"""(имя, тип, статус) устройства одним запросом — для одиночных ответов (создание/правка/автоназначение адреса).
|
||
В списке (list_addresses) те же поля даёт join, без запроса на каждую строку (изменение 039)."""
|
||
if device_id is None:
|
||
return None, None, None
|
||
row = db.execute(
|
||
select(Device.name, DeviceType.name, Device.status)
|
||
.join(DeviceType, DeviceType.id == Device.device_type_id)
|
||
.where(Device.id == device_id)
|
||
).first()
|
||
return (row[0], row[1], row[2].value) if row else (None, None, None)
|
||
|
||
|
||
def _check_device(db: Session, user: User, prefix: Prefix, device_id: int | None):
|
||
if device_id is not None:
|
||
d = get_or_404(db, Device, device_id, "Устройство")
|
||
require_org(user, d.organization_id, "Устройство") # изменение 033, находка №11
|
||
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 = "", group_free: bool = False, limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
||
db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
|
||
):
|
||
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).
|
||
Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети).
|
||
group_free (изменение 042): только в смешанном режиме — подряд идущие свободные адреса сворачиваются
|
||
в строки free_range; без параметра поведение и ответ побайтно прежние (совместимость, тесты)."""
|
||
p = get_or_404(db, Prefix, id, "Префикс")
|
||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||
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, "Неизвестный статус")
|
||
net = ipaddress.ip_network(str(p.prefix))
|
||
|
||
def occupied() -> list[int]:
|
||
return sorted(int(ipaddress.ip_address(s.ip_text(a))) for a in db.scalars(select(Address.address).where(Address.prefix_id == id)))
|
||
|
||
def free_rows(ips: list[str]) -> list[s.AddressOut]:
|
||
return [s.AddressOut(id=None, prefix_id=id, address=ip, status="free") for ip in ips]
|
||
|
||
def group_free_rows(rows: list[s.AddressOut]) -> list[s.AddressOut]:
|
||
"""Изменение 042: подряд идущие строки status=free (серия ≥ 2) сворачиваются в одну строку free_range.
|
||
Свёртка — арифметически по уже отсортированному списку (free-строки идут подряд после сортировки),
|
||
без повторного перебора подсети. free_offset — число свободных адресов подсети до начала диапазона,
|
||
совпадает с offset для GET .../addresses?status=free&offset=…&limit=… (free_page считает так же)."""
|
||
out: list[s.AddressOut] = []
|
||
free_before, i, n = 0, 0, len(rows)
|
||
while i < n:
|
||
if rows[i].status != "free":
|
||
out.append(rows[i])
|
||
i += 1
|
||
continue
|
||
j = i
|
||
while j < n and rows[j].status == "free":
|
||
j += 1
|
||
run = j - i
|
||
if run >= 2:
|
||
out.append(s.AddressOut(
|
||
id=None, prefix_id=id, address=rows[i].address, status="free_range",
|
||
range_end=rows[j - 1].address, range_count=run, free_offset=free_before,
|
||
))
|
||
else:
|
||
out.append(rows[i])
|
||
free_before += run
|
||
i = j
|
||
return out
|
||
|
||
if status == "free":
|
||
return s.AddressPage(items=free_rows(free_page(net, occupied(), offset, limit)), total=summary.free, summary=summary)
|
||
|
||
flt = [Address.prefix_id == id]
|
||
if status:
|
||
flt.append(Address.status == AddressStatus(status))
|
||
if q:
|
||
flt.append(or_(contains(func.host(Address.address), q), contains(Address.dns_name, q), contains(Address.description, q)))
|
||
# изменение 039: тип и статус устройства — тем же join, без запроса на каждую строку
|
||
stmt = (
|
||
select(Address, Device.name, DeviceType.name, Device.status)
|
||
.outerjoin(Device, Device.id == Address.device_id)
|
||
.outerjoin(DeviceType, DeviceType.id == Device.device_type_id)
|
||
.where(*flt)
|
||
)
|
||
mixed = not status and not q and cap <= FREE_LISTING_LIMIT # малая подсеть: занятые и свободные вперемешку (список ограничен размером подсети)
|
||
if mixed:
|
||
rows = [_addr_out(a, dn, dtn, ds.value if ds else None) for a, dn, dtn, ds in db.execute(stmt.order_by(Address.address)).all()]
|
||
rows = sorted(rows + free_rows(free_page(net, occupied(), 0, cap)), key=lambda r: ipaddress.ip_address(r.address))
|
||
if group_free: # изменение 042
|
||
rows = group_free_rows(rows)
|
||
return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary)
|
||
total = count(db, select(Address.id).where(*flt))
|
||
page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all()
|
||
return s.AddressPage(items=[_addr_out(a, dn, dtn, ds.value if ds else None) for a, dn, dtn, ds in page], 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 = _lock_prefix(db, id) # выбор самого узкого префикса должен видеть согласованное дерево VRF (изменение 029/030)
|
||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||
net, ip = ipaddress.ip_network(str(p.prefix)), ipaddress.ip_address(body.address)
|
||
if ip not in net:
|
||
raise HTTPException(422, f"Адрес {body.address} не принадлежит префиксу {p.prefix}")
|
||
role = network_role(net, ip)
|
||
if role:
|
||
raise HTTPException(422, f"Адрес {body.address} — {'адрес сети' if role == 'network' else 'broadcast'} префикса {p.prefix}, назначить его нельзя")
|
||
narrowest = _narrowest_prefix(db, p.vrf_id, body.address)
|
||
if narrowest is not None and narrowest.id != p.id: # адрес хранится в самом узком префиксе VRF
|
||
raise HTTPException(422, f"Адрес {body.address} принадлежит вложенному префиксу {narrowest.prefix}, назначьте его там")
|
||
_check_device(db, user, p, body.device_id)
|
||
a = Address(prefix_id=id, vrf_id=p.vrf_id, **body.model_dump())
|
||
db.add(a)
|
||
flush(db, "Адрес уже есть в этом префиксе")
|
||
audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address, organization_id=p.organization_id) # изменение 032: organization_id
|
||
commit(db, "Адрес уже есть в этом префиксе")
|
||
db.refresh(a)
|
||
return _addr_out(a, *_device_info(db, a.device_id)) # изменение 039
|
||
|
||
|
||
@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 = _lock_prefix(db, id) # занятые диапазоны (вложенные префиксы) должны читаться по согласованному дереву (изменение 029/030)
|
||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||
if not p.is_pool:
|
||
raise HTTPException(422, "Префикс не является пулом для автоназначения")
|
||
ip = next_free_address(ipaddress.ip_network(str(p.prefix)), _busy_ranges(db, p)) # вложенные префиксы и адрес сети/broadcast пропускаются
|
||
if ip is None:
|
||
raise HTTPException(409, "В префиксе нет свободных адресов")
|
||
data = body.model_dump(exclude_unset=True, exclude_none=True) if body else {}
|
||
_check_device(db, user, p, data.get("device_id"))
|
||
a = Address(prefix_id=id, vrf_id=p.vrf_id, address=ip, **data)
|
||
db.add(a)
|
||
flush(db, "Адрес уже занят, повторите запрос")
|
||
audit(db, user, "address", a, "assigned", ip, organization_id=p.organization_id) # изменение 032: organization_id
|
||
commit(db, "Адрес уже занят, повторите запрос")
|
||
db.refresh(a)
|
||
return _addr_out(a, *_device_info(db, a.device_id)) # изменение 039
|
||
|
||
|
||
@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, "Адрес")
|
||
p = db.get(Prefix, a.prefix_id)
|
||
require_org(user, p.organization_id, "Адрес") # изменение 032
|
||
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, user, p, 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()}, organization_id=p.organization_id) # изменение 032: organization_id
|
||
commit(db)
|
||
db.refresh(a)
|
||
return _addr_out(a, *_device_info(db, a.device_id)) # изменение 039
|
||
|
||
|
||
@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, "Адрес")
|
||
p = db.get(Prefix, a.prefix_id)
|
||
require_org(user, p.organization_id, "Адрес") # изменение 032
|
||
audit(db, user, "address", a, "deleted", s.ip_text(a.address), organization_id=p.organization_id) # изменение 032: organization_id
|
||
db.delete(a)
|
||
commit(db)
|