Задачи 006-010: пользователи, исправление удаления, журнал отказов, групповые операции, автовыделение префиксов
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>
This commit is contained in:
1 parent
a846d30872
commit
cd09ef0805
23 files changed
+988
-49
No files matched your search
+44
-3
@@ -9,7 +9,8 @@ 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, capacity, commit, count, flush, get_or_404, next_free, utilization,
|
||||
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"])
|
||||
@@ -188,6 +189,45 @@ def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User =
|
||||
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, "Префикс")
|
||||
@@ -204,8 +244,9 @@ def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db),
|
||||
@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, "Префикс")
|
||||
if not force and count(db, select(Address.id).where(Address.prefix_id == id)):
|
||||
raise HTTPException(409, "В префиксе есть адреса; удалите их или используйте force=true")
|
||||
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)
|
||||
|
||||
+18
-13
@@ -1,6 +1,6 @@
|
||||
"""Справочники: организации, VRF, операторы, типы устройств, устройства."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import String, cast, func, or_, select
|
||||
from sqlalchemy import String, cast, delete, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas as s
|
||||
@@ -9,7 +9,7 @@ from app.models import (
|
||||
Address, AddressStatus, Device, DeviceType, Isp, IspNetwork, Organization, Prefix, User, Vrf,
|
||||
)
|
||||
from app.security import admin_user, current_user
|
||||
from app.services import apply_update, audit, commit, count, flush, get_or_404
|
||||
from app.services import apply_update, audit, blockers, commit, count, flush, get_or_404, refuse_delete
|
||||
from fastapi import HTTPException
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)])
|
||||
@@ -67,12 +67,15 @@ def update_org(id: int, body: s.OrgIn, db: Session = Depends(get_db), user: User
|
||||
@router.delete("/organizations/{id}", status_code=204, tags=["organizations"])
|
||||
def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
o = get_or_404(db, Organization, id, "Организация")
|
||||
busy = count(db, select(Prefix.id).where(Prefix.organization_id == id)) or count(
|
||||
db, select(Device.id).where(Device.organization_id == id)) or count(db, select(Isp.id).where(Isp.organization_id == id))
|
||||
if busy:
|
||||
raise HTTPException(409, "Нельзя удалить: у организации есть префиксы, устройства или операторы")
|
||||
for v in db.scalars(select(Vrf).where(Vrf.organization_id == id)):
|
||||
db.delete(v)
|
||||
found = {
|
||||
"prefixes": blockers(db, select(func.concat(cast(Prefix.prefix, String), " (", Vrf.name, ")")).select_from(Prefix).join(Vrf, Vrf.id == Prefix.vrf_id)
|
||||
.where(Prefix.organization_id == id).order_by(Prefix.id)),
|
||||
"devices": blockers(db, select(Device.name).where(Device.organization_id == id).order_by(Device.id)),
|
||||
"isps": blockers(db, select(Isp.name).where(Isp.organization_id == id).order_by(Isp.id)),
|
||||
}
|
||||
if any(found.values()):
|
||||
refuse_delete(db, user, "organization", o, o.name, "Нельзя удалить: у организации есть префиксы, устройства или операторы", found)
|
||||
db.execute(delete(Vrf).where(Vrf.organization_id == id)) # немедленно: между Vrf и Organization нет relationship(), порядок DELETE в UoW не гарантирован
|
||||
audit(db, user, "organization", o, "deleted", o.name)
|
||||
db.delete(o)
|
||||
commit(db)
|
||||
@@ -117,8 +120,9 @@ def update_vrf(id: int, body: s.VrfUpdate, db: Session = Depends(get_db), user:
|
||||
@router.delete("/vrfs/{id}", status_code=204, tags=["vrf"])
|
||||
def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
v = get_or_404(db, Vrf, id, "VRF")
|
||||
if count(db, select(Prefix.id).where(Prefix.vrf_id == id)):
|
||||
raise HTTPException(409, "Нельзя удалить: VRF используется префиксами")
|
||||
used = blockers(db, select(cast(Prefix.prefix, String)).where(Prefix.vrf_id == id).order_by(Prefix.id))
|
||||
if used:
|
||||
refuse_delete(db, user, "vrf", v, v.name, "Нельзя удалить: VRF используется префиксами", {"prefixes": used})
|
||||
audit(db, user, "vrf", v, "deleted", v.name)
|
||||
db.delete(v)
|
||||
commit(db)
|
||||
@@ -160,9 +164,10 @@ def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), us
|
||||
def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
t = get_or_404(db, DeviceType, id, "Тип")
|
||||
if t.is_default:
|
||||
raise HTTPException(409, "Нельзя удалить тип по умолчанию")
|
||||
if count(db, select(Device.id).where(Device.device_type_id == id)):
|
||||
raise HTTPException(409, "Нельзя удалить: тип используется устройствами")
|
||||
refuse_delete(db, user, "device_type", t, t.name, "Нельзя удалить тип по умолчанию")
|
||||
used = blockers(db, select(Device.name).where(Device.device_type_id == id).order_by(Device.id))
|
||||
if used:
|
||||
refuse_delete(db, user, "device_type", t, t.name, "Нельзя удалить: тип используется устройствами", {"devices": used})
|
||||
audit(db, user, "device_type", t, "deleted", t.name)
|
||||
db.delete(t)
|
||||
commit(db)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Пользователи: учётные записи UI (роли admin/viewer), смена своего пароля.
|
||||
|
||||
Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить
|
||||
свою учётную запись и последнего активного администратора; зарезервированные логины `system`
|
||||
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas as s
|
||||
from app.db import get_db
|
||||
from app.models import Role, User
|
||||
from app.security import admin_user, current_user, hash_password, verify_password
|
||||
from app.services import apply_update, audit, commit, count, flush, get_or_404, refuse_delete
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)], tags=["users"])
|
||||
|
||||
|
||||
def _other_active_admins(db: Session, user_id: int) -> int:
|
||||
"""Активные администраторы, кроме указанного: 0 — система осталась бы без прав записи."""
|
||||
return count(db, select(User.id).where(User.role == Role.admin, User.is_active, User.id != user_id))
|
||||
|
||||
|
||||
@router.get("/users", response_model=s.Page[s.UserOut])
|
||||
def list_users(
|
||||
q: str = "", limit: int = Query(100, le=500), offset: int = 0,
|
||||
db: Session = Depends(get_db), admin: User = Depends(admin_user),
|
||||
):
|
||||
stmt = select(User)
|
||||
if q.strip():
|
||||
stmt = stmt.where(User.username.ilike(f"%{q.strip()}%"))
|
||||
total = count(db, stmt)
|
||||
rows = db.scalars(stmt.order_by(User.username).limit(limit).offset(offset)).all()
|
||||
return s.Page(items=[s.UserOut.model_validate(u) for u in rows], total=total)
|
||||
|
||||
|
||||
@router.post("/users", response_model=s.UserOut, status_code=201)
|
||||
def create_user(body: s.UserIn, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
|
||||
# логин уникален без учёта регистра: в токене и в журнале он должен опознаваться однозначно
|
||||
if db.scalar(select(User.id).where(func.lower(User.username) == body.username.lower())):
|
||||
raise HTTPException(409, "Пользователь с таким логином уже существует")
|
||||
u = User(username=body.username, password_hash=hash_password(body.password), role=body.role, is_active=body.is_active)
|
||||
db.add(u)
|
||||
flush(db, "Пользователь с таким логином уже существует")
|
||||
audit(db, admin, "user", u, "created", u.username)
|
||||
commit(db, "Пользователь с таким логином уже существует")
|
||||
return u
|
||||
|
||||
|
||||
@router.patch("/users/{id}", response_model=s.UserOut)
|
||||
def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
|
||||
u = get_or_404(db, User, id, "Пользователь")
|
||||
data = body.model_dump(exclude_unset=True, exclude_none=True)
|
||||
pwd = data.pop("password", None)
|
||||
role, is_active = data.get("role", u.role), data.get("is_active", u.is_active)
|
||||
loses_admin = u.role == Role.admin and u.is_active and (role != Role.admin or not is_active)
|
||||
if u.id == admin.id and (role != Role.admin or not is_active):
|
||||
raise HTTPException(409, "Нельзя отключить или понизить свою учётную запись")
|
||||
if loses_admin and _other_active_admins(db, u.id) == 0:
|
||||
raise HTTPException(409, "Нельзя отключить или понизить единственного активного администратора")
|
||||
changed = apply_update(u, data)
|
||||
if pwd:
|
||||
u.password_hash = hash_password(pwd)
|
||||
if changed:
|
||||
audit(db, admin, "user", u, "updated", u.username, changed)
|
||||
if pwd:
|
||||
audit(db, admin, "user", u, "password_reset", u.username, message=f"Пользователь {u.username}: пароль изменён администратором")
|
||||
commit(db)
|
||||
return u
|
||||
|
||||
|
||||
@router.delete("/users/{id}", status_code=204)
|
||||
def delete_user(id: int, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
|
||||
u = get_or_404(db, User, id, "Пользователь")
|
||||
if u.id == admin.id:
|
||||
refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить свою учётную запись")
|
||||
if u.role == Role.admin and u.is_active and _other_active_admins(db, u.id) == 0:
|
||||
refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить единственного активного администратора")
|
||||
audit(db, admin, "user", u, "deleted", u.username)
|
||||
db.delete(u)
|
||||
commit(db)
|
||||
|
||||
|
||||
@router.post("/users/me/password")
|
||||
def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||
"""Смена своего пароля: доступна любой роли, требует текущий пароль."""
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(403, "Неверный текущий пароль")
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем")
|
||||
commit(db)
|
||||
return {"ok": True}
|
||||
+2
-2
@@ -9,7 +9,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.v1 import auth, journal, overview, prefixes, refs
|
||||
from app.api.v1 import auth, journal, overview, prefixes, refs, users
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import DeviceType, Role, User
|
||||
@@ -41,7 +41,7 @@ app = FastAPI(title="IPAM Manager API", version="1.0.0", lifespan=lifespan)
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
api = APIRouter(prefix="/api/v1")
|
||||
for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router):
|
||||
for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router, users.router):
|
||||
api.include_router(r)
|
||||
app.include_router(api)
|
||||
|
||||
|
||||
+59
-2
@@ -5,7 +5,7 @@ from typing import Annotated, Generic, TypeVar
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
from app.models import AddressStatus, PrefixStatus
|
||||
from app.models import AddressStatus, PrefixStatus, Role
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -50,9 +50,52 @@ class TokenOut(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
LOGIN_RE = re.compile(r"^[A-Za-z0-9._-]{3,100}$")
|
||||
RESERVED_LOGINS = {"system", "anonymous"} # заняты служебными акторами журнала (actor_of в app/services.py)
|
||||
|
||||
|
||||
def _login(v: str) -> str:
|
||||
v = v.strip()
|
||||
if not LOGIN_RE.match(v):
|
||||
raise ValueError("Логин: 3–100 символов — латиница, цифры, точка, дефис или подчёркивание")
|
||||
if v.lower() in RESERVED_LOGINS:
|
||||
raise ValueError("Логин зарезервирован системой")
|
||||
return v
|
||||
|
||||
|
||||
Login = Annotated[str, AfterValidator(_login)]
|
||||
|
||||
|
||||
class UserOut(ORM):
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
role: Role
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserIn(BaseModel):
|
||||
username: Login
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
role: Role = Role.admin
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
role: Role | None = None
|
||||
is_active: bool | None = None
|
||||
password: str | None = Field(None, min_length=8, max_length=128)
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def _differs(cls, v: str, info) -> str:
|
||||
if v == info.data.get("current_password"):
|
||||
raise ValueError("Новый пароль совпадает с текущим")
|
||||
return v
|
||||
|
||||
|
||||
# --- organizations
|
||||
@@ -188,6 +231,20 @@ class PrefixIn(BaseModel):
|
||||
note: str = ""
|
||||
|
||||
|
||||
class SubnetNextIn(BaseModel):
|
||||
length: int = Field(ge=1, le=128)
|
||||
description: str = Field("", max_length=500)
|
||||
status: PrefixStatus = PrefixStatus.active
|
||||
is_pool: bool = False
|
||||
note: str = ""
|
||||
|
||||
|
||||
class SubnetPreview(BaseModel):
|
||||
prefix: str | None
|
||||
length_min: int
|
||||
length_max: int
|
||||
|
||||
|
||||
class PrefixUpdate(BaseModel):
|
||||
vrf_id: int | None = None
|
||||
description: str | None = Field(None, max_length=500)
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from argon2.exceptions import VerificationError
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
@@ -23,7 +23,7 @@ def hash_password(password: str) -> str:
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return _ph.verify(hashed, password)
|
||||
except VerifyMismatchError:
|
||||
except VerificationError: # несовпадение пароля или повреждённый хэш — не 500, а 401/403
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ ANONYMOUS = SimpleNamespace(username="anonymous")
|
||||
_NOUNS = {
|
||||
"organization": ("Организация", "f"), "vrf": ("VRF", "m"), "prefix": ("Префикс", "m"), "address": ("Адрес", "m"),
|
||||
"device": ("Устройство", "n"), "device_type": ("Тип устройства", "m"), "isp": ("Оператор", "m"),
|
||||
"user": ("Пользователь", "m"),
|
||||
}
|
||||
_VERBS = {
|
||||
"created": ("создан", "создана", "создано"), "updated": ("изменён", "изменена", "изменено"),
|
||||
@@ -48,6 +49,27 @@ def audit(db: Session, user, entity_type: str, entity, action: str, label: str,
|
||||
))
|
||||
|
||||
|
||||
BLOCKERS_LIMIT = 20
|
||||
_GROUPS = {"prefixes": "префиксы", "devices": "устройства", "isps": "операторы", "addresses": "адреса"}
|
||||
|
||||
|
||||
def blockers(db: Session, labels) -> dict | None:
|
||||
"""Мешающие удалению объекты: `labels` — select одной текстовой колонки; {"total", "items"} (до BLOCKERS_LIMIT), None — если пусто."""
|
||||
total = count(db, labels)
|
||||
return {"total": total, "items": list(db.scalars(labels.limit(BLOCKERS_LIMIT)))} if total else None
|
||||
|
||||
|
||||
def refuse_delete(db: Session, user, entity_type: str, entity, label: str, reason: str, blocked_by: dict | None = None):
|
||||
"""Отказ в удалении (409): фиксируем предупреждение в журнале (`<entity>.delete_blocked`) и отвечаем прежним текстом `reason`."""
|
||||
blocked_by = {k: v for k, v in (blocked_by or {}).items() if v}
|
||||
diff = {"reason": reason, **({"blocked_by": blocked_by} if blocked_by else {})}
|
||||
noun = _NOUNS.get(entity_type, (entity_type, "m"))[0]
|
||||
tail = ("связанные объекты (" + ", ".join(f"{_GROUPS[k]}: {v['total']}" for k, v in blocked_by.items()) + ")") if blocked_by else reason[:1].lower() + reason[1:]
|
||||
audit(db, user, entity_type, entity, "delete_blocked", label, diff, message=f"{noun} {label}: удаление отклонено — {tail}")
|
||||
commit(db) # к этому моменту в транзакции только запись журнала
|
||||
raise HTTPException(409, reason)
|
||||
|
||||
|
||||
def commit(db: Session, conflict_msg: str = "Запись с такими значениями уже существует"):
|
||||
try:
|
||||
db.commit()
|
||||
@@ -97,6 +119,24 @@ def count(db: Session, stmt) -> int:
|
||||
return db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
|
||||
|
||||
def next_free_subnet(parent: str, length: int, occupied: list[tuple[int, int]]) -> str | None:
|
||||
"""Первый свободный блок длины `length` внутри `parent`, выровненный по размеру блока.
|
||||
`occupied` — занятые диапазоны адресов (включительно); без перебора всех подсетей: перескок за конец пересечения."""
|
||||
net = ipaddress.ip_network(parent)
|
||||
size = 1 << (net.max_prefixlen - length)
|
||||
first, last = int(net.network_address), int(net.broadcast_address)
|
||||
cand = first
|
||||
for start, end in sorted(occupied):
|
||||
if end < cand:
|
||||
continue
|
||||
if start >= cand + size:
|
||||
break # блок cand..cand+size-1 свободен
|
||||
cand = -(-(end + 1) // size) * size # за конец занятого диапазона, с выравниванием
|
||||
if cand + size - 1 > last:
|
||||
return None
|
||||
return str(ipaddress.ip_network((type(net.network_address)(cand), length)))
|
||||
|
||||
|
||||
def next_free(db: Session, prefix_id: int, prefix: str) -> str | None:
|
||||
net = ipaddress.ip_network(prefix)
|
||||
used = {ipaddress.ip_address(a) for a in db.scalars(select(Address.address).where(Address.prefix_id == prefix_id))}
|
||||
|
||||
Reference in new issue
Block a user