diff --git a/README.md b/README.md index bd67a24..d877b08 100644 --- a/README.md +++ b/README.md @@ -67,105 +67,124 @@ #### **Аутентификация** (`/api/auth`) -| Метод | Эндпоинт | Доступ | Описание | -|--------|-----------------------------------------------|----------------|------------------------------------------| -| POST | `/api/auth/register` | Публичный | Регистрация нового пользователя | -| POST | `/api/auth/token` | Публичный | Получение JWT токенов (access + refresh) | -| POST | `/api/auth/refresh` | Публичный | Обновление пары токенов | -| GET | `/api/auth/me` | Авторизованный | Информация о текущем пользователе | -| PUT | `/api/auth/me` | Авторизованный | Обновление профиля текущего пользователя | -| GET | `/api/auth/users` | Сотрудник | Список всех пользователей | -| POST | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю | -| DELETE | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя | -| GET | `/api/auth/roles` | Авторизованный | Список ролей в системе | +| Метод | Эндпоинт | Доступ | Описание | +|--------|------------------------------|----------------|------------------------------------------| +| POST | `/register` | Публичный | Регистрация нового пользователя | +| POST | `/token` | Публичный | Получение JWT токенов (access + refresh) | +| POST | `/refresh` | Публичный | Обновление пары токенов | +| GET | `/me` | Авторизованный | Информация о текущем пользователе | +| PUT | `/me` | Авторизованный | Обновление профиля текущего пользователя | +| GET | `/2fa` | Авторизованный | Создаёт QR-код для включения 2FA | +| POST | `/2fa/verify` | Неполный вход | Завершает вход при включеной 2FA | +| POST | `/2fa/enable` | Авторизованный | Включает двухваткорную аутентификацию | +| POST | `/2fa/disable` | Авторизованный | Выключает двухваткорную аутентификацию | +| GET | `/recovery-codes/status` | Авторизованный | Проверяет состояние кодов восстановления | +| POST | `/recovery-codes/regenerate` | Авторизованный | Пересоздает коды восстановления пароля | +| POST | `/password/reset` | Публичный | Сброс пароля с помощью одноразового кода | #### **Авторы** (`/api/authors`) -| Метод | Эндпоинт | Доступ | Описание | -|--------|---------------------|-----------|---------------------------------| -| POST | `/api/authors` | Сотрудник | Создать нового автора | -| GET | `/api/authors` | Публичный | Получить список всех авторов | -| GET | `/api/authors/{id}` | Публичный | Получить автора по ID с книгами | -| PUT | `/api/authors/{id}` | Сотрудник | Обновить автора по ID | -| DELETE | `/api/authors/{id}` | Сотрудник | Удалить автора по ID | +| Метод | Эндпоинт | Доступ | Описание | +|--------|----------|-----------|---------------------------------| +| POST | `/` | Сотрудник | Создать нового автора | +| GET | `/` | Публичный | Получить список всех авторов | +| GET | `/{id}` | Публичный | Получить автора по ID с книгами | +| PUT | `/{id}` | Сотрудник | Обновить автора по ID | +| DELETE | `/{id}` | Сотрудник | Удалить автора по ID | #### **Книги** (`/api/books`) -| Метод | Эндпоинт | Доступ | Описание | -|--------|---------------------|-----------|-----------------------------------------------------------| -| GET | `/api/books/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам с пагинацией | -| POST | `/api/books` | Сотрудник | Создать новую книгу | -| GET | `/api/books` | Публичный | Получить список всех книг | -| GET | `/api/books/{id}` | Публичный | Получить книгу по ID с авторами и жанрами | -| PUT | `/api/books/{id}` | Сотрудник | Обновить книгу по ID | -| DELETE | `/api/books/{id}` | Сотрудник | Удалить книгу по ID | +| Метод | Эндпоинт | Доступ | Описание | +|--------|-----------|-----------|----------------------------------------------| +| POST | `/` | Сотрудник | Создать новую книгу | +| GET | `/` | Публичный | Получить список всех книг | +| GET | `/{id}` | Публичный | Получить книгу по ID с авторами и жанрами | +| PUT | `/{id}` | Сотрудник | Обновить книгу по ID | +| DELETE | `/{id}` | Сотрудник | Удалить книгу по ID | +| GET | `/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам | #### **Жанры** (`/api/genres`) -| Метод | Эндпоинт | Доступ | Описание | -|--------|--------------------|-----------|-------------------------------| -| POST | `/api/genres` | Сотрудник | Создать новый жанр | -| GET | `/api/genres` | Публичный | Получить список всех жанров | -| GET | `/api/genres/{id}` | Публичный | Получить жанр по ID с книгами | -| PUT | `/api/genres/{id}` | Сотрудник | Обновить жанр по ID | -| DELETE | `/api/genres/{id}` | Сотрудник | Удалить жанр по ID | +| Метод | Эндпоинт | Доступ | Описание | +|--------|----------|-----------|-------------------------------| +| POST | `/` | Сотрудник | Создать новый жанр | +| GET | `/` | Публичный | Получить список всех жанров | +| GET | `/{id}` | Публичный | Получить жанр по ID с книгами | +| PUT | `/{id}` | Сотрудник | Обновить жанр по ID | +| DELETE | `/{id}` | Сотрудник | Удалить жанр по ID | #### **Выдачи** (`/api/loans`) -| Метод | Эндпоинт | Доступ | Описание | -|--------|------------------------------------|----------------|--------------------------------------------------------------| -| POST | `/api/loans` | Авторизованный | Создать выдачу/бронь (читатели для себя, Сотрудник для всех) | -| GET | `/api/loans` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) | -| GET | `/api/loans/analytics` | Админ | Аналитика выдач и возвратов | -| GET | `/api/loans/{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) | -| PUT | `/api/loans/{id}` | Авторизованный | Обновить выдачу (читатели только свои) | -| POST | `/api/loans/{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) | -| POST | `/api/loans/{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу | -| DELETE | `/api/loans/{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) | -| GET | `/api/loans/book/{book_id}/active` | Сотрудник | Получить активную выдачу книги | -| POST | `/api/loans/issue` | Админ | Выдать книгу напрямую без бронирования | +| Метод | Эндпоинт | Доступ | Описание | +|--------|-------------------------|----------------|------------------------------------------------------------| +| POST | `/` | Авторизованный | Создать выдачу/бронь (читатели на себя, cотрудник на всех) | +| GET | `/` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) | +| GET | `{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) | +| PUT | `{id}` | Авторизованный | Обновить выдачу (читатели только свои) | +| DELETE | `{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) | +| POST | `{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) | +| POST | `{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу | +| GET | `book/{book_id}/active` | Сотрудник | Получить активную выдачу книги | +| POST | `issue` | Админ | Выдать книгу напрямую без бронирования | +| GET | `analytics` | Админ | Аналитика выдач и возвратов | #### **Связи** (`/api`) -| Метод | Эндпоинт | Доступ | Описание | -|--------|----------------------------------|-----------|-------------------------------| -| POST | `/api/relationships/author-book` | Сотрудник | Связать автора и книгу | -| DELETE | `/api/relationships/author-book` | Сотрудник | Удалить связь автор-книга | -| GET | `/api/authors/{id}/books` | Публичный | Получить список книг автора | -| GET | `/api/books/{id}/authors` | Публичный | Получить список авторов книги | -| POST | `/api/relationships/genre-book` | Сотрудник | Связать жанр и книгу | -| DELETE | `/api/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга | -| GET | `/api/genres/{id}/books` | Публичный | Получить список книг жанра | -| GET | `/api/books/{id}/genres` | Публичный | Получить список жанров книги | +| Метод | Эндпоинт | Доступ | Описание | +|--------|------------------------------|-----------|-------------------------------| +| POST | `/relationships/author-book` | Сотрудник | Связать автора и книгу | +| DELETE | `/relationships/author-book` | Сотрудник | Удалить связь автор-книга | +| GET | `/authors/{id}/books` | Публичный | Получить список книг автора | +| GET | `/books/{id}/authors` | Публичный | Получить список авторов книги | +| POST | `/relationships/genre-book` | Сотрудник | Связать жанр и книгу | +| DELETE | `/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга | +| GET | `/genres/{id}/books` | Публичный | Получить список книг жанра | +| GET | `/books/{id}/genres` | Публичный | Получить список жанров книги | + + +#### **Пользователи** (`/api/users`) + +| Метод | Эндпоинт | Доступ | Описание | +|--------|-------------------------------|----------------|------------------------------| +| POST | `/` | Админ | Создать нового пользователя | +| GET | `/` | Админ | Список всех пользователей | +| GET | `/{id}` | Админ | Получить пользователя по ID | +| PUT | `/{id}` | Админ | Обновить пользователя по ID | +| DELETE | `/{id}` | Админ | Удалить пользователя по ID | +| POST | `/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю | +| DELETE | `/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя | +| GET | `/roles` | Авторизованный | Список ролей в системе | + #### **Прочее** (`/api`) -| Метод | Эндпоинт | Доступ | Описание | -|-------|--------------|-----------|----------------------| -| GET | `/api/info` | Публичный | Информация о сервисе | -| GET | `/api/stats` | Публичный | Статистика системы | +| Метод | Эндпоинт | Доступ | Описание | +|-------|----------|-----------|----------------------| +| GET | `/info` | Публичный | Информация о сервисе | +| GET | `/stats` | Публичный | Статистика системы | ### **Веб-страницы** -| Путь | Доступ | Описание | -|---------------------|----------------|-----------------------------------------| -| `/` | Публичный | Главная страница | -| `/auth` | Публичный | Страница авторизации | -| `/profile` | Авторизованный | Профиль пользователя | -| `/books` | Публичный | Каталог книг с фильтрацией | -| `/book/{id}` | Публичный | Страница просмотра книги | -| `/book/create` | Сотрудник | Создание новой книги | -| `/book/{id}/edit` | Сотрудник | Редактирование книги | -| `/authors` | Публичный | Список авторов | -| `/author/{id}` | Публичный | Страница автора | -| `/author/create` | Сотрудник | Создание автора | -| `/author/{id}/edit` | Сотрудник | Редактирование автора | -| `/genre/create` | Сотрудник | Создание жанра | -| `/genre/{id}/edit` | Сотрудник | Редактирование жанра | -| `/my-books` | Авторизованный | Мои выдачи | -| `/users` | Сотрудник | Управление пользователями | -| `/analytics` | Админ | Аналитика выдач и возвратов | -| `/api` | Публичный | Страница с ссылками на документацию API | +| Путь | Доступ | Описание | +|---------------------|----------------|-----------------------------| +| `/` | Публичный | Главная страница | +| `/api` | Публичный | Ссылки на документацию | +| `/auth` | Публичный | Страница авторизации | +| `/profile` | Авторизованный | Профиль пользователя | +| `/books` | Публичный | Каталог книг с фильтрацией | +| `/book/{id}` | Публичный | Страница просмотра книги | +| `/book/create` | Сотрудник | Создание новой книги | +| `/book/{id}/edit` | Сотрудник | Редактирование книги | +| `/authors` | Публичный | Список авторов | +| `/author/{id}` | Публичный | Страница автора | +| `/author/create` | Сотрудник | Создание автора | +| `/author/{id}/edit` | Сотрудник | Редактирование автора | +| `/genre/create` | Сотрудник | Создание жанра | +| `/genre/{id}/edit` | Сотрудник | Редактирование жанра | +| `/my-books` | Авторизованный | Мои выдачи | +| `/users` | Админ | Управление пользователями | +| `/analytics` | Админ | Аналитика выдач и возвратов | + ### **Схема базы данных** diff --git a/library_service/auth/core.py b/library_service/auth/core.py index ee0b691..982bdf7 100644 --- a/library_service/auth/core.py +++ b/library_service/auth/core.py @@ -11,7 +11,7 @@ from jose import jwt, JWTError, ExpiredSignatureError from passlib.context import CryptContext from sqlmodel import Session, select -from library_service.models.db import Role, User +from library_service.models.db import User from library_service.models.dto import TokenData from library_service.settings import get_session, get_logger diff --git a/library_service/auth/recovery.py b/library_service/auth/recovery.py index e9b7ef3..393daba 100644 --- a/library_service/auth/recovery.py +++ b/library_service/auth/recovery.py @@ -1,6 +1,5 @@ """Модуль резервных кодов восстановления пароля""" -import os import secrets from datetime import datetime, timezone, timedelta diff --git a/library_service/auth/seed.py b/library_service/auth/seed.py index 297a519..c6094a6 100644 --- a/library_service/auth/seed.py +++ b/library_service/auth/seed.py @@ -6,7 +6,7 @@ from sqlmodel import Session, select from library_service.models.db import Role, User from .core import get_password_hash -from library_service.settings import get_session, get_logger +from library_service.settings import get_logger # Получение логгера logger = get_logger() diff --git a/library_service/models/dto/__init__.py b/library_service/models/dto/__init__.py index 133128d..8022cbe 100644 --- a/library_service/models/dto/__init__.py +++ b/library_service/models/dto/__init__.py @@ -8,7 +8,7 @@ from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogi from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse from .token import Token, TokenData, PartialToken -from .combined import ( +from .misc import ( AuthorWithBooks, GenreWithBooks, BookWithAuthors, @@ -19,6 +19,8 @@ from .combined import ( LoanWithBook, LoginResponse, RegisterResponse, + UserCreateByAdmin, + UserUpdateByAdmin, TOTPSetupResponse, TOTPVerifyRequest, TOTPDisableRequest, @@ -67,6 +69,8 @@ __all__ = [ "TOTPVerifyRequest", "TOTPDisableRequest", "RecoveryCodeUse", + "UserCreateByAdmin", + "UserUpdateByAdmin", "LoginResponse", "RegisterResponse", "RecoveryCodesStatus", diff --git a/library_service/models/dto/combined.py b/library_service/models/dto/misc.py similarity index 85% rename from library_service/models/dto/combined.py rename to library_service/models/dto/misc.py index e91a743..d859a97 100644 --- a/library_service/models/dto/combined.py +++ b/library_service/models/dto/misc.py @@ -1,4 +1,4 @@ -"""Модуль объединёных объектов""" +"""Модуль разных моделей""" from datetime import datetime from typing import List @@ -11,8 +11,8 @@ from .book import BookRead from .loan import LoanRead from ..enums import BookStatus -from .user import UserRead -from .recovery import RecoveryCodesResponse, RecoveryCodesStatus +from .user import UserCreate, UserRead, UserUpdate +from .recovery import RecoveryCodesResponse class AuthorWithBooks(SQLModel): @@ -80,6 +80,20 @@ class BookStatusUpdate(SQLModel): status: str +class UserCreateByAdmin(UserCreate): + """Создание пользователя администратором""" + + is_active: bool = True + roles: list[str] | None = None + + +class UserUpdateByAdmin(UserUpdate): + """Обновление пользователя администратором""" + + is_active: bool | None = None + roles: list[str] | None = None + + class LoginResponse(SQLModel): """Модель для авторизации пользователя""" diff --git a/library_service/routers/__init__.py b/library_service/routers/__init__.py index 80f3b3d..706eb46 100644 --- a/library_service/routers/__init__.py +++ b/library_service/routers/__init__.py @@ -1,4 +1,5 @@ """Модуль объединения роутеров""" + from fastapi import APIRouter from .auth import router as auth_router @@ -7,6 +8,7 @@ from .books import router as books_router from .genres import router as genres_router from .loans import router as loans_router from .relationships import router as relationships_router +from .users import router as users_router from .misc import router as misc_router @@ -20,4 +22,5 @@ api_router.include_router(authors_router, prefix="/api") api_router.include_router(books_router, prefix="/api") api_router.include_router(genres_router, prefix="/api") api_router.include_router(loans_router, prefix="/api") +api_router.include_router(users_router, prefix="/api") api_router.include_router(relationships_router, prefix="/api") diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py index 7623d9c..25ac895 100644 --- a/library_service/routers/auth.py +++ b/library_service/routers/auth.py @@ -2,13 +2,10 @@ from datetime import timedelta from typing import Annotated -from pathlib import Path -from fastapi import APIRouter, Body, Depends, HTTPException, status, Request +from fastapi import APIRouter, Body, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm -from fastapi.templating import Jinja2Templates from sqlmodel import Session, select -import pyotp from library_service.models.db import Role, User from library_service.models.dto import ( @@ -56,7 +53,6 @@ from library_service.auth import ( ) -templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -157,7 +153,7 @@ def login( "/refresh", response_model=Token, summary="Обновление токена", - description="Получение новой пары токенов (Access + Refresh) используя действующий Refresh токен", + description="Получение новой пары токенов, используя действующий Refresh токен", ) def refresh_token( refresh_token: str = Body(..., embed=True), @@ -243,131 +239,6 @@ def update_user_me( ) -@router.get( - "/users", - response_model=UserList, - summary="Список пользователей", - description="Получить список всех пользователей (только для админов)", -) -def read_users( - current_user: RequireStaff, - skip: int = 0, - limit: int = 100, - session: Session = Depends(get_session), -): - """Возвращает список всех пользователей""" - users = session.exec(select(User).offset(skip).limit(limit)).all() - return UserList( - users=[ - UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) - for user in users - ], - total=len(users), - ) - - -@router.post( - "/users/{user_id}/roles/{role_name}", - response_model=UserRead, - summary="Назначить роль пользователю", - description="Добавить указанную роль пользователю", -) -def add_role_to_user( - user_id: int, - role_name: str, - admin: RequireAdmin, - session: Session = Depends(get_session), -): - """Добавляет роль пользователю""" - user = session.get(User, user_id) - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", - ) - - role = session.exec(select(Role).where(Role.name == role_name)).first() - if not role: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Role '{role_name}' not found", - ) - - if role in user.roles: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="User already has this role", - ) - - user.roles.append(role) - session.add(user) - session.commit() - session.refresh(user) - - return UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) - - -@router.delete( - "/users/{user_id}/roles/{role_name}", - response_model=UserRead, - summary="Удалить роль у пользователя", - description="Убрать указанную роль у пользователя", -) -def remove_role_from_user( - user_id: int, - role_name: str, - admin: RequireAdmin, - session: Session = Depends(get_session), -): - """Удаляет роль у пользователя""" - user = session.get(User, user_id) - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", - ) - - role = session.exec(select(Role).where(Role.name == role_name)).first() - if not role: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Role '{role_name}' not found", - ) - - if role not in user.roles: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="User does not have this role", - ) - - user.roles.remove(role) - session.add(user) - session.commit() - session.refresh(user) - - return UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) - - -@router.get( - "/roles", - response_model=RoleList, - summary="Получить список ролей", - description="Возвращает список ролей", -) -def get_roles( - auth: RequireAuth, - session: Session = Depends(get_session), -): - """Возвращает список ролей в системе""" - user_roles = [role.name for role in auth.roles] - exclude = {"payroll"} if "admin" in user_roles else set() - roles = session.exec(select(Role)).all() - return RoleList( - roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles], - total=len(roles), - ) - - @router.get( "/2fa", response_model=TOTPSetupResponse, diff --git a/library_service/routers/books.py b/library_service/routers/books.py index b9db8ef..6002423 100644 --- a/library_service/routers/books.py +++ b/library_service/routers/books.py @@ -25,7 +25,7 @@ from library_service.models.dto import ( BookUpdate, GenreRead, ) -from library_service.models.dto.combined import ( +from library_service.models.dto.misc import ( BookWithAuthorsAndGenres, BookFilteredList, ) @@ -71,13 +71,17 @@ def filter_books( if author_ids: statement = statement.join(AuthorBookLink).where( - AuthorBookLink.author_id.in_(author_ids) - ) # ty: ignore[unresolved-attribute, unresolved-reference] + AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference] + author_ids + ) + ) if genre_ids: statement = statement.join(GenreBookLink).where( - GenreBookLink.genre_id.in_(genre_ids) - ) # ty: ignore[unresolved-attribute, unresolved-reference] + GenreBookLink.genre_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference] + genre_ids + ) + ) total_statement = select(func.count()).select_from(statement.subquery()) total = session.exec(total_statement).one() diff --git a/library_service/routers/users.py b/library_service/routers/users.py new file mode 100644 index 0000000..6f5cc9f --- /dev/null +++ b/library_service/routers/users.py @@ -0,0 +1,302 @@ +"""Модуль управления пользователями (для администраторов)""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Session, select + +from library_service.models.db import Role, User +from library_service.models.dto import ( + RoleRead, + RoleList, + UserRead, + UserList, + UserCreateByAdmin, + UserUpdateByAdmin, +) +from library_service.settings import get_session +from library_service.auth import ( + RequireAuth, + RequireAdmin, + RequireStaff, + get_password_hash, +) + + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get( + "/roles", + response_model=RoleList, + summary="Список ролей", +) +def get_roles( + auth: RequireAuth, + session: Session = Depends(get_session), +): + """Возвращает список ролей в системе""" + user_roles = [role.name for role in auth.roles] + exclude = {"payroll"} if "admin" not in user_roles else set() + roles = session.exec(select(Role)).all() + + return RoleList( + roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles], + total=len(roles), + ) + + +@router.get( + "/", + response_model=UserList, + summary="Список пользователей", +) +def list_users( + current_user: RequireStaff, + skip: int = 0, + limit: int = 100, + session: Session = Depends(get_session), +): + """Возвращает список всех пользователей""" + users = session.exec(select(User).offset(skip).limit(limit)).all() + total = session.exec(select(User)).all() + + return UserList( + users=[ + UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) + for user in users + ], + total=len(total), + ) + + +@router.post( + "/", + response_model=UserRead, + status_code=status.HTTP_201_CREATED, + summary="Создать пользователя", +) +def create_user( + user_data: UserCreateByAdmin, + current_user: RequireAdmin, + session: Session = Depends(get_session), +): + """Создает пользователя (без резервных кодов)""" + if session.exec(select(User).where(User.username == user_data.username)).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered", + ) + + if session.exec(select(User).where(User.email == user_data.email)).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + + db_user = User( + username=user_data.username, + email=user_data.email, + full_name=user_data.full_name, + hashed_password=get_password_hash(user_data.password), + is_active=user_data.is_active, + ) + + if user_data.roles: + for role_name in user_data.roles: + role = session.exec(select(Role).where(Role.name == role_name)).first() + if role: + db_user.roles.append(role) + else: + default_role = session.exec(select(Role).where(Role.name == "member")).first() + if default_role: + db_user.roles.append(default_role) + + session.add(db_user) + session.commit() + session.refresh(db_user) + + return UserRead(**db_user.model_dump(), roles=[r.name for r in db_user.roles]) + + +@router.get( + "/{user_id}", + response_model=UserRead, + summary="Получить пользователя", +) +def get_user( + user_id: int, + current_user: RequireStaff, + session: Session = Depends(get_session), +): + """Возвращает пользователя по ID""" + user = session.get(User, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + return UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) + + +@router.put( + "/{user_id}", + response_model=UserRead, + summary="Обновить пользователя", +) +def update_user( + user_id: int, + user_data: UserUpdateByAdmin, + current_user: RequireAdmin, + session: Session = Depends(get_session), +): + """Обновляет данные пользователя""" + user = session.get(User, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + if user_data.email and user_data.email != user.email: + existing = session.exec( + select(User).where(User.email == user_data.email) + ).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + user.email = user_data.email + + if user_data.full_name is not None: + user.full_name = user_data.full_name + + if user_data.password: + user.hashed_password = get_password_hash(user_data.password) + + if user_data.is_active is not None: + user.is_active = user_data.is_active + + if user_data.roles is not None: + user.roles.clear() + for role_name in user_data.roles: + role = session.exec(select(Role).where(Role.name == role_name)).first() + if role: + user.roles.append(role) + + session.add(user) + session.commit() + session.refresh(user) + + return UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) + + +@router.delete( + "/{user_id}", + response_model=UserRead, + summary="Удалить пользователя", +) +def delete_user( + user_id: int, + current_user: RequireAdmin, + session: Session = Depends(get_session), +): + """Деактивирует пользователя, при повторном вызове — удаляет физически""" + user = session.get(User, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + if user.is_active: + user.is_active = False + session.add(user) + session.commit() + session.refresh(user) + return UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) + else: + user_read = UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) + session.delete(user) + session.commit() + return user_read + + +@router.post( + "/{user_id}/roles/{role_name}", + response_model=UserRead, + summary="Назначить роль пользователю", +) +def add_role_to_user( + user_id: int, + role_name: str, + current_user: RequireAdmin, + session: Session = Depends(get_session), +): + """Добавляет роль пользователю""" + user = session.get(User, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + role = session.exec(select(Role).where(Role.name == role_name)).first() + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Role '{role_name}' not found", + ) + + if role in user.roles: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User already has this role", + ) + + user.roles.append(role) + session.add(user) + session.commit() + session.refresh(user) + + return UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) + + +@router.delete( + "/{user_id}/roles/{role_name}", + response_model=UserRead, + summary="Удалить роль у пользователя", +) +def remove_role_from_user( + user_id: int, + role_name: str, + current_user: RequireAdmin, + session: Session = Depends(get_session), +): + """Удаляет роль у пользователя""" + user = session.get(User, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + role = session.exec(select(Role).where(Role.name == role_name)).first() + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Role '{role_name}' not found", + ) + + if role not in user.roles: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User does not have this role", + ) + + user.roles.remove(role) + session.add(user) + session.commit() + session.refresh(user) + + return UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) diff --git a/library_service/settings.py b/library_service/settings.py index da14ad9..8231e80 100644 --- a/library_service/settings.py +++ b/library_service/settings.py @@ -60,6 +60,7 @@ OPENAPI_TAGS = [ {"name": "genres", "description": "Действия с жанрами."}, {"name": "loans", "description": "Действия с выдачами."}, {"name": "relations", "description": "Действия со связями."}, + {"name": "users", "description": "Действия с пользователями."}, {"name": "misc", "description": "Прочие."}, ] diff --git a/library_service/static/page/profile.js b/library_service/static/page/profile.js index 98f61a0..333dbcc 100644 --- a/library_service/static/page/profile.js +++ b/library_service/static/page/profile.js @@ -13,7 +13,7 @@ $(document).ready(() => { function loadProfile() { Promise.all([ Api.get("/api/auth/me"), - Api.get("/api/auth/roles").catch(() => ({ roles: [] })), + Api.get("/api/users/roles").catch(() => ({ roles: [] })), Api.get("/api/auth/recovery-codes/status").catch(() => null), ]) .then(async ([user, rolesData, recoveryStatus]) => { diff --git a/library_service/static/page/users.js b/library_service/static/page/users.js index 8299dc2..d439e28 100644 --- a/library_service/static/page/users.js +++ b/library_service/static/page/users.js @@ -5,18 +5,11 @@ $(document).ready(() => { ); return; } - setTimeout(() => { - if (!window.isAdmin()) { - $("#users-container").html( - document.getElementById("access-denied-template").innerHTML, - ); - } - }, 100); let allRoles = []; let users = []; let currentPage = 1; - let pageSize = 20; + const pageSize = 20; let totalUsers = 0; let searchQuery = ""; let selectedFilterRoles = new Set(); @@ -28,8 +21,8 @@ $(document).ready(() => { showLoadingState(); Promise.all([ - Api.get("/api/auth/users?skip=0&limit=100"), - Api.get("/api/auth/roles"), + Api.get("/api/users?skip=0&limit=100"), + Api.get("/api/users/roles"), ]) .then(([usersData, rolesData]) => { users = usersData.users; @@ -57,12 +50,12 @@ $(document).ready(() => { .attr("data-name", role.name) .html( `