2026-09-26 13:25:33 +03:00
|
|
|
|
"""Пользователи: учётные записи UI (роли admin/viewer), смена своего пароля.
|
|
|
|
|
|
|
|
|
|
|
|
Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить
|
|
|
|
|
|
свою учётную запись и последнего активного администратора; зарезервированные логины `system`
|
|
|
|
|
|
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
|
|
|
|
|
|
"""
|
2026-09-26 21:33:50 +03:00
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
2026-09-26 13:25:33 +03:00
|
|
|
|
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
|
2026-09-26 21:33:50 +03:00
|
|
|
|
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
|
2026-09-26 13:25:33 +03:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(dependencies=[Depends(current_user)], tags=["users"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 21:33:50 +03:00
|
|
|
|
USERS_ADMIN_LOCK = 703002 # advisory lock: изменения прав администраторов идут по одному (иначе двое отключат друг друга одновременно)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lock_admins(db: Session) -> None:
|
|
|
|
|
|
db.execute(select(func.pg_advisory_xact_lock(USERS_ADMIN_LOCK)))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 13:25:33 +03:00
|
|
|
|
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(
|
2026-09-26 21:33:50 +03:00
|
|
|
|
q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
2026-09-26 13:25:33 +03:00
|
|
|
|
db: Session = Depends(get_db), admin: User = Depends(admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
stmt = select(User)
|
|
|
|
|
|
if q.strip():
|
2026-09-26 21:33:50 +03:00
|
|
|
|
stmt = stmt.where(contains(User.username, q))
|
2026-09-26 13:25:33 +03:00
|
|
|
|
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)
|
2026-09-26 21:33:50 +03:00
|
|
|
|
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 (с подтверждением текущего)")
|
2026-09-26 13:25:33 +03:00
|
|
|
|
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)
|
2026-09-26 21:33:50 +03:00
|
|
|
|
u.password_changed_at = datetime.now(timezone.utc)
|
2026-09-26 13:25:33 +03:00
|
|
|
|
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)):
|
2026-09-26 21:33:50 +03:00
|
|
|
|
_lock_admins(db)
|
2026-09-26 13:25:33 +03:00
|
|
|
|
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)
|
2026-09-26 21:33:50 +03:00
|
|
|
|
user.password_changed_at = datetime.now(timezone.utc)
|
2026-09-26 13:25:33 +03:00
|
|
|
|
audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем")
|
|
|
|
|
|
commit(db)
|
2026-09-26 21:33:50 +03:00
|
|
|
|
return {"ok": True, "access_token": create_token(user)} # прежние токены недействительны — текущая сессия получает новый
|