Files
ipam_control/app/api/v1/users.py
T
ayurishchevandClaude Sonnet 5 cd09ef0805 Задачи 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>
2026-09-26 13:25:33 +03:00

94 lines
5.4 KiB
Python

"""Пользователи: учётные записи 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}