"""Пользователи: учётные записи UI (роли admin/viewer), смена своего пароля. Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить свою учётную запись и последнего активного администратора; зарезервированные логины `system` и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py). """ from datetime import datetime, timezone 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, create_token, current_user, hash_password, verify_password from app.services import MAX_OFFSET, apply_update, audit, commit, contains, count, flush, get_or_404, refuse_delete router = APIRouter(dependencies=[Depends(current_user)], tags=["users"]) USERS_ADMIN_LOCK = 703002 # advisory lock: изменения прав администраторов идут по одному (иначе двое отключат друг друга одновременно) def _lock_admins(db: Session) -> None: db.execute(select(func.pg_advisory_xact_lock(USERS_ADMIN_LOCK))) 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, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db), admin: User = Depends(admin_user), ): stmt = select(User) if q.strip(): stmt = stmt.where(contains(User.username, q)) 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)): data = body.model_dump(exclude_unset=True, exclude_none=True) if "role" in data or "is_active" in data: _lock_admins(db) # до чтения пользователя и подсчёта администраторов u = get_or_404(db, User, id, "Пользователь") if u.id == admin.id and "password" in data: raise HTTPException(422, "Свой пароль меняется через /users/me/password (с подтверждением текущего)") 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) u.password_changed_at = datetime.now(timezone.utc) 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)): _lock_admins(db) 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) user.password_changed_at = datetime.now(timezone.utc) audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем") commit(db) return {"ok": True, "access_token": create_token(user)} # прежние токены недействительны — текущая сессия получает новый