Files
lux_fiscal/backend/app/api/v1/users.py
T
lauadminandClaude Sonnet 5 2664cb8213 Initial commit: backend scaffold, auth, frontend login
- FastAPI + SQLAlchemy async + Alembic + Postgres backend
- Auth: JWT access + rotating refresh tokens, argon2, roles, audit log
- React 19 + Vite frontend: login page, protected route, auth context
- Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:07:15 +03:00

134 lines
4.5 KiB
Python

"""Управление пользователями. Доступно только администратору."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.api.deps import CurrentUser, SessionDep, require_admin
from app.core.security import hash_password
from app.db.models.audit import AuditAction
from app.db.models.user import User, UserRole
from app.schemas.auth import UserOut
from app.services import audit, auth
router = APIRouter(
prefix="/users", tags=["users"], dependencies=[Depends(require_admin)]
)
class UserCreate(BaseModel):
email: EmailStr
full_name: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=10, max_length=128)
role: UserRole = UserRole.CASHIER
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
full_name: str | None = Field(default=None, min_length=1, max_length=255)
role: UserRole | None = None
is_active: bool | None = None
password: str | None = Field(default=None, min_length=10, max_length=128)
@router.get("", response_model=list[UserOut])
async def list_users(session: SessionDep) -> list[UserOut]:
users = await session.scalars(select(User).order_by(User.created_at.desc()))
return [UserOut.model_validate(user) for user in users]
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
payload: UserCreate, request: Request, session: SessionDep, actor: CurrentUser
) -> UserOut:
user = User(
email=payload.email.strip().lower(),
full_name=payload.full_name,
password_hash=hash_password(payload.password),
role=payload.role,
)
session.add(user)
await audit.record(
session,
action=AuditAction.USER_CREATED,
user=actor,
entity_type="user",
entity_id=user.id,
payload={"email": user.email, "role": user.role.value},
request=request,
)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Пользователь с таким email уже существует",
) from exc
return UserOut.model_validate(user)
@router.patch("/{user_id}", response_model=UserOut)
async def update_user(
user_id: uuid.UUID,
payload: UserUpdate,
request: Request,
session: SessionDep,
actor: CurrentUser,
) -> UserOut:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
changes = payload.model_dump(exclude_unset=True)
# Администратор, снявший с себя роль или отключивший себя, потеряет доступ
# к админке — и вернуть его будет некому.
if user.id == actor.id:
if changes.get("is_active") is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя отключить собственную учётную запись",
)
if "role" in changes and changes["role"] != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя снять с себя роль администратора",
)
if (new_password := changes.pop("password", None)) is not None:
user.password_hash = hash_password(new_password)
await auth.revoke_all_for_user(session, user.id)
for field, value in changes.items():
setattr(user, field, value)
# Отключение учётки обязано немедленно завершить её активные сессии.
if changes.get("is_active") is False:
await auth.revoke_all_for_user(session, user.id)
await audit.record(
session,
action=AuditAction.USER_UPDATED,
user=actor,
entity_type="user",
entity_id=user.id,
payload={
"fields": sorted(changes),
"password_changed": new_password is not None,
},
request=request,
)
await session.commit()
return UserOut.model_validate(user)