Files
ayurishchevandClaude Opus 5.5 d1a2efd683 Задача 042: свёртка свободных адресов в раскрывающиеся диапазоны
В списке адресов префикса подряд идущие свободные адреса сворачиваются в строку
«начало – конец · Свободно: N» (параметр group_free, группировка на сервере — пагинация
по строкам после свёртки); раскрытие — существующим status=free&offset=free_offset
порциями по 500. Без group_free ответ API прежний.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:40:22 +03:00

533 lines
35 KiB
Python
Raw Permalink 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 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)