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( `
-
${Utils.escapeHtml(role.name)}
- ${role.description ? `
${Utils.escapeHtml(role.description)}
` : ""} -
- `, +
${Utils.escapeHtml(role.name)}
+ ${role.description ? `
${Utils.escapeHtml(role.description)}
` : ""} + + `, ) .appendTo($dropdown); }); @@ -139,13 +132,11 @@ $(document).ready(() => { } function loadUsers() { - const params = new URLSearchParams(); - params.append("skip", (currentPage - 1) * pageSize); - params.append("limit", pageSize); + const skip = (currentPage - 1) * pageSize; showLoadingState(); - Api.get(`/api/auth/users?${params.toString()}`) + Api.get(`/api/users?skip=${skip}&limit=${pageSize}`) .then((data) => { users = data.users; totalUsers = data.total; @@ -220,6 +211,8 @@ $(document).ready(() => { } const rolesContainer = clone.querySelector(".user-roles"); + let totalPayroll = 0; + if (user.roles && user.roles.length > 0) { user.roles.forEach((roleName) => { const badge = roleBadgeTpl.content.cloneNode(true); @@ -238,12 +231,25 @@ $(document).ready(() => { removeBtn.dataset.userId = user.id; removeBtn.dataset.roleName = roleName; rolesContainer.appendChild(badge); + + const fullRole = allRoles.find((r) => r.name === roleName); + if (fullRole && fullRole.payroll) { + totalPayroll += fullRole.payroll; + } }); } else { rolesContainer.innerHTML = 'Нет ролей'; } + if (totalPayroll > 0) { + const payrollBadge = clone.querySelector(".user-payroll"); + const payrollAmount = clone.querySelector(".user-payroll-amount"); + + payrollBadge.classList.remove("hidden"); + payrollAmount.textContent = totalPayroll.toLocaleString("ru-RU"); + } + const addRoleBtn = clone.querySelector(".add-role-btn"); addRoleBtn.dataset.userId = user.id; @@ -265,30 +271,30 @@ $(document).ready(() => { function showLoadingState() { $("#users-container").html(` -
- ${Array(3) - .fill() - .map( - () => ` -
-
-
-
-
-
-
-
-
-
-
-
-
-
- `, - ) - .join("")} +
+ ${Array(3) + .fill() + .map( + () => ` +
+
+
+
+
+
+
+
+
+
+
+
+
- `); + `, + ) + .join("")} +
+ `); } function renderPagination() { @@ -297,12 +303,12 @@ $(document).ready(() => { if (totalPages <= 1) return; const $pagination = $(` -
- -
- -
- `); +
+ +
+ +
+ `); const $pageNumbers = $pagination.find("#page-numbers"); const pages = generatePageNumbers(currentPage, totalPages); @@ -313,8 +319,8 @@ $(document).ready(() => { } else { const isActive = page === currentPage; $pageNumbers.append(` - - `); + + `); } }); @@ -383,13 +389,13 @@ $(document).ready(() => { } const $dropdown = $(` -
-
- -
-
-
- `); +
+
+ +
+
+
+ `); const $roleItems = $dropdown.find(".role-items"); @@ -402,12 +408,12 @@ $(document).ready(() => { : "hover:bg-gray-50"; $roleItems.append(` -
-
${Utils.escapeHtml(role.name)}
- ${role.description ? `
${Utils.escapeHtml(role.description)}
` : ""} - ${role.payroll ? `
Оклад: ${role.payroll}
` : ""} -
- `); +
+
${Utils.escapeHtml(role.name)}
+ ${role.description ? `
${Utils.escapeHtml(role.description)}
` : ""} + ${role.payroll ? `
Оклад: ${role.payroll}
` : ""} +
+ `); }); const $button = $(button); @@ -457,12 +463,9 @@ $(document).ready(() => { } function addRoleToUser(userId, roleName) { - Api.request( - `/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`, - { - method: "POST", - }, - ) + Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, { + method: "POST", + }) .then((updatedUser) => { const userIndex = users.findIndex((u) => u.id === userId); if (userIndex !== -1) { @@ -485,12 +488,9 @@ $(document).ready(() => { return; } - Api.request( - `/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`, - { - method: "DELETE", - }, - ) + Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, { + method: "DELETE", + }) .then((updatedUser) => { const userIndex = users.findIndex((u) => u.id === userId); if (userIndex !== -1) { @@ -514,7 +514,6 @@ $(document).ready(() => { $("#edit-user-fullname").val(user.full_name || ""); $("#edit-user-password").val(""); $("#edit-user-active").prop("checked", user.is_active); - $("#edit-user-verified").prop("checked", user.is_verified); $("#edit-user-modal").removeClass("hidden"); } @@ -529,6 +528,7 @@ $(document).ready(() => { const email = $("#edit-user-email").val().trim(); const fullName = $("#edit-user-fullname").val().trim(); const password = $("#edit-user-password").val(); + const isActive = $("#edit-user-active").prop("checked"); if (!email) { Utils.showToast("Email обязателен", "error"); @@ -538,36 +538,26 @@ $(document).ready(() => { const updateData = { email: email, full_name: fullName || null, + is_active: isActive, }; if (password) { updateData.password = password; } - Api.put(`/api/auth/me`, updateData) + Api.put(`/api/users/${userId}`, updateData) .then((updatedUser) => { const userIndex = users.findIndex((u) => u.id === userId); if (userIndex !== -1) { - users[userIndex] = { ...users[userIndex], ...updatedUser }; + users[userIndex] = updatedUser; } renderUsers(); closeEditModal(); Utils.showToast("Пользователь обновлён", "success"); }) .catch((error) => { - console.warn("API update failed, updating locally:", error); - const userIndex = users.findIndex((u) => u.id === userId); - if (userIndex !== -1) { - users[userIndex].email = email; - users[userIndex].full_name = fullName || null; - users[userIndex].is_active = $("#edit-user-active").prop("checked"); - users[userIndex].is_verified = $("#edit-user-verified").prop( - "checked", - ); - } - renderUsers(); - closeEditModal(); - Utils.showToast("Изменения сохранены локально", "info"); + console.error(error); + Utils.showToast(error.message || "Ошибка обновления", "error"); }); } @@ -582,7 +572,16 @@ $(document).ready(() => { } userToDelete = user; + + const actionText = user.is_active ? "деактивировать" : "удалить навсегда"; $("#delete-user-name").text(user.full_name || user.username); + $("#delete-user-modal .text-sm.text-gray-500").html( + `Вы уверены, что хотите ${actionText} пользователя ${Utils.escapeHtml(user.full_name || user.username)}?` + + (user.is_active + ? "" + : " Это действие необратимо!"), + ); + $("#delete-user-modal").removeClass("hidden"); } @@ -594,22 +593,27 @@ $(document).ready(() => { function confirmDeleteUser() { if (!userToDelete) return; - Utils.showToast("Удаление пользователей не поддерживается API", "error"); - closeDeleteModal(); - - // Api.delete(`/api/auth/users/${userToDelete.id}`) - // .then(() => { - // users = users.filter(u => u.id !== userToDelete.id); - // totalUsers--; - // $("#total-users-count").text(totalUsers); - // renderUsers(); - // closeDeleteModal(); - // Utils.showToast("Пользователь удалён", "success"); - // }) - // .catch((error) => { - // console.error(error); - // Utils.showToast(error.message || "Ошибка удаления", "error"); - // }); + Api.delete(`/api/users/${userToDelete.id}`) + .then((deletedUser) => { + if (deletedUser.is_active === false) { + const userIndex = users.findIndex((u) => u.id === userToDelete.id); + if (userIndex !== -1) { + users[userIndex] = deletedUser; + } + Utils.showToast("Пользователь деактивирован", "success"); + } else { + users = users.filter((u) => u.id !== userToDelete.id); + totalUsers--; + $("#total-users-count").text(totalUsers); + Utils.showToast("Пользователь удалён", "success"); + } + renderUsers(); + closeDeleteModal(); + }) + .catch((error) => { + console.error(error); + Utils.showToast(error.message || "Ошибка удаления", "error"); + }); } $("#users-container").on("click", ".add-role-btn", function (e) { diff --git a/library_service/templates/users.html b/library_service/templates/users.html index 7c1562e..6c8f08e 100644 --- a/library_service/templates/users.html +++ b/library_service/templates/users.html @@ -38,7 +38,7 @@ type="text" id="role-filter-input" placeholder="Фильтр по роли..." - class="w-full md:w-56 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" + class="w-full md:w-56 border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" autocomplete="off" />
-
+
Роли:
+ +
@@ -312,7 +321,7 @@ placeholder="••••••••" /> -
+
-
diff --git a/uv.lock b/uv.lock index a1aef1a..d1d14f5 100644 --- a/uv.lock +++ b/uv.lock @@ -619,7 +619,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32 [[package]] name = "libraryapi" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },