Задачи 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:
ayurishchevandClaude Sonnet 5 committed 2026-09-26 13:25:33 +03:00
1 parent a846d30872
commit cd09ef0805
23 files changed
+988 -49

No files matched your search

+44 -3
View File
@@ -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
View File
@@ -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)
+93
View File
@@ -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}