2026-09-20 12:27:47 +03:00
import ipaddress
from fastapi import APIRouter , Depends , HTTPException , Query
2026-09-26 21:33:50 +03:00
from sqlalchemy import String , and_ , cast , func , or_ , select , text , update
2026-09-20 12:27:47 +03:00
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 (
2026-09-26 21:33:50 +03:00
MAX_CAPACITY , 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 ,
2026-09-26 13:25:33 +03:00
utilization ,
2026-09-20 12:27:47 +03:00
)
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 ] = {}
2026-09-26 21:33:50 +03:00
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 {}
2026-09-20 12:27:47 +03:00
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 (
2026-09-26 21:33:50 +03:00
id = r . id , organization_id = r . organization_id , vrf_id = r . vrf_id , vrf_name = vrf_names [ r . vrf_id ],
2026-09-20 12:27:47 +03:00
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
2026-09-26 21:33:50 +03:00
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
2026-09-20 12:27:47 +03:00
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 ) } " )
2026-09-26 21:33:50 +03:00
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 ) } " )
2026-09-20 12:27:47 +03:00
old_name = p . vrf . name
for m in subtree :
m . vrf = vrf
p . parent_id = None
db . flush ()
2026-09-26 21:33:50 +03:00
db . expire_all () # vrf_id адресов обновлён каскадом БД
2026-09-20 12:27:47 +03:00
attach_to_tree ( db , p , exclude = frozenset ( m . id for m in subtree ))
2026-09-26 21:33:50 +03:00
bad = _unusable_after_rehome ( db , p )
if bad : # commit ещё не выполнялся — исключение уходит без частичных изменений (rollback при закрытии сессии)
raise HTTPException ( 422 , _unusable_message ( bad ))
rehome_addresses ( db , p )
2026-09-20 12:27:47 +03:00
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 = "" ,
2026-09-26 21:33:50 +03:00
limit : int = Query ( 100 , ge = 1 , le = 1000 ), offset : int = Query ( 0 , ge = 0 , le = MAX_OFFSET ), db : Session = Depends ( get_db ),
2026-09-20 12:27:47 +03:00
):
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 :
2026-09-26 21:33:50 +03:00
stmt = stmt . where ( or_ ( contains ( cast ( Prefix . prefix , String ), q ), contains ( Prefix . description , q )))
2026-09-20 12:27:47 +03:00
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 )
2026-09-26 21:33:50 +03:00
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 {})})
2026-09-20 12:27:47 +03:00
commit ( db , "Такой префикс уже есть в этом VRF" )
return _prefix_outs ( db , [ p ])[ 0 ]
2026-09-26 21:33:50 +03:00
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
2026-09-26 13:25:33 +03:00
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 "Префикс нельзя дробить: это одиночный адрес" )
2026-09-26 21:33:50 +03:00
return next_free_subnet ( str ( parent . prefix ), length , _busy_ranges ( db , parent )), lo , hi
2026-09-26 13:25:33 +03:00
@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 )
2026-09-26 21:33:50 +03:00
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 {})})
2026-09-26 13:25:33 +03:00
commit ( db , "Такой префикс уже есть в этом VRF, повторите запрос" )
return _prefix_outs ( db , [ p ])[ 0 ]
2026-09-20 12:27:47 +03:00
@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 , "Префикс" )
2026-09-26 13:25:33 +03:00
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 })
2026-09-20 12:27:47 +03:00
db . execute ( update ( Prefix ) . where ( Prefix . parent_id == id ) . values ( parent_id = p . parent_id ))
2026-09-26 21:33:50 +03:00
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 )
2026-09-20 12:27:47 +03:00
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 (
2026-09-26 21:33:50 +03:00
id : int , status : str = "" , q : str = "" , limit : int = Query ( 100 , ge = 1 , le = 500 ), offset : int = Query ( 0 , ge = 0 , le = MAX_OFFSET ),
2026-09-20 12:27:47 +03:00
db : Session = Depends ( get_db ),
):
2026-09-26 21:33:50 +03:00
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).
Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети)."""
2026-09-20 12:27:47 +03:00
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 , "Неизвестный статус" )
net = ipaddress . ip_network ( str ( p . prefix ))
2026-09-26 21:33:50 +03:00
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 ]
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 )))
stmt = select ( Address , Device . name ) . outerjoin ( Device , Device . id == Address . device_id ) . where ( * flt )
mixed = not status and not q and cap <= FREE_LISTING_LIMIT # малая подсеть: занятые и свободные вперемешку (список ограничен размером подсети)
if mixed :
rows = [ _addr_out ( a , dn ) for a , dn 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 ))
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 ) for a , dn in page ], total = total , summary = summary )
2026-09-20 12:27:47 +03:00
@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 , "Префикс" )
2026-09-26 21:33:50 +03:00
net , ip = ipaddress . ip_network ( str ( p . prefix )), ipaddress . ip_address ( body . address )
if ip not in net :
2026-09-20 12:27:47 +03:00
raise HTTPException ( 422 , f "Адрес { body . address } не принадлежит префиксу { p . prefix } " )
2026-09-26 21:33:50 +03:00
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 } , назначьте его там" )
2026-09-20 12:27:47 +03:00
_check_device ( db , p , body . device_id )
2026-09-26 21:33:50 +03:00
a = Address ( prefix_id = id , vrf_id = p . vrf_id , ** body . model_dump ())
2026-09-20 12:27:47 +03:00
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."""
2026-09-26 21:33:50 +03:00
p = db . scalar ( select ( Prefix ) . where ( Prefix . id == id ) . with_for_update ()) # параллельные запросы получают разные адреса
if p is None :
raise HTTPException ( 404 , "Префикс не найден" )
2026-09-20 12:27:47 +03:00
if not p . is_pool :
raise HTTPException ( 422 , "Префикс не является пулом для автоназначения" )
2026-09-26 21:33:50 +03:00
ip = next_free_address ( ipaddress . ip_network ( str ( p . prefix )), _busy_ranges ( db , p )) # вложенные префиксы и адрес сети/broadcast пропускаются
2026-09-20 12:27:47 +03:00
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" ))
2026-09-26 21:33:50 +03:00
a = Address ( prefix_id = id , vrf_id = p . vrf_id , address = ip , ** data )
2026-09-20 12:27:47 +03:00
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 )