Задачи 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}
|
||||
Reference in new issue
Block a user