From 5a814d99e675bf7e70b8b0e18370bebcac34088a Mon Sep 17 00:00:00 2001 From: wowlikon Date: Wed, 24 Dec 2025 06:50:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 7 +- README.md | 238 +++++++---- library_service/auth.py | 51 ++- library_service/models/db/__init__.py | 2 + library_service/models/dto/loan.py | 2 + library_service/routers/__init__.py | 4 + library_service/routers/auth.py | 25 +- library_service/routers/authors.py | 19 +- library_service/routers/books.py | 68 ++- library_service/routers/genres.py | 23 +- library_service/routers/loans.py | 506 ++++++++++++++++++++++ library_service/routers/misc.py | 52 ++- library_service/routers/relationships.py | 34 +- library_service/settings.py | 10 +- library_service/static/analytics.js | 262 ++++++++++++ library_service/static/book.js | 518 ++++++++++++++++++----- library_service/static/my_books.js | 248 +++++++++++ library_service/templates/analytics.html | 142 +++++++ library_service/templates/base.html | 57 ++- library_service/templates/book.html | 164 ++++++- library_service/templates/my_books.html | 50 +++ 21 files changed, 2170 insertions(+), 312 deletions(-) create mode 100644 library_service/routers/loans.py create mode 100644 library_service/static/analytics.js create mode 100644 library_service/static/my_books.js create mode 100644 library_service/templates/analytics.html create mode 100644 library_service/templates/my_books.html diff --git a/.env b/.env index 04b5d6e..4e4f45b 100644 --- a/.env +++ b/.env @@ -1,8 +1,13 @@ +ALGORITHM = "HS256" +REFRESH_TOKEN_EXPIRE_DAYS = "7" +ACCESS_TOKEN_EXPIRE_MINUTES = "20" +SECRET_KEY = "your-secret-key-change-in-production" + # DEFAULT_ADMIN_USERNAME = "admin" # DEFAULT_ADMIN_EMAIL = "admin@example.com" # DEFAULT_ADMIN_PASSWORD = "password-is-generated-randomly-on-first-launch" -POSTGRES_HOST = "db" +POSTGRES_HOST = "localhost" POSTGRES_PORT = "5432" POSTGRES_USER = "postgres" POSTGRES_PASSWORD = "postgres" diff --git a/README.md b/README.md index 1a3f662..bd67a24 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ ![logo](./logo.png) # LiB -Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания. +Веб-приложение библиотеки на FastAPI с современным REST API и веб-интерфейсом. Использует Pydantic для валидации данных, SQLModel для работы с базой данных, Alembic для миграций, PostgreSQL как СУБД и Docker Compose для развертывания. -### **Ключевые элементы:** - -1. FastAPI: Предоставляет высокопроизводительность и простоту для разработки RESTful API, поддерживает асинхронные операции и автоматическую генерацию документации. -2. Pydantic: Используется для валидации данных и сериализации, позволяет легко определить схемы данных. -3. SQLModel: Объединяет SQLAlchemy и Pydantic, включая операции с базой данных с помощью классов Python. -4. Alembic: Инструмент для управления миграциями базы данных, упрощающий отслеживание и применение изменений в схеме базы данных. -5. PostgreSQL: Надежная реляционная база данных для хранения данных. -6. Docker Compose: Упрощает развертывание приложения и его зависимостей в контейнерах. +### **Ключевые технологии:** +1. **FastAPI**: Высокопроизводительный веб-фреймворк для создания RESTful API с автоматической генерацией документации +2. **Pydantic**: Валидация данных и сериализация с использованием аннотаций типов Python +3. **SQLModel**: Объединение SQLAlchemy и Pydantic для работы с БД через классы Python +4. **Alembic**: Инструмент для управления миграциями базы данных +5. **PostgreSQL**: Надежная реляционная база данных +6. **Docker Compose**: Упрощенное развертывание приложения и зависимостей в контейнерах +7. **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса +8. **Alpine.js**: Легковесный JavaScript-фреймворк для реактивности +9. **Chart.js**: Библиотека для визуализации данных ### **Инструкция по установке** @@ -50,58 +52,122 @@ docker compose up test ``` -Для добавление данных для примера используйте: +Для добавления данных для примера используйте: ```bash python data.py ``` +### **Роли пользователей** + +- **Админ**: Полный доступ ко всем функциям системы +- **librarian**: Управление книгами, авторами, жанрами и выдачами +- **member**: Просмотр каталога и управление своими выдачами + ### **Эндпоинты API** -**Авторы** -| Метод | Эндпоинты | Описание | -|--------|---------------------------|---------------------------------| -| POST | `/api/authors` | Создать нового автора | -| GET | `/api/authors` | Получить список всех авторов | -| GET | `/api/authors/{id}` | Получить автора по ID с книгами | -| PUT | `/api/authors/{id}` | Обновить автора по ID | -| DELETE | `/api/authors/{id}` | Удалить автора по ID | +#### **Аутентификация** (`/api/auth`) -**Книги** -| Метод | Эндпоинты | Описание | -|--------|---------------------------|---------------------------------| -| POST | `/api/books` | Создать новую книгу | -| GET | `/api/books` | Получить список всех книг | -| GET | `/api/book/{id}` | Получить книгу по ID с авторами | -| PUT | `/api/books/{id}` | Обновить книгу по ID | -| DELETE | `/api/books/{id}` | Удалить книгу по ID | +| Метод | Эндпоинт | Доступ | Описание | +|--------|-----------------------------------------------|----------------|------------------------------------------| +| 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 | `/api/genres` | Создать новый жанр | -| GET | `/api/genres` | Получить список всех жанров | -| GET | `/api/genres/{id}` | Получить жанр по ID | -| PUT | `/api/genres/{id}` | Обновить жанр по ID | -| DELETE | `/api/genres/{id}` | Удалить жанр по ID | +#### **Авторы** (`/api/authors`) -**Связи** -| Метод | Эндпоинты | Описание | -|--------|------------------------------|-----------------------------------| -| GET | `/authors/{id}/books` | Получить список книг для автора | -| GET | `/books/{id}/authors` | Получить список авторов для книги | -| POST | `/relationships/author-book` | Связать автор-книга | -| DELETE | `/relationships/author-book` | Разделить автор-книга | -| GET | `/genres/{id}/books` | Получить список книг для жанра | -| GET | `/books/{id}/genres` | Получить список жанров для книги | -| POST | `/relationships/genre-book` | Связать автор-книга | -| DELETE | `/relationships/genre-book` | Разделить автор-книга | +| Метод | Эндпоинт | Доступ | Описание | +|--------|---------------------|-----------|---------------------------------| +| POST | `/api/authors` | Сотрудник | Создать нового автора | +| GET | `/api/authors` | Публичный | Получить список всех авторов | +| GET | `/api/authors/{id}` | Публичный | Получить автора по ID с книгами | +| PUT | `/api/authors/{id}` | Сотрудник | Обновить автора по ID | +| DELETE | `/api/authors/{id}` | Сотрудник | Удалить автора по ID | -**Другие** -| Метод | Эндпоинты | Описание | -|--------|--------------|----------------------------------------------| -| GET | `/api/info` | Получить общую информацию о сервисе | -| GET | `/api/stats` | Получить статистическую информацию о сервисе | +#### **Книги** (`/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 | + +#### **Жанры** (`/api/genres`) + +| Метод | Эндпоинт | Доступ | Описание | +|--------|--------------------|-----------|-------------------------------| +| POST | `/api/genres` | Сотрудник | Создать новый жанр | +| GET | `/api/genres` | Публичный | Получить список всех жанров | +| GET | `/api/genres/{id}` | Публичный | Получить жанр по ID с книгами | +| PUT | `/api/genres/{id}` | Сотрудник | Обновить жанр по ID | +| DELETE | `/api/genres/{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` | Админ | Выдать книгу напрямую без бронирования | + +#### **Связи** (`/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` | Публичный | Получить список жанров книги | + +#### **Прочее** (`/api`) + +| Метод | Эндпоинт | Доступ | Описание | +|-------|--------------|-----------|----------------------| +| GET | `/api/info` | Публичный | Информация о сервисе | +| GET | `/api/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 | + +### **Схема базы данных** ```mermaid erDiagram @@ -110,29 +176,28 @@ erDiagram string username UK string email UK string full_name - string password + string hashed_password boolean is_active boolean is_verified } - USER_ROLE { - int user_id FK - string role + ROLE { + int id PK + string name UK + string description + int payroll } - LOAN { - int id PK - int book_id FK + USER_ROLE_LINK { int user_id FK - datetime borrowed_at - datetime due_date - datetime returned_at + int role_id FK } BOOK { int id PK string title string description + string status } AUTHOR { @@ -144,38 +209,53 @@ erDiagram GENRE { int id PK string name + string description } - AUTHOR_BOOK { + AUTHOR_BOOK_LINK { int author_id FK int book_id FK } - GENRE_BOOK { + GENRE_BOOK_LINK { int genre_id FK int book_id FK } - USER ||--o{ USER_ROLE : "имеет роли" - USER ||--o{ LOAN : "берёт книги" - LOAN }o--|| BOOK : "выдача" + BOOK_USER_LINK { + int id PK + int book_id FK + int user_id FK + datetime borrowed_at + datetime due_date + datetime returned_at + } - AUTHOR ||--o{ AUTHOR_BOOK : "пишет" - AUTHOR_BOOK }o--|| BOOK : "авторство" - - GENRE ||--o{ GENRE_BOOK : "содержит" - GENRE_BOOK }o--|| BOOK : "жанр" + USER ||--o{ USER_ROLE_LINK : "имеет" + ROLE ||--o{ USER_ROLE_LINK : "назначена" + USER ||--o{ BOOK_USER_LINK : "берет" + BOOK ||--o{ BOOK_USER_LINK : "выдана" + AUTHOR ||--o{ AUTHOR_BOOK_LINK : "пишет" + BOOK ||--o{ AUTHOR_BOOK_LINK : "написана" + GENRE ||--o{ GENRE_BOOK_LINK : "содержит" + BOOK ||--o{ GENRE_BOOK_LINK : "принадлежит" ``` +### **Статусы книг** + +- **ACTIVE**: Книга доступна для выдачи +- **RESERVED**: Книга забронирована (ожидает подтверждения) +- **BORROWED**: Книга выдана пользователю ### **Используемые технологии** -- **FastAPI**: Современный web фреймворк для построения API с использованием Python, известный своей скоростью и простотой использования. -- **Pydantic**: Библиотека для валидации данных и управления настройками, использующая аннотации типов Python. -- **SQLModel**: Библиотека для взаимодействия с базами данных с использованием классов Python, объединяющая функции SQLAlchemy и Pydantic. -- **Alembic**: Легковесный инструмент для миграции базы данных на основе SQLAlchemy. -- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных. -- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах. -- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker. -- **Tailwind**: CSS-фреймворк, позволяющий стилизовать веб-интерфейсы, применяя готовые низкоуровневые классы. -- **Cash**: Микро JavaScript-библиотека, созданная как очень быстрая и компактная альтернатива jQuery. +- **FastAPI**: Современный веб-фреймворк для построения API на Python +- **Pydantic**: Библиотека для валидации данных и управления настройками +- **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic +- **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy +- **PostgreSQL**: Реляционная система управления базами данных +- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах +- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker +- **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса +- **Alpine.js**: Легковесный JavaScript-фреймворк для реактивности +- **Chart.js**: Библиотека для визуализации данных diff --git a/library_service/auth.py b/library_service/auth.py index ce07609..72e48a1 100644 --- a/library_service/auth.py +++ b/library_service/auth.py @@ -32,17 +32,17 @@ pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") def verify_password(plain_password: str, hashed_password: str) -> bool: - """Проверка пароль по его хешу.""" + """Проверяет пароль по его хешу""" return pwd_context.verify(plain_password, hashed_password) def get_password_hash(password: str) -> str: - """Хэширование пароля.""" + """Хэширует пароль""" return pwd_context.hash(password) def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: - """Создание JWT access токена.""" + """Создает JWT access токен""" to_encode = data.copy() if expires_delta: expire = datetime.now(timezone.utc) + expires_delta @@ -56,7 +56,7 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s def create_refresh_token(data: dict) -> str: - """Создание JWT refresh токена.""" + """Создает JWT refresh токен""" to_encode = data.copy() expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) to_encode.update({"exp": expire, "type": "refresh"}) @@ -65,7 +65,7 @@ def create_refresh_token(data: dict) -> str: def decode_token(token: str, expected_type: str = "access") -> TokenData: - """Декодирование и проверка JWT токенов.""" + """Декодирует и проверяет JWT токен""" token_error = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Bearer"}, @@ -88,7 +88,7 @@ def decode_token(token: str, expected_type: str = "access") -> TokenData: def authenticate_user(session: Session, username: str, password: str) -> User | None: - """Аутентификация пользователя по имени пользователя и паролю.""" + """Аутентифицирует пользователя по имени и паролю""" statement = select(User).where(User.username == username) user = session.exec(statement).first() if not user or not verify_password(password, user.hashed_password): @@ -100,7 +100,7 @@ def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], session: Session = Depends(get_session), ) -> User: - """Получить текущего авторизованного пользователя.""" + """Возвращает текущего авторизованного пользователя""" token_data = decode_token(token) user = session.get(User, token_data.user_id) @@ -116,7 +116,7 @@ def get_current_user( def get_current_active_user( current_user: Annotated[User, Depends(get_current_user)], ) -> User: - """Получить текущего активного пользователя.""" + """Проверяет активность пользователя и возвращает его""" if not current_user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" @@ -125,7 +125,7 @@ def get_current_active_user( def require_role(role_name: str): - """Dependency, требующая выполнения определенной роли.""" + """Создает dependency для проверки наличия определенной роли""" def role_checker(current_user: User = Depends(get_current_active_user)) -> User: user_roles = [role.name for role in current_user.roles] @@ -139,15 +139,42 @@ def require_role(role_name: str): return role_checker +def require_any_role(allowed_roles: list[str]): + """Создает dependency для проверки наличия хотя бы одной из ролей""" + def role_checker(current_user: User = Depends(get_current_active_user)) -> User: + user_roles = {role.name for role in current_user.roles} + if not (user_roles & set(allowed_roles)): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requires one of roles: {allowed_roles}", + ) + return current_user + return role_checker + + # Создание dependencies RequireAuth = Annotated[User, Depends(get_current_active_user)] RequireAdmin = Annotated[User, Depends(require_role("admin"))] RequireMember = Annotated[User, Depends(require_role("member"))] RequireLibrarian = Annotated[User, Depends(require_role("librarian"))] +RequireStaff = Annotated[User, Depends(require_any_role(["admin", "librarian"]))] + + +def is_user_staff(user: User) -> bool: + """Проверяет, является ли пользователь сотрудником (admin или librarian)""" + roles = {role.name for role in user.roles} + return bool(roles & {"admin", "librarian"}) + + + +def is_user_admin(user: User) -> bool: + """Проверяет, является ли пользователь администратором""" + roles = {role.name for role in user.roles} + return "admin" in roles def seed_roles(session: Session) -> dict[str, Role]: - """Создаёт роли по умолчанию, если их нет.""" + """Создает роли по умолчанию, если их нет""" default_roles = [ {"name": "admin", "description": "Администратор системы", "payroll": 80000}, {"name": "librarian", "description": "Библиотекарь", "payroll": 55000}, @@ -174,7 +201,7 @@ def seed_roles(session: Session) -> dict[str, Role]: def seed_admin(session: Session, admin_role: Role) -> User | None: - """Создаёт администратора по умолчанию, если нет ни одного.""" + """Создает администратора по умолчанию, если нет ни одного""" existing_admins = session.exec( select(User).join(User.roles).where(Role.name == "admin") ).all() @@ -219,6 +246,6 @@ def seed_admin(session: Session, admin_role: Role) -> User | None: def run_seeds(session: Session) -> None: - """Запускаем создание ролей и администратора.""" + """Запускает создание ролей и администратора""" roles = seed_roles(session) seed_admin(session, roles["admin"]) diff --git a/library_service/models/db/__init__.py b/library_service/models/db/__init__.py index 8c04171..1b26eaf 100644 --- a/library_service/models/db/__init__.py +++ b/library_service/models/db/__init__.py @@ -7,6 +7,7 @@ from .user import User from .links import ( AuthorBookLink, GenreBookLink, + BookUserLink, UserRoleLink ) @@ -18,5 +19,6 @@ __all__ = [ "User", "AuthorBookLink", "GenreBookLink", + "BookUserLink", "UserRoleLink", ] diff --git a/library_service/models/dto/loan.py b/library_service/models/dto/loan.py index 36a2e90..c78108b 100644 --- a/library_service/models/dto/loan.py +++ b/library_service/models/dto/loan.py @@ -19,6 +19,8 @@ class LoanCreate(LoanBase): class LoanUpdate(SQLModel): """Модель для обновления записи о выдаче""" + user_id: int | None = None + due_date: datetime | None = None returned_at: datetime | None = None diff --git a/library_service/routers/__init__.py b/library_service/routers/__init__.py index 624e55e..80f3b3d 100644 --- a/library_service/routers/__init__.py +++ b/library_service/routers/__init__.py @@ -5,15 +5,19 @@ from .auth import router as auth_router from .authors import router as authors_router 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 .misc import router as misc_router + api_router = APIRouter() + # Подключение всех маршрутов api_router.include_router(misc_router) api_router.include_router(auth_router, prefix="/api") 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(relationships_router, prefix="/api") diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py index 4f992f7..86b28a1 100644 --- a/library_service/routers/auth.py +++ b/library_service/routers/auth.py @@ -9,10 +9,11 @@ from sqlmodel import Session, select from library_service.models.db import Role, User from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList from library_service.settings import get_session -from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin, RequireAuth, +from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAuth, RequireAdmin, RequireStaff, authenticate_user, get_password_hash, decode_token, create_access_token, create_refresh_token) + router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -24,8 +25,7 @@ router = APIRouter(prefix="/auth", tags=["authentication"]) description="Создает нового пользователя в системе", ) def register(user_data: UserCreate, session: Session = Depends(get_session)): - """Эндпоинт регистрации пользователя""" - # Проверка если username существует + """Регистрирует нового пользователя в системе""" existing_user = session.exec( select(User).where(User.username == user_data.username) ).first() @@ -35,7 +35,6 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)): detail="Username already registered", ) - # Проверка если email существует existing_email = session.exec( select(User).where(User.email == user_data.email) ).first() @@ -70,7 +69,7 @@ def login( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], session: Session = Depends(get_session), ): - """Эндпоинт аутентификации и получения JWT токена""" + """Аутентифицирует пользователя и возвращает JWT токены""" user = authenticate_user(session, form_data.username, form_data.password) if not user: raise HTTPException( @@ -103,7 +102,7 @@ def refresh_token( refresh_token: str = Body(..., embed=True), session: Session = Depends(get_session), ): - """Эндпоинт для обновления токенов.""" + """Обновляет пару токенов (access и refresh)""" try: token_data = decode_token(refresh_token, expected_type="refresh") except HTTPException: @@ -149,7 +148,7 @@ def refresh_token( description="Получить информацию о текущем авторизованном пользователе", ) def get_my_profile(current_user: RequireAuth): - """Эндпоинт получения информации о себе""" + """Возвращает информацию о текущем пользователе""" return UserRead( **current_user.model_dump(), roles=[role.name for role in current_user.roles] ) @@ -166,7 +165,7 @@ def update_user_me( current_user: RequireAuth, session: Session = Depends(get_session), ): - """Эндпоинт обновления пользователя""" + """Обновляет профиль текущего пользователя""" if user_update.email: current_user.email = user_update.email if user_update.full_name: @@ -190,12 +189,12 @@ def update_user_me( description="Получить список всех пользователей (только для админов)", ) def read_users( - admin: RequireAdmin, + 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=[ @@ -218,7 +217,7 @@ def add_role_to_user( admin: RequireAdmin, session: Session = Depends(get_session), ): - """Эндпоинт добавления роли пользователю""" + """Добавляет роль пользователю""" user = session.get(User, user_id) if not user: raise HTTPException( @@ -259,7 +258,7 @@ def remove_role_from_user( admin: RequireAdmin, session: Session = Depends(get_session), ): - """Эндпоинт удаления роли у пользователя""" + """Удаляет роль у пользователя""" user = session.get(User, user_id) if not user: raise HTTPException( @@ -298,7 +297,7 @@ 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() diff --git a/library_service/routers/authors.py b/library_service/routers/authors.py index 19e3283..9b61c4b 100644 --- a/library_service/routers/authors.py +++ b/library_service/routers/authors.py @@ -2,12 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException, Path from sqlmodel import Session, select -from library_service.auth import RequireAuth +from library_service.auth import RequireStaff from library_service.settings import get_session from library_service.models.db import Author, AuthorBookLink, Book from library_service.models.dto import (BookRead, AuthorWithBooks, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate) + router = APIRouter(prefix="/authors", tags=["authors"]) @@ -18,11 +19,11 @@ router = APIRouter(prefix="/authors", tags=["authors"]) description="Добавляет автора в систему", ) def create_author( - current_user: RequireAuth, + current_user: RequireStaff, author: AuthorCreate, session: Session = Depends(get_session), ): - """Эндпоинт создания автора""" + """Создает нового автора в системе""" db_author = Author(**author.model_dump()) session.add(db_author) session.commit() @@ -37,7 +38,7 @@ def create_author( description="Возвращает список всех авторов в системе", ) def read_authors(session: Session = Depends(get_session)): - """Эндпоинт чтения списка авторов""" + """Возвращает список всех авторов""" authors = session.exec(select(Author)).all() return AuthorList( authors=[AuthorRead(**author.model_dump()) for author in authors], @@ -55,7 +56,7 @@ def get_author( author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): - """Эндпоинт чтения конкретного автора""" + """Возвращает информацию об авторе и его книгах""" author = session.get(Author, author_id) if not author: raise HTTPException(status_code=404, detail="Author not found") @@ -79,12 +80,12 @@ def get_author( description="Обновляет информацию об авторе в системе", ) def update_author( - current_user: RequireAuth, + current_user: RequireStaff, author: AuthorUpdate, author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): - """Эндпоинт обновления автора""" + """Обновляет информацию об авторе""" db_author = session.get(Author, author_id) if not db_author: raise HTTPException(status_code=404, detail="Author not found") @@ -105,11 +106,11 @@ def update_author( description="Удаляет автора из системы", ) def delete_author( - current_user: RequireAuth, + current_user: RequireStaff, author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): - """Эндпоинт удаления автора""" + """Удаляет автора из системы""" author = session.get(Author, author_id) if not author: raise HTTPException(status_code=404, detail="Author not found") diff --git a/library_service/routers/books.py b/library_service/routers/books.py index c0b757f..8839b09 100644 --- a/library_service/routers/books.py +++ b/library_service/routers/books.py @@ -1,12 +1,14 @@ """Модуль работы с книгами""" +from datetime import datetime from typing import List from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlmodel import Session, select, col, func -from library_service.auth import RequireAuth +from library_service.auth import RequireStaff from library_service.settings import get_session -from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre +from library_service.models.enums import BookStatus +from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre, BookUserLink from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead from library_service.models.dto.combined import ( BookWithAuthorsAndGenres, @@ -17,6 +19,19 @@ from library_service.models.dto.combined import ( router = APIRouter(prefix="/books", tags=["books"]) +def close_active_loan(session: Session, book_id: int) -> None: + """Закрывает активную выдачу книги при изменении статуса""" + active_loan = session.exec( + select(BookUserLink) + .where(BookUserLink.book_id == book_id) + .where(BookUserLink.returned_at == None) # noqa: E711 + ).first() + + if active_loan: + active_loan.returned_at = datetime.utcnow() + session.add(active_loan) + + @router.get( "/filter", response_model=BookFilteredList, @@ -31,7 +46,7 @@ def filter_books( page: int = Query(1, gt=0, description="Номер страницы"), size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"), ): - """Эндпоинт получения отфильтрованного списка книг""" + """Возвращает отфильтрованный список книг с пагинацией""" statement = select(Book).distinct() if q: @@ -72,9 +87,11 @@ def filter_books( description="Добавляет книгу в систему", ) def create_book( - current_user: RequireAuth, book: BookCreate, session: Session = Depends(get_session) + book: BookCreate, + current_user: RequireStaff, + session: Session = Depends(get_session) ): - """Эндпоинт создания книги""" + """Создает новую книгу в системе""" db_book = Book(**book.model_dump()) session.add(db_book) session.commit() @@ -89,7 +106,7 @@ def create_book( description="Возвращает список всех книг в системе", ) def read_books(session: Session = Depends(get_session)): - """Эндпоинт чтения списка книг""" + """Возвращает список всех книг""" books = session.exec(select(Book)).all() return BookList( books=[BookRead(**book.model_dump()) for book in books], total=len(books) @@ -106,7 +123,7 @@ def get_book( book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): - """Эндпоинт чтения конкретной книги""" + """Возвращает информацию о книге с авторами и жанрами""" book = session.get(Book, book_id) if not book: raise HTTPException(status_code=404, detail="Book not found") @@ -137,22 +154,39 @@ def get_book( description="Обновляет информацию о книге в системе", ) def update_book( - current_user: RequireAuth, - book: BookUpdate, - book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), + current_user: RequireStaff, + book_update: BookUpdate, + book_id: int = Path(..., gt=0), session: Session = Depends(get_session), ): - """Эндпоинт обновления книги""" + """Обновляет информацию о книге""" db_book = session.get(Book, book_id) if not db_book: raise HTTPException(status_code=404, detail="Book not found") - db_book.title = book.title or db_book.title - db_book.description = book.description or db_book.description - db_book.status = book.status or db_book.status + if book_update.status is not None: + if book_update.status == BookStatus.BORROWED: + raise HTTPException( + status_code=400, + detail="Статус 'borrowed' устанавливается только через выдачу книги" + ) + + if db_book.status == BookStatus.BORROWED: + close_active_loan(session, book_id) + + db_book.status = book_update.status + + if book_update.title is not None or book_update.description is not None: + if book_update.title is not None: + db_book.title = book_update.title + if book_update.description is not None: + db_book.description = book_update.description + + session.add(db_book) session.commit() session.refresh(db_book) - return db_book + + return BookRead(**db_book.model_dump()) @router.delete( @@ -162,11 +196,11 @@ def update_book( description="Удаляет книгу их системы", ) def delete_book( - current_user: RequireAuth, + current_user: RequireStaff, book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): - """Эндпоинт удаления книги""" + """Удаляет книгу из системы""" book = session.get(Book, book_id) if not book: raise HTTPException(status_code=404, detail="Book not found") diff --git a/library_service/routers/genres.py b/library_service/routers/genres.py index 2281e42..d933dff 100644 --- a/library_service/routers/genres.py +++ b/library_service/routers/genres.py @@ -2,11 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException, Path from sqlmodel import Session, select -from library_service.auth import RequireAuth +from library_service.auth import RequireStaff from library_service.models.db import Book, Genre, GenreBookLink from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks from library_service.settings import get_session + router = APIRouter(prefix="/genres", tags=["genres"]) @@ -17,11 +18,11 @@ router = APIRouter(prefix="/genres", tags=["genres"]) description="Добавляет жанр книг в систему", ) def create_genre( - current_user: RequireAuth, + current_user: RequireStaff, genre: GenreCreate, session: Session = Depends(get_session), ): - """Эндпоинт создания жанра""" + """Создает новый жанр в системе""" db_genre = Genre(**genre.model_dump()) session.add(db_genre) session.commit() @@ -36,7 +37,7 @@ def create_genre( description="Возвращает список всех жанров в системе", ) def read_genres(session: Session = Depends(get_session)): - """Эндпоинт чтения списка жанров""" + """Возвращает список всех жанров""" genres = session.exec(select(Genre)).all() return GenreList( genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres) @@ -53,7 +54,7 @@ def get_genre( genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): - """Эндпоинт чтения конкретного жанра""" + """Возвращает информацию о жанре и книгах с ним""" genre = session.get(Genre, genre_id) if not genre: raise HTTPException(status_code=404, detail="Genre not found") @@ -73,16 +74,16 @@ def get_genre( @router.put( "/{genre_id}", response_model=GenreRead, - summary="Обновляет информацию о жанре", + summary="Обновить информацию о жанре", description="Обновляет информацию о жанре в системе", ) def update_genre( - current_user: RequireAuth, + current_user: RequireStaff, genre: GenreUpdate, genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): - """Эндпоинт обновления жанра""" + """Обновляет информацию о жанре""" db_genre = session.get(Genre, genre_id) if not db_genre: raise HTTPException(status_code=404, detail="Genre not found") @@ -100,14 +101,14 @@ def update_genre( "/{genre_id}", response_model=GenreRead, summary="Удалить жанр", - description="Удаляет автора из системы", + description="Удаляет жанр из системы", ) def delete_genre( - current_user: RequireAuth, + current_user: RequireStaff, genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), session: Session = Depends(get_session), ): - """Эндпоинт удаления жанра""" + """Удаляет жанр из системы""" genre = session.get(Genre, genre_id) if not genre: raise HTTPException(status_code=404, detail="Genre not found") diff --git a/library_service/routers/loans.py b/library_service/routers/loans.py new file mode 100644 index 0000000..d271e59 --- /dev/null +++ b/library_service/routers/loans.py @@ -0,0 +1,506 @@ +"""Модуль работы с выдачей и бронированием книг""" +from datetime import datetime, timedelta +from typing import Dict, List + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from fastapi.responses import JSONResponse +from sqlmodel import Session, select, col, func +from sqlalchemy import cast, Date + +from library_service.auth import RequireAuth, RequireStaff, RequireAdmin, is_user_staff +from library_service.settings import get_session +from library_service.models.db import Book, User, BookUserLink +from library_service.models.dto import LoanCreate, LoanRead, LoanList, LoanUpdate +from library_service.models.enums import BookStatus + + +router = APIRouter(prefix="/loans", tags=["loans"]) + + +@router.post( + "/", + response_model=LoanRead, + summary="Создать выдачу/бронь", + description="Создает запись о выдаче или бронировании книги", +) +def create_loan( + current_user: RequireAuth, + loan: LoanCreate, + session: Session = Depends(get_session), +): + """Создает выдачу или бронирование книги""" + is_staff = is_user_staff(current_user) + + if not is_staff and loan.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only create loans for yourself" + ) + + book = session.get(Book, loan.book_id) + if not book: + raise HTTPException(status_code=404, detail="Book not found") + + if book.status != BookStatus.ACTIVE: + raise HTTPException( + status_code=400, + detail=f"Book is not available for loan (status: {book.status})" + ) + + target_user = session.get(User, loan.user_id) + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + db_loan = BookUserLink( + book_id=loan.book_id, + user_id=loan.user_id, + due_date=loan.due_date, + borrowed_at=datetime.utcnow() + ) + + book.status = BookStatus.RESERVED + + session.add(db_loan) + session.add(book) + session.commit() + session.refresh(db_loan) + + return LoanRead(**db_loan.model_dump()) + + +@router.get( + "/", + response_model=LoanList, + summary="Получить список выдач", + description="Возвращает список выдач. Читатели видят только свои. Сотрудники видят все.", +) +def read_loans( + current_user: RequireAuth, + session: Session = Depends(get_session), + user_id: int | None = Query(None, description="Фильтр по user_ID"), + book_id: int | None = Query(None, description="Фильтр по book_ID"), + active_only: bool = Query(False, description="Только не возвращенные выдачи"), + page: int = Query(1, gt=0, description="Номер страницы"), + size: int = Query(20, gt=0, lt=101, description="Элементов на странице"), +): + """Возвращает список выдач с фильтрацией и пагинацией""" + is_staff = is_user_staff(current_user) + + statement = select(BookUserLink) + + if not is_staff: + statement = statement.where(BookUserLink.user_id == current_user.id) + elif user_id is not None: + statement = statement.where(BookUserLink.user_id == user_id) + + if book_id is not None: + statement = statement.where(BookUserLink.book_id == book_id) + + if active_only: + statement = statement.where(BookUserLink.returned_at == None) # noqa: E711 + + total_statement = select(func.count()).select_from(statement.subquery()) + total = session.exec(total_statement).one() + + offset = (page - 1) * size + statement = statement.order_by(col(BookUserLink.borrowed_at).desc()) + statement = statement.offset(offset).limit(size) + + loans = session.exec(statement).all() + + return LoanList( + loans=[LoanRead(**loan.model_dump()) for loan in loans], + total=total + ) + + +@router.get( + "/analytics", + summary="Аналитика выдач и возвратов", + description="Возвращает аналитику выдач и возвратов. Только для админов.", +) +def get_loans_analytics( + current_user: RequireAdmin, + days: int = Query(30, ge=1, le=365, description="Количество дней для анализа"), + session: Session = Depends(get_session), +): + """Возвращает аналитику по выдачам и возвратам книг""" + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + total_loans = session.exec( + select(func.count(BookUserLink.id)) + .where(BookUserLink.borrowed_at >= start_date) + ).one() + + active_loans = session.exec( + select(func.count(BookUserLink.id)) + .where(BookUserLink.borrowed_at >= start_date) + .where(BookUserLink.returned_at == None) # noqa: E711 + ).one() + + returned_loans = session.exec( + select(func.count(BookUserLink.id)) + .where(BookUserLink.borrowed_at >= start_date) + .where(BookUserLink.returned_at != None) # noqa: E711 + ).one() + + overdue_loans = session.exec( + select(func.count(BookUserLink.id)) + .where(BookUserLink.returned_at == None) # noqa: E711 + .where(BookUserLink.due_date < end_date) + ).one() + + daily_loans = {} + daily_returns = {} + + loans_by_date = session.exec( + select( + cast(BookUserLink.borrowed_at, Date).label("date"), + func.count(BookUserLink.id).label("count") + ) + .where(BookUserLink.borrowed_at >= start_date) + .group_by(cast(BookUserLink.borrowed_at, Date)) + .order_by(cast(BookUserLink.borrowed_at, Date)) + ).all() + + returns_by_date = session.exec( + select( + cast(BookUserLink.returned_at, Date).label("date"), + func.count(BookUserLink.id).label("count") + ) + .where(BookUserLink.returned_at >= start_date) + .where(BookUserLink.returned_at != None) # noqa: E711 + .group_by(cast(BookUserLink.returned_at, Date)) + .order_by(cast(BookUserLink.returned_at, Date)) + ).all() + + for row in loans_by_date: + date_str = str(row[0]) if isinstance(row, tuple) else str(row.date) + count = row[1] if isinstance(row, tuple) else row.count + daily_loans[date_str] = count + + for row in returns_by_date: + date_str = str(row[0]) if isinstance(row, tuple) else str(row.date) + count = row[1] if isinstance(row, tuple) else row.count + daily_returns[date_str] = count + + top_books = session.exec( + select( + BookUserLink.book_id, + func.count(BookUserLink.id).label("loan_count") + ) + .where(BookUserLink.borrowed_at >= start_date) + .group_by(BookUserLink.book_id) + .order_by(func.count(BookUserLink.id).desc()) + .limit(10) + ).all() + + top_books_data = [] + for row in top_books: + book_id = row[0] if isinstance(row, tuple) else row.book_id + loan_count = row[1] if isinstance(row, tuple) else row.loan_count + book = session.get(Book, book_id) + if book: + top_books_data.append({ + "book_id": book_id, + "title": book.title, + "loan_count": loan_count + }) + + reserved_count = session.exec( + select(func.count(Book.id)) + .where(Book.status == BookStatus.RESERVED) + ).one() + + borrowed_count = session.exec( + select(func.count(Book.id)) + .where(Book.status == BookStatus.BORROWED) + ).one() + + return JSONResponse(content={ + "summary": { + "total_loans": total_loans, + "active_loans": active_loans, + "returned_loans": returned_loans, + "overdue_loans": overdue_loans, + "reserved_books": reserved_count, + "borrowed_books": borrowed_count, + }, + "daily_loans": daily_loans, + "daily_returns": daily_returns, + "top_books": top_books_data, + "period_days": days, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }) + + +@router.get( + "/{loan_id}", + response_model=LoanRead, + summary="Получить выдачу по ID", + description="Возвращает выдачу по ID", +) +def get_loan( + current_user: RequireAuth, + loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0), + session: Session = Depends(get_session), +): + """Возвращает информацию о выдаче по ID""" + loan = session.get(BookUserLink, loan_id) + + if not loan: + raise HTTPException(status_code=404, detail="Loan not found") + + is_staff = is_user_staff(current_user) + + if not is_staff and loan.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this loan" + ) + + return LoanRead(**loan.model_dump()) + + +@router.put( + "/{loan_id}", + response_model=LoanRead, + summary="Обновить выдачу", + description="Обновляет информацию о выдаче. Сотрудники могут обновлять любые, читатели только свои.", +) +def update_loan( + current_user: RequireAuth, + loan_update: LoanUpdate, + loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0), + session: Session = Depends(get_session), +): + """Обновляет информацию о выдаче""" + db_loan = session.get(BookUserLink, loan_id) + if not db_loan: + raise HTTPException(status_code=404, detail="Loan not found") + + is_staff = is_user_staff(current_user) + + if not is_staff and db_loan.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only update your own loans" + ) + + book = session.get(Book, db_loan.book_id) + if not book: + raise HTTPException(status_code=404, detail="Book not found") + + if loan_update.user_id is not None: + if not is_staff: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only staff can change loan user" + ) + new_user = session.get(User, loan_update.user_id) + if not new_user: + raise HTTPException(status_code=404, detail="User not found") + db_loan.user_id = loan_update.user_id + + if loan_update.due_date is not None: + db_loan.due_date = loan_update.due_date + + if loan_update.returned_at is not None: + if db_loan.returned_at is not None: + raise HTTPException( + status_code=400, + detail="Loan is already returned" + ) + db_loan.returned_at = loan_update.returned_at + book.status = BookStatus.ACTIVE + + session.add(db_loan) + session.add(book) + session.commit() + session.refresh(db_loan) + + return LoanRead(**db_loan.model_dump()) + + +@router.post( + "/{loan_id}/confirm", + response_model=LoanRead, + summary="Подтвердить бронь", + description="Подтверждает бронирование и меняет статус книги на BORROWED", +) +def confirm_loan( + current_user: RequireStaff, + loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0), + session: Session = Depends(get_session), +): + """Подтверждает бронирование и меняет статус книги на BORROWED""" + loan = session.get(BookUserLink, loan_id) + if not loan: + raise HTTPException(status_code=404, detail="Loan not found") + + if loan.returned_at: + raise HTTPException(status_code=400, detail="Loan is already returned") + + book = session.get(Book, loan.book_id) + if not book: + raise HTTPException(status_code=404, detail="Book not found") + + if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]: + raise HTTPException( + status_code=400, + detail=f"Cannot confirm loan for book with status: {book.status}" + ) + + book.status = BookStatus.BORROWED + + session.add(loan) + session.add(book) + session.commit() + session.refresh(loan) + + return LoanRead(**loan.model_dump()) + + +@router.post( + "/{loan_id}/return", + response_model=LoanRead, + summary="Вернуть книгу", + description="Возвращает книгу и закрывает выдачу", +) +def return_loan( + current_user: RequireStaff, + loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0), + session: Session = Depends(get_session), +): + """Возвращает книгу и закрывает выдачу""" + loan = session.get(BookUserLink, loan_id) + if not loan: + raise HTTPException(status_code=404, detail="Loan not found") + + if loan.returned_at: + raise HTTPException(status_code=400, detail="Loan is already returned") + + loan.returned_at = datetime.utcnow() + + book = session.get(Book, loan.book_id) + if book: + book.status = BookStatus.ACTIVE + session.add(book) + + session.add(loan) + session.commit() + session.refresh(loan) + + return LoanRead(**loan.model_dump()) + + +@router.delete( + "/{loan_id}", + response_model=LoanRead, + summary="Удалить выдачу/бронь", + description="Удаляет выдачу/бронь. Работает только для статуса RESERVED.", +) +def delete_loan( + current_user: RequireAuth, + loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0), + session: Session = Depends(get_session), +): + """Удаляет выдачу или бронирование (только для RESERVED статуса)""" + loan = session.get(BookUserLink, loan_id) + if not loan: + raise HTTPException(status_code=404, detail="Loan not found") + + is_staff = is_user_staff(current_user) + + if not is_staff and loan.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only delete your own loans" + ) + + book = session.get(Book, loan.book_id) + + if book and book.status != BookStatus.RESERVED: + raise HTTPException( + status_code=400, + detail="Can only delete reservations. Use update endpoint to return borrowed books" + ) + + loan_read = LoanRead(**loan.model_dump()) + session.delete(loan) + + if book: + book.status = BookStatus.ACTIVE + session.add(book) + + session.commit() + + return loan_read + + +@router.get( + "/book/{book_id}/active", + response_model=LoanRead | None, + summary="Получить активную выдачу книги", + description="Возвращает активную выдачу для указанной книги", +) +def get_active_loan_for_book( + current_user: RequireStaff, + book_id: int = Path(..., description="Book ID (integer, > 0)", gt=0), + session: Session = Depends(get_session), +): + """Возвращает активную выдачу для указанной книги""" + loan = session.exec( + select(BookUserLink) + .where(BookUserLink.book_id == book_id) + .where(BookUserLink.returned_at == None) # noqa: E711 + ).first() + + if not loan: + return None + + return LoanRead(**loan.model_dump()) + + +@router.post( + "/issue", + response_model=LoanRead, + summary="Выдать книгу напрямую", + description="Только для администраторов. Создает выдачу и устанавливает статус книги на BORROWED.", +) +def issue_book_directly( + current_user: RequireAdmin, + loan: LoanCreate, + session: Session = Depends(get_session), +): + """Выдает книгу напрямую без бронирования (только для администраторов)""" + book = session.get(Book, loan.book_id) + if not book: + raise HTTPException(status_code=404, detail="Book not found") + + if book.status != BookStatus.ACTIVE: + raise HTTPException( + status_code=400, + detail=f"Book is not available (status: {book.status})" + ) + + target_user = session.get(User, loan.user_id) + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + db_loan = BookUserLink( + book_id=loan.book_id, + user_id=loan.user_id, + due_date=loan.due_date, + borrowed_at=datetime.utcnow() + ) + + book.status = BookStatus.BORROWED + + session.add(db_loan) + session.add(book) + session.commit() + session.refresh(db_loan) + + return LoanRead(**db_loan.model_dump()) diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py index 674dfa6..8e5dad9 100644 --- a/library_service/routers/misc.py +++ b/library_service/routers/misc.py @@ -18,7 +18,7 @@ templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates" def get_info(app) -> Dict: - """Форматированная информация о приложении""" + """Возвращает информацию о приложении""" return { "status": "ok", "app_info": { @@ -32,103 +32,115 @@ def get_info(app) -> Dict: @router.get("/", include_in_schema=False) async def root(request: Request): - """Эндпоинт главной страницы""" + """Рендерит главную страницу""" return templates.TemplateResponse(request, "index.html") @router.get("/genre/create", include_in_schema=False) async def create_genre(request: Request): - """Эндпоинт страницы создания жанра""" + """Рендерит страницу создания жанра""" return templates.TemplateResponse(request, "create_genre.html") @router.get("/genre/{genre_id}/edit", include_in_schema=False) async def edit_genre(request: Request, genre_id: int): - """Эндпоинт страницы редактирования жанра""" + """Рендерит страницу редактирования жанра""" return templates.TemplateResponse(request, "edit_genre.html") @router.get("/authors", include_in_schema=False) async def authors(request: Request): - """Эндпоинт страницы выбора автора""" + """Рендерит страницу списка авторов""" return templates.TemplateResponse(request, "authors.html") @router.get("/author/create", include_in_schema=False) async def create_author(request: Request): - """Эндпоинт страницы создания автора""" + """Рендерит страницу создания автора""" return templates.TemplateResponse(request, "create_author.html") @router.get("/author/{author_id}/edit", include_in_schema=False) async def edit_author(request: Request, author_id: int): - """Эндпоинт страницы редактирования автора""" + """Рендерит страницу редактирования автора""" return templates.TemplateResponse(request, "edit_author.html") @router.get("/author/{author_id}", include_in_schema=False) async def author(request: Request, author_id: int): - """Эндпоинт страницы автора""" + """Рендерит страницу просмотра автора""" return templates.TemplateResponse(request, "author.html") @router.get("/books", include_in_schema=False) async def books(request: Request): - """Эндпоинт страницы выбора книг""" + """Рендерит страницу списка книг""" return templates.TemplateResponse(request, "books.html") @router.get("/book/create", include_in_schema=False) async def create_book(request: Request): - """Эндпоинт страницы создания книги""" + """Рендерит страницу создания книги""" return templates.TemplateResponse(request, "create_book.html") @router.get("/book/{book_id}/edit", include_in_schema=False) async def edit_book(request: Request, book_id: int): - """Эндпоинт страницы редактирования книги""" + """Рендерит страницу редактирования книги""" return templates.TemplateResponse(request, "edit_book.html") @router.get("/book/{book_id}", include_in_schema=False) async def book(request: Request, book_id: int): - """Эндпоинт страницы книги""" + """Рендерит страницу просмотра книги""" return templates.TemplateResponse(request, "book.html") @router.get("/auth", include_in_schema=False) async def auth(request: Request): - """Эндпоинт страницы авторизации""" + """Рендерит страницу авторизации""" return templates.TemplateResponse(request, "auth.html") @router.get("/profile", include_in_schema=False) async def profile(request: Request): - """Эндпоинт страницы профиля""" + """Рендерит страницу профиля пользователя""" return templates.TemplateResponse(request, "profile.html") @router.get("/users", include_in_schema=False) async def users(request: Request): - """Эндпоинт страницы управления пользователями""" + """Рендерит страницу управления пользователями""" return templates.TemplateResponse(request, "users.html") +@router.get("/my-books", include_in_schema=False) +async def my_books(request: Request): + """Рендерит страницу моих книг пользователя""" + return templates.TemplateResponse(request, "my_books.html") + + +@router.get("/analytics", include_in_schema=False) +async def analytics(request: Request): + """Рендерит страницу аналитики выдач""" + return templates.TemplateResponse(request, "analytics.html") + + @router.get("/api", include_in_schema=False) async def api(request: Request, app=Depends(lambda: get_app())): - """Страница с сылками на документацию API""" + """Рендерит страницу с ссылками на документацию API""" return templates.TemplateResponse(request, "api.html", get_info(app)) @router.get("/favicon.ico", include_in_schema=False) def redirect_favicon(): - """Редирект иконки вкладки""" + """Редиректит на favicon.svg""" return RedirectResponse("/favicon.svg") @router.get("/favicon.svg", include_in_schema=False) async def favicon(): - """Эндпоинт иконки вкладки""" + """Возвращает иконку сайта""" return FileResponse( "library_service/static/favicon.svg", media_type="image/svg+xml" ) @@ -140,7 +152,7 @@ async def favicon(): description="Возвращает общую информацию о системе", ) async def api_info(app=Depends(lambda: get_app())): - """Эндпоинт информации об API""" + """Возвращает информацию о сервисе""" return JSONResponse(content=get_info(app)) @@ -150,7 +162,7 @@ async def api_info(app=Depends(lambda: get_app())): description="Возвращает статистическую информацию о системе", ) async def api_stats(session: Session = Depends(get_session)): - """Эндпоинт стстистики системы""" + """Возвращает статистику системы""" authors = select(func.count()).select_from(Author) books = select(func.count()).select_from(Book) genres = select(func.count()).select_from(Genre) diff --git a/library_service/routers/relationships.py b/library_service/routers/relationships.py index 52dd816..6fe3000 100644 --- a/library_service/routers/relationships.py +++ b/library_service/routers/relationships.py @@ -4,7 +4,7 @@ from typing import Dict, List from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select -from library_service.auth import RequireAuth +from library_service.auth import RequireStaff from library_service.models.db import Author, AuthorBookLink, Book, Genre, GenreBookLink from library_service.models.dto import AuthorRead, BookRead, GenreRead from library_service.settings import get_session @@ -14,7 +14,7 @@ router = APIRouter(tags=["relations"]) def check_entity_exists(session, model, entity_id, entity_name): - """Проверка существования связи между сущностями в БД""" + """Проверяет существование сущности в базе данных""" entity = session.get(model, entity_id) if not entity: raise HTTPException(status_code=404, detail=f"{entity_name} not found") @@ -22,7 +22,7 @@ def check_entity_exists(session, model, entity_id, entity_name): def add_relationship(session, link_model, id1, field1, id2, field2, detail): - """Создание связи между сущностями в БД""" + """Создает связь между сущностями в базе данных""" existing_link = session.exec( select(link_model) .where(getattr(link_model, field1) == id1) @@ -40,7 +40,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail): def remove_relationship(session, link_model, id1, field1, id2, field2): - """Удаление связи между сущностями в БД""" + """Удаляет связь между сущностями в базе данных""" link = session.exec( select(link_model) .where(getattr(link_model, field1) == id1) @@ -66,7 +66,7 @@ def get_related( link_related_field, read_model ): - """Получение связанных в БД сущностей""" + """Возвращает список связанных сущностей""" check_entity_exists(session, main_model, main_id, main_name) related = session.exec( @@ -84,12 +84,12 @@ def get_related( description="Добавляет связь между автором и книгой в систему", ) def add_author_to_book( - current_user: RequireAuth, + current_user: RequireStaff, author_id: int, book_id: int, session: Session = Depends(get_session), ): - """Эндпоинт добавления автора к книге""" + """Добавляет связь между автором и книгой""" check_entity_exists(session, Author, author_id, "Author") check_entity_exists(session, Book, book_id, "Book") @@ -104,12 +104,12 @@ def add_author_to_book( description="Удаляет связь между автором и книгой в системе", ) def remove_author_from_book( - current_user: RequireAuth, + current_user: RequireStaff, author_id: int, book_id: int, session: Session = Depends(get_session), ): - """Эндпоинт удаления автора из книги""" + """Удаляет связь между автором и книгой""" return remove_relationship(session, AuthorBookLink, author_id, "author_id", book_id, "book_id") @@ -121,7 +121,7 @@ def remove_author_from_book( description="Возвращает все книги в системе, написанные автором", ) def get_books_for_author(author_id: int, session: Session = Depends(get_session)): - """Эндпоинт получения книг, написанных автором""" + """Возвращает список книг автора""" return get_related(session, Author, author_id, "Author", Book, AuthorBookLink, "author_id", "book_id", BookRead) @@ -134,7 +134,7 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session) description="Возвращает всех авторов книги в системе", ) def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): - """Эндпоинт получения авторов книги""" + """Возвращает список авторов книги""" return get_related(session, Book, book_id, "Book", Author, AuthorBookLink, "book_id", "author_id", AuthorRead) @@ -147,12 +147,12 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): description="Добавляет связь между книгой и жанром в систему", ) def add_genre_to_book( - current_user: RequireAuth, + current_user: RequireStaff, genre_id: int, book_id: int, session: Session = Depends(get_session), ): - """Эндпоинт добавления жанра к книге""" + """Добавляет связь между жанром и книгой""" check_entity_exists(session, Genre, genre_id, "Genre") check_entity_exists(session, Book, book_id, "Book") @@ -167,12 +167,12 @@ def add_genre_to_book( description="Удаляет связь между жанром и книгой в системе", ) def remove_genre_from_book( - current_user: RequireAuth, + current_user: RequireStaff, genre_id: int, book_id: int, session: Session = Depends(get_session), ): - """Эндпоинт удаления жанра из книги""" + """Удаляет связь между жанром и книгой""" return remove_relationship(session, GenreBookLink, genre_id, "genre_id", book_id, "book_id") @@ -184,7 +184,7 @@ def remove_genre_from_book( description="Возвращает все книги в системе в этом жанре", ) def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)): - """Эндпоинт получения книг с жанром""" + """Возвращает список книг в жанре""" return get_related(session, Genre, genre_id, "Genre", Book, GenreBookLink, "genre_id", "book_id", BookRead) @@ -197,7 +197,7 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)): description="Возвращает все жанры книги в системе", ) def get_genres_for_book(book_id: int, session: Session = Depends(get_session)): - """Эндпоинт получения жанров книги""" + """Возвращает список жанров книги""" return get_related(session, Book, book_id, "Book", Genre, GenreBookLink, "book_id", "genre_id", GenreRead) diff --git a/library_service/settings.py b/library_service/settings.py index b2f8d8a..19be3ec 100644 --- a/library_service/settings.py +++ b/library_service/settings.py @@ -13,7 +13,7 @@ with open("pyproject.toml", 'r', encoding='utf-8') as f: def get_app(lifespan=None, /) -> FastAPI: - """Dependency для получения экземпляра FastAPI application""" + """Возвращает экземпляр FastAPI приложения""" if not hasattr(get_app, 'instance'): get_app.instance = FastAPI( title=config["tool"]["poetry"]["name"], @@ -37,6 +37,10 @@ def get_app(lifespan=None, /) -> FastAPI: "name": "genres", "description": "Действия с жанрами.", }, + { + "name": "loans", + "description": "Действия с выдачами.", + }, { "name": "relations", "description": "Действия с связями.", @@ -64,11 +68,11 @@ engine = create_engine(POSTGRES_DATABASE_URL, echo=False, future=True) def get_session(): - """Dependency, для получение сессии БД""" + """Возвращает сессию базы данных""" with Session(engine) as session: yield session def get_logger(name: str = "uvicorn"): - """Dependency, для получение логгера""" + """Возвращает логгер с указанным именем""" return logging.getLogger(name) diff --git a/library_service/static/analytics.js b/library_service/static/analytics.js new file mode 100644 index 0000000..0f2da6d --- /dev/null +++ b/library_service/static/analytics.js @@ -0,0 +1,262 @@ +$(document).ready(() => { + if (!window.isAdmin()) { + $(".container").html( + '

Доступ запрещён

Только администраторы могут просматривать аналитику

На главную
' + ); + return; + } + + let loansChart = null; + let returnsChart = null; + let currentPeriod = 30; + + init(); + + function init() { + $("#period-select").on("change", function () { + currentPeriod = parseInt($(this).val()); + loadAnalytics(); + }); + + $("#refresh-btn").on("click", loadAnalytics); + + loadAnalytics(); + } + + async function loadAnalytics() { + try { + const data = await Api.get(`/api/loans/analytics?days=${currentPeriod}`); + renderSummary(data.summary); + renderCharts(data); + renderTopBooks(data.top_books); + } catch (error) { + console.error("Failed to load analytics", error); + Utils.showToast("Ошибка загрузки аналитики", "error"); + } + } + + function renderSummary(summary) { + $("#total-loans").text(summary.total_loans || 0); + $("#active-loans").text(summary.active_loans || 0); + $("#returned-loans").text(summary.returned_loans || 0); + $("#overdue-loans").text(summary.overdue_loans || 0); + $("#reserved-books").text(summary.reserved_books || 0); + $("#borrowed-books").text(summary.borrowed_books || 0); + } + + function renderCharts(data) { + // Подготовка данных для графиков + const startDate = new Date(data.start_date); + const endDate = new Date(data.end_date); + const dates = []; + const loansData = []; + const returnsData = []; + + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + const dateStr = d.toISOString().split("T")[0]; + dates.push(new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" })); + loansData.push(data.daily_loans[dateStr] || 0); + returnsData.push(data.daily_returns[dateStr] || 0); + } + + // График выдач + const loansCtx = document.getElementById("loans-chart"); + if (loansChart) { + loansChart.destroy(); + } + loansChart = new Chart(loansCtx, { + type: "line", + data: { + labels: dates, + datasets: [ + { + label: "Выдачи", + data: loansData, + borderColor: "rgb(75, 85, 99)", + backgroundColor: "rgba(75, 85, 99, 0.05)", + borderWidth: 1.5, + fill: true, + tension: 0.3, + pointRadius: 2.5, + pointHoverRadius: 4, + pointBackgroundColor: "rgb(75, 85, 99)", + pointBorderColor: "#fff", + pointBorderWidth: 1.5, + pointStyle: "circle", + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: true, + interaction: { + intersect: false, + mode: "index", + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + backgroundColor: "rgba(0, 0, 0, 0.8)", + padding: 10, + titleFont: { size: 12, weight: "500" }, + bodyFont: { size: 11 }, + cornerRadius: 6, + displayColors: false, + borderColor: "rgba(255, 255, 255, 0.08)", + borderWidth: 1, + titleSpacing: 4, + bodySpacing: 4, + }, + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: "rgba(0, 0, 0, 0.03)", + drawBorder: false, + lineWidth: 1, + }, + ticks: { + precision: 0, + font: { size: 10 }, + color: "rgba(0, 0, 0, 0.4)", + padding: 8, + }, + }, + x: { + grid: { + display: false, + }, + ticks: { + maxRotation: 0, + minRotation: 0, + font: { size: 10 }, + color: "rgba(0, 0, 0, 0.4)", + padding: 8, + }, + }, + }, + }, + }); + + // График возвратов + const returnsCtx = document.getElementById("returns-chart"); + if (returnsChart) { + returnsChart.destroy(); + } + returnsChart = new Chart(returnsCtx, { + type: "line", + data: { + labels: dates, + datasets: [ + { + label: "Возвраты", + data: returnsData, + borderColor: "rgb(107, 114, 128)", + backgroundColor: "rgba(107, 114, 128, 0.05)", + borderWidth: 1.5, + fill: true, + tension: 0.3, + pointRadius: 2.5, + pointHoverRadius: 4, + pointBackgroundColor: "rgb(107, 114, 128)", + pointBorderColor: "#fff", + pointBorderWidth: 1.5, + pointStyle: "circle", + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: true, + interaction: { + intersect: false, + mode: "index", + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + backgroundColor: "rgba(0, 0, 0, 0.8)", + padding: 10, + titleFont: { size: 12, weight: "500" }, + bodyFont: { size: 11 }, + cornerRadius: 6, + displayColors: false, + borderColor: "rgba(255, 255, 255, 0.08)", + borderWidth: 1, + titleSpacing: 4, + bodySpacing: 4, + }, + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: "rgba(0, 0, 0, 0.03)", + drawBorder: false, + lineWidth: 1, + }, + ticks: { + precision: 0, + font: { size: 10 }, + color: "rgba(0, 0, 0, 0.4)", + padding: 8, + }, + }, + x: { + grid: { + display: false, + }, + ticks: { + maxRotation: 0, + minRotation: 0, + font: { size: 10 }, + color: "rgba(0, 0, 0, 0.4)", + padding: 8, + }, + }, + }, + }, + }); + } + + function renderTopBooks(topBooks) { + const $container = $("#top-books-container"); + $container.empty(); + + if (!topBooks || topBooks.length === 0) { + $container.html( + '
Нет данных
' + ); + return; + } + + topBooks.forEach((book, index) => { + const $item = $(` +
+
+
+ ${index + 1} +
+ +
+
+ + ${book.loan_count} ${book.loan_count === 1 ? "выдача" : book.loan_count < 5 ? "выдачи" : "выдач"} + +
+
+ `); + $container.append($item); + }); + } +}); + diff --git a/library_service/static/book.js b/library_service/static/book.js index 0484086..acf5819 100644 --- a/library_service/static/book.js +++ b/library_service/static/book.js @@ -32,6 +32,194 @@ $(document).ready(() => { }, }; + const pathParts = window.location.pathname.split("/"); + const bookId = parseInt(pathParts[pathParts.length - 1]); + let currentBook = null; + let cachedUsers = null; + let selectedLoanUserId = null; + let activeLoan = null; + + init(); + + function init() { + if (!bookId || isNaN(bookId)) { + Utils.showToast("Некорректный ID книги", "error"); + return; + } + loadBookData(); + setupEventHandlers(); + } + + function setupEventHandlers() { + $(document).on("click", (e) => { + const $menu = $("#status-menu"); + const $toggleBtn = $("#status-toggle-btn"); + if ( + $menu.length && + !$menu.hasClass("hidden") && + !$toggleBtn.is(e.target) && + $toggleBtn.has(e.target).length === 0 && + !$menu.has(e.target).length + ) { + $menu.addClass("hidden"); + } + }); + + $("#cancel-loan-btn").on("click", closeLoanModal); + $("#user-search-input").on("input", handleUserSearch); + $("#confirm-loan-btn").on("click", submitLoan); + $("#refresh-loans-btn").on("click", loadLoans); + + const future = new Date(); + future.setDate(future.getDate() + 14); + $("#loan-due-date").val(future.toISOString().split("T")[0]); + } + + function loadBookData() { + Api.get(`/api/books/${bookId}`) + .then((book) => { + currentBook = book; + document.title = `LiB - ${book.title}`; + renderBook(book); + if (window.canManage()) { + $("#edit-book-btn") + .attr("href", `/book/${book.id}/edit`) + .removeClass("hidden"); + $("#loans-section").removeClass("hidden"); + loadLoans(); + } + }) + .catch((error) => { + console.error(error); + Utils.showToast("Книга не найдена", "error"); + $("#book-loader").html( + '

Ошибка загрузки

', + ); + }); + } + + async function loadLoans() { + if (!window.canManage()) return; + + try { + const data = await Api.get( + `/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10` + ); + activeLoan = data.loans.length > 0 ? data.loans[0] : null; + renderLoans(data.loans); + } catch (error) { + console.error("Failed to load loans", error); + $("#loans-container").html( + '
Ошибка загрузки выдач
', + ); + } + } + + function renderLoans(loans) { + const $container = $("#loans-container"); + $container.empty(); + + if (!loans || loans.length === 0) { + $container.html( + '
Нет активных выдач
', + ); + return; + } + + loans.forEach((loan) => { + const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString( + "ru-RU" + ); + const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); + const isOverdue = + !loan.returned_at && new Date(loan.due_date) < new Date(); + + const $loanCard = $(` +
+
+
+
+ ID выдачи: ${loan.id} + ${ + isOverdue + ? 'Просрочена' + : "" + } +
+

+ Дата выдачи: ${borrowedDate} +

+

+ Срок возврата: ${dueDate} +

+

+ Пользователь ID: ${loan.user_id} +

+
+
+ ${ + !loan.returned_at && currentBook.status === "reserved" + ? `` + : "" + } + ${ + !loan.returned_at + ? `` + : "" + } +
+
+
+ `); + + $loanCard.find(".confirm-loan-btn").on("click", function () { + const loanId = $(this).data("loan-id"); + confirmLoan(loanId); + }); + + $loanCard.find(".return-loan-btn").on("click", function () { + const loanId = $(this).data("loan-id"); + returnLoan(loanId); + }); + + $container.append($loanCard); + }); + } + + async function confirmLoan(loanId) { + try { + await Api.post(`/api/loans/${loanId}/confirm`); + Utils.showToast("Бронь подтверждена", "success"); + loadBookData(); + loadLoans(); + } catch (error) { + console.error(error); + Utils.showToast(error.message || "Ошибка подтверждения брони", "error"); + } + } + + async function returnLoan(loanId) { + if (!confirm("Вы уверены, что хотите вернуть эту книгу?")) { + return; + } + + try { + await Api.post(`/api/loans/${loanId}/return`); + Utils.showToast("Книга возвращена", "success"); + loadBookData(); + loadLoans(); + } catch (error) { + console.error(error); + Utils.showToast(error.message || "Ошибка возврата книги", "error"); + } + } + function getStatusConfig(status) { return ( STATUS_CONFIG[status] || { @@ -43,34 +231,6 @@ $(document).ready(() => { ); } - const pathParts = window.location.pathname.split("/"); - const bookId = pathParts[pathParts.length - 1]; - let currentBook = null; - - if (!bookId || isNaN(bookId)) { - Utils.showToast("Некорректный ID книги", "error"); - return; - } - - Api.get(`/api/books/${bookId}`) - .then((book) => { - currentBook = book; - document.title = `LiB - ${book.title}`; - renderBook(book); - if (window.canManage()) { - $("#edit-book-btn") - .attr("href", `/book/${book.id}/edit`) - .removeClass("hidden"); - } - }) - .catch((error) => { - console.error(error); - Utils.showToast("Книга не найдена", "error"); - $("#book-loader").html( - '

Ошибка загрузки

', - ); - }); - function renderBook(book) { $("#book-title").text(book.title); $("#book-id").text(`ID: ${book.id}`); @@ -81,8 +241,10 @@ $(document).ready(() => { renderStatusWidget(book); - if (!window.canManage && book.status === "active") { + if (!window.canManage() && book.status === "active") { renderReserveButton(); + } else { + $("#book-actions-container").empty(); } if (book.genres && book.genres.length > 0) { @@ -91,10 +253,10 @@ $(document).ready(() => { $genres.empty(); book.genres.forEach((g) => { $genres.append(` - - ${Utils.escapeHtml(g.name)} - - `); + + ${Utils.escapeHtml(g.name)} + + `); }); } @@ -104,13 +266,13 @@ $(document).ready(() => { $authors.empty(); book.authors.forEach((a) => { $authors.append(` - -
- ${a.name.charAt(0).toUpperCase()} -
- ${Utils.escapeHtml(a.name)} -
- `); + +
+ ${a.name.charAt(0).toUpperCase()} +
+ ${Utils.escapeHtml(a.name)} +
+ `); }); } @@ -125,86 +287,96 @@ $(document).ready(() => { if (window.canManage()) { const $dropdownHTML = $(` -
- +
+ - + +
+ `); $container.append($dropdownHTML); - const $toggleBtn = $("#status-toggle-btn"); - const $menu = $("#status-menu"); - - $toggleBtn.on("click", (e) => { + $("#status-toggle-btn").on("click", (e) => { e.stopPropagation(); - $menu.toggleClass("hidden"); - }); - - $(document).on("click", (e) => { - if ( - !$toggleBtn.is(e.target) && - $toggleBtn.has(e.target).length === 0 && - !$menu.has(e.target).length - ) { - $menu.addClass("hidden"); - } + $("#status-menu").toggleClass("hidden"); }); $(".status-option").on("click", function () { const newStatus = $(this).data("status"); - if (newStatus !== currentBook.status) { + $("#status-menu").addClass("hidden"); + + if (newStatus === currentBook.status) return; + + if (newStatus === "borrowed") { + openLoanModal(); + } else { updateBookStatus(newStatus); } - $menu.addClass("hidden"); }); } else { $container.append(` - - ${config.icon} - ${config.label} - - `); + + ${config.icon} + ${config.label} + + `); } } function renderReserveButton() { const $container = $("#book-actions-container"); $container.html(` - - `); + + `); $("#reserve-btn").on("click", function () { - Utils.showToast("Функция бронирования в разработке", "info"); + const user = window.getUser(); + if (!user) { + Utils.showToast("Необходима авторизация", "error"); + return; + } + + Api.post("/api/loans/", { + book_id: currentBook.id, + user_id: user.id, + due_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(), + }) + .then((loan) => { + Utils.showToast("Книга забронирована", "success"); + loadBookData(); + }) + .catch((err) => { + Utils.showToast(err.message || "Ошибка бронирования", "error"); + }); }); } @@ -213,14 +385,12 @@ $(document).ready(() => { const originalContent = $toggleBtn.html(); $toggleBtn.prop("disabled", true).addClass("opacity-75").html(` - - Обновление... - `); + + Обновление... + `); try { const payload = { - title: currentBook.title, - description: currentBook.description, status: newStatus, }; @@ -229,17 +399,155 @@ $(document).ready(() => { payload, ); currentBook = updatedBook; - Utils.showToast("Статус успешно изменен", "success"); - renderStatusWidget(updatedBook); + loadLoans(); } catch (error) { console.error(error); - Utils.showToast("Ошибка при смене статуса", "error"); + Utils.showToast(error.message || "Ошибка при смене статуса", "error"); $toggleBtn .prop("disabled", false) .removeClass("opacity-75") .html(originalContent); } } + + function openLoanModal() { + $("#loan-modal").removeClass("hidden"); + $("#user-search-input").val("")[0].focus(); + $("#users-list-container").html( + '
Загрузка списка пользователей...
', + ); + $("#confirm-loan-btn").prop("disabled", true); + selectedLoanUserId = null; + + fetchUsers(); + } + + function closeLoanModal() { + $("#loan-modal").addClass("hidden"); + } + + async function fetchUsers() { + if (cachedUsers) { + renderUsersList(cachedUsers); + return; + } + + try { + const data = await Api.get("/api/auth/users?skip=0&limit=500"); + cachedUsers = data.users; + renderUsersList(cachedUsers); + } catch (error) { + console.error("Failed to load users", error); + $("#users-list-container").html( + '
Ошибка загрузки пользователей
', + ); + } + } + + function renderUsersList(users) { + const $container = $("#users-list-container"); + $container.empty(); + + if (!users || users.length === 0) { + $container.html( + '
Пользователи не найдены
', + ); + return; + } + + users.forEach((user) => { + const roleBadges = user.roles + .map((r) => { + const color = + r === "admin" + ? "bg-purple-100 text-purple-800" + : r === "librarian" + ? "bg-blue-100 text-blue-800" + : "bg-gray-100 text-gray-800"; + return `${r}`; + }) + .join(""); + + const $item = $(` +
+
+
${Utils.escapeHtml(user.full_name || user.username)}
+
@${Utils.escapeHtml(user.username)} • ${Utils.escapeHtml(user.email)}
+
+
${roleBadges}
+
+ `); + + $item.on("click", function () { + $(".user-item").removeClass("bg-blue-100 border-l-4 border-blue-500"); + $(this).addClass("bg-blue-100 border-l-4 border-blue-500"); + selectedLoanUserId = user.id; + $("#confirm-loan-btn") + .prop("disabled", false) + .text(`Выдать для ${user.username}`); + }); + + $container.append($item); + }); + } + + function handleUserSearch() { + const query = $(this).val().toLowerCase(); + if (!cachedUsers) return; + + if (!query) { + renderUsersList(cachedUsers); + return; + } + + const filtered = cachedUsers.filter( + (u) => + u.username.toLowerCase().includes(query) || + (u.full_name && u.full_name.toLowerCase().includes(query)) || + u.email.toLowerCase().includes(query), + ); + renderUsersList(filtered); + } + + async function submitLoan() { + if (!selectedLoanUserId) return; + const dueDate = $("#loan-due-date").val(); + + if (!dueDate) { + Utils.showToast("Выберите дату возврата", "error"); + return; + } + + const $btn = $("#confirm-loan-btn"); + const originalText = $btn.text(); + $btn.prop("disabled", true).text("Обработка..."); + + try { + const payload = { + book_id: currentBook.id, + user_id: selectedLoanUserId, + due_date: new Date(dueDate).toISOString(), + }; + + // Используем прямой эндпоинт выдачи для администраторов + if (window.isAdmin()) { + await Api.post("/api/loans/issue", payload); + } else { + // Для библиотекарей создаем бронь, которую потом нужно подтвердить + await Api.post("/api/loans/", payload); + } + + Utils.showToast("Книга успешно выдана", "success"); + closeLoanModal(); + loadBookData(); + loadLoans(); + } catch (error) { + console.error(error); + Utils.showToast(error.message || "Ошибка выдачи", "error"); + } finally { + $btn.prop("disabled", false).text(originalText); + } + } }); diff --git a/library_service/static/my_books.js b/library_service/static/my_books.js new file mode 100644 index 0000000..c0c8e25 --- /dev/null +++ b/library_service/static/my_books.js @@ -0,0 +1,248 @@ +$(document).ready(() => { + let allLoans = []; + let booksCache = new Map(); + + init(); + + function init() { + const user = window.getUser(); + if (!user) { + Utils.showToast("Необходима авторизация", "error"); + window.location.href = "/auth"; + return; + } + loadLoans(); + } + + async function loadLoans() { + try { + const data = await Api.get("/api/loans/?page=1&size=100"); + allLoans = data.loans; + + // Загружаем информацию о книгах + const bookIds = [...new Set(allLoans.map(loan => loan.book_id))]; + await loadBooks(bookIds); + + renderLoans(); + } catch (error) { + console.error("Failed to load loans", error); + Utils.showToast("Ошибка загрузки выдач", "error"); + } + } + + async function loadBooks(bookIds) { + const promises = bookIds.map(async (bookId) => { + if (!booksCache.has(bookId)) { + try { + const book = await Api.get(`/api/books/${bookId}`); + booksCache.set(bookId, book); + } catch (error) { + console.error(`Failed to load book ${bookId}`, error); + } + } + }); + await Promise.all(promises); + } + + function renderLoans() { + const reservations = allLoans.filter( + loan => !loan.returned_at && getBookStatus(loan.book_id) === "reserved" + ); + const activeLoans = allLoans.filter( + loan => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed" + ); + const returned = allLoans.filter(loan => loan.returned_at !== null); + + renderReservations(reservations); + renderActiveLoans(activeLoans); + renderReturned(returned); + } + + function getBookStatus(bookId) { + const book = booksCache.get(bookId); + return book ? book.status : null; + } + + function renderReservations(reservations) { + const $container = $("#reservations-container"); + $("#reservations-count").text(reservations.length); + $container.empty(); + + if (reservations.length === 0) { + $container.html( + '
Нет активных бронирований
' + ); + return; + } + + reservations.forEach((loan) => { + const book = booksCache.get(loan.book_id); + if (!book) return; + + const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU"); + const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); + + const $card = $(` +
+
+
+ + ${Utils.escapeHtml(book.title)} + +

+ Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"} +

+
+

Дата бронирования: ${borrowedDate}

+

Срок возврата: ${dueDate}

+
+
+ + Забронирована + +
+
+ +
+
+ `); + + $card.find(".cancel-reservation-btn").on("click", function () { + const loanId = $(this).data("loan-id"); + const bookId = $(this).data("book-id"); + cancelReservation(loanId, bookId); + }); + + $container.append($card); + }); + } + + function renderActiveLoans(activeLoans) { + const $container = $("#active-loans-container"); + $("#active-loans-count").text(activeLoans.length); + $container.empty(); + + if (activeLoans.length === 0) { + $container.html( + '
Нет активных выдач
' + ); + return; + } + + activeLoans.forEach((loan) => { + const book = booksCache.get(loan.book_id); + if (!book) return; + + const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU"); + const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); + const isOverdue = new Date(loan.due_date) < new Date(); + + const $card = $(` +
+
+
+ + ${Utils.escapeHtml(book.title)} + +

+ Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"} +

+
+

Дата выдачи: ${borrowedDate}

+

Срок возврата: ${dueDate}

+
+
+ + Выдана + + ${isOverdue ? 'Просрочена' : ""} +
+
+
+
+ `); + + $container.append($card); + }); + } + + function renderReturned(returned) { + const $container = $("#returned-container"); + $("#returned-count").text(returned.length); + $container.empty(); + + if (returned.length === 0) { + $container.html( + '
Нет возвращенных книг
' + ); + return; + } + + returned.forEach((loan) => { + const book = booksCache.get(loan.book_id); + if (!book) return; + + const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU"); + const returnedDate = new Date(loan.returned_at).toLocaleDateString("ru-RU"); + const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); + + const $card = $(` +
+
+
+ + ${Utils.escapeHtml(book.title)} + +

+ Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"} +

+
+

Дата выдачи: ${borrowedDate}

+

Срок возврата: ${dueDate}

+

Дата возврата: ${returnedDate}

+
+
+ + Возвращена + +
+
+
+
+ `); + + $container.append($card); + }); + } + + async function cancelReservation(loanId, bookId) { + if (!confirm("Вы уверены, что хотите отменить бронирование?")) { + return; + } + + try { + await Api.delete(`/api/loans/${loanId}`); + Utils.showToast("Бронирование отменено", "success"); + + // Удаляем из кэша и перезагружаем + allLoans = allLoans.filter(loan => loan.id !== loanId); + const book = booksCache.get(bookId); + if (book) { + book.status = "active"; + booksCache.set(bookId, book); + } + + renderLoans(); + } catch (error) { + console.error(error); + Utils.showToast(error.message || "Ошибка отмены бронирования", "error"); + } + } +}); + diff --git a/library_service/templates/analytics.html b/library_service/templates/analytics.html new file mode 100644 index 0000000..a88c239 --- /dev/null +++ b/library_service/templates/analytics.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} {% block title %}Аналитика - LiB{% endblock %} {% block content %} +
+
+

Аналитика выдач и возвратов

+

Статистика и графики по выдачам книг

+
+ + +
+
+ + + +
+
+ + +
+
+
+
+

Всего выдач

+

+
+
+ + + +
+
+
+ +
+
+
+

Активные выдачи

+

+
+
+ + + +
+
+
+ +
+
+
+

Возвращено

+

+
+
+ + + +
+
+
+ +
+
+
+

Просрочено

+

+
+
+ + + +
+
+
+ +
+
+
+

Забронировано

+

+
+
+ + + +
+
+
+ +
+
+
+

Выдано сейчас

+

+
+
+ + + +
+
+
+
+ + +
+
+

Выдачи по дням

+
+ +
+
+ +
+

Возвраты по дням

+
+ +
+
+
+ + +
+

Топ книг по выдачам

+
+
Загрузка данных...
+
+
+
+{% endblock %} {% block extra_head %} + +{% endblock %} {% block scripts %} + +{% endblock %} + diff --git a/library_service/templates/base.html b/library_service/templates/base.html index 4cf5c5c..183226d 100644 --- a/library_service/templates/base.html +++ b/library_service/templates/base.html @@ -162,25 +162,46 @@ +
+
+ Загрузка информации о выдачах... +
+
+
+ + + + {% endblock %} {% block scripts %} diff --git a/library_service/templates/my_books.html b/library_service/templates/my_books.html new file mode 100644 index 0000000..a732dcf --- /dev/null +++ b/library_service/templates/my_books.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} {% block content %} +
+
+

Мои книги

+

Управление вашими бронированиями и выдачами

+
+ + +
+
+

Мои бронирования

+ 0 +
+
+
+ Загрузка бронирований... +
+
+
+ + +
+
+

Активные выдачи

+ 0 +
+
+
+ Загрузка активных выдач... +
+
+
+ + +
+
+

История возвратов

+ 0 +
+
+
+ Загрузка истории... +
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} +