Добавление аналитики

This commit is contained in:
2025-12-24 06:50:44 +03:00
parent 82d298effe
commit 5a814d99e6
21 changed files with 2170 additions and 312 deletions
+6 -1
View File
@@ -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_USERNAME = "admin"
# DEFAULT_ADMIN_EMAIL = "admin@example.com" # DEFAULT_ADMIN_EMAIL = "admin@example.com"
# DEFAULT_ADMIN_PASSWORD = "password-is-generated-randomly-on-first-launch" # DEFAULT_ADMIN_PASSWORD = "password-is-generated-randomly-on-first-launch"
POSTGRES_HOST = "db" POSTGRES_HOST = "localhost"
POSTGRES_PORT = "5432" POSTGRES_PORT = "5432"
POSTGRES_USER = "postgres" POSTGRES_USER = "postgres"
POSTGRES_PASSWORD = "postgres" POSTGRES_PASSWORD = "postgres"
+159 -79
View File
@@ -1,17 +1,19 @@
![logo](./logo.png) ![logo](./logo.png)
# LiB # 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 docker compose up test
``` ```
Для добавление данных для примера используйте: Для добавления данных для примера используйте:
```bash ```bash
python data.py python data.py
``` ```
### **Роли пользователей**
- **Админ**: Полный доступ ко всем функциям системы
- **librarian**: Управление книгами, авторами, жанрами и выдачами
- **member**: Просмотр каталога и управление своими выдачами
### **Эндпоинты API** ### **Эндпоинты API**
**Авторы** #### **Аутентификация** (`/api/auth`)
| Метод | Эндпоинты | Описание |
|--------|---------------------------|---------------------------------|
| POST | `/api/authors` | Создать нового автора |
| GET | `/api/authors` | Получить список всех авторов |
| GET | `/api/authors/{id}` | Получить автора по ID с книгами |
| PUT | `/api/authors/{id}` | Обновить автора по ID |
| DELETE | `/api/authors/{id}` | Удалить автора по ID |
**Книги** | Метод | Эндпоинт | Доступ | Описание |
| Метод | Эндпоинты | Описание | |--------|-----------------------------------------------|----------------|------------------------------------------|
|--------|---------------------------|---------------------------------| | POST | `/api/auth/register` | Публичный | Регистрация нового пользователя |
| POST | `/api/books` | Создать новую книгу | | POST | `/api/auth/token` | Публичный | Получение JWT токенов (access + refresh) |
| GET | `/api/books` | Получить список всех книг | | POST | `/api/auth/refresh` | Публичный | Обновление пары токенов |
| GET | `/api/book/{id}` | Получить книгу по ID с авторами | | GET | `/api/auth/me` | Авторизованный | Информация о текущем пользователе |
| PUT | `/api/books/{id}` | Обновить книгу по ID | | PUT | `/api/auth/me` | Авторизованный | Обновление профиля текущего пользователя |
| DELETE | `/api/books/{id}` | Удалить книгу по ID | | 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` | Авторизованный | Список ролей в системе |
**Жанры** #### **Авторы** (`/api/authors`)
| Метод | Эндпоинты | Описание |
|--------|----------------------------|--------------------------------|
| POST | `/api/genres` | Создать новый жанр |
| GET | `/api/genres` | Получить список всех жанров |
| GET | `/api/genres/{id}` | Получить жанр по ID |
| PUT | `/api/genres/{id}` | Обновить жанр по ID |
| DELETE | `/api/genres/{id}` | Удалить жанр по ID |
**Связи** | Метод | Эндпоинт | Доступ | Описание |
| Метод | Эндпоинты | Описание | |--------|---------------------|-----------|---------------------------------|
|--------|------------------------------|-----------------------------------| | POST | `/api/authors` | Сотрудник | Создать нового автора |
| GET | `/authors/{id}/books` | Получить список книг для автора | | GET | `/api/authors` | Публичный | Получить список всех авторов |
| GET | `/books/{id}/authors` | Получить список авторов для книги | | GET | `/api/authors/{id}` | Публичный | Получить автора по ID с книгами |
| POST | `/relationships/author-book` | Связать автор-книга | | PUT | `/api/authors/{id}` | Сотрудник | Обновить автора по ID |
| DELETE | `/relationships/author-book` | Разделить автор-книга | | DELETE | `/api/authors/{id}` | Сотрудник | Удалить автора по ID |
| GET | `/genres/{id}/books` | Получить список книг для жанра |
| GET | `/books/{id}/genres` | Получить список жанров для книги |
| POST | `/relationships/genre-book` | Связать автор-книга |
| DELETE | `/relationships/genre-book` | Разделить автор-книга |
**Другие** #### **Книги** (`/api/books`)
| Метод | Эндпоинты | Описание |
|--------|--------------|----------------------------------------------|
| GET | `/api/info` | Получить общую информацию о сервисе |
| GET | `/api/stats` | Получить статистическую информацию о сервисе |
| Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|-----------------------------------------------------------|
| 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 ```mermaid
erDiagram erDiagram
@@ -110,29 +176,28 @@ erDiagram
string username UK string username UK
string email UK string email UK
string full_name string full_name
string password string hashed_password
boolean is_active boolean is_active
boolean is_verified boolean is_verified
} }
USER_ROLE { ROLE {
int user_id FK int id PK
string role string name UK
string description
int payroll
} }
LOAN { USER_ROLE_LINK {
int id PK
int book_id FK
int user_id FK int user_id FK
datetime borrowed_at int role_id FK
datetime due_date
datetime returned_at
} }
BOOK { BOOK {
int id PK int id PK
string title string title
string description string description
string status
} }
AUTHOR { AUTHOR {
@@ -144,38 +209,53 @@ erDiagram
GENRE { GENRE {
int id PK int id PK
string name string name
string description
} }
AUTHOR_BOOK { AUTHOR_BOOK_LINK {
int author_id FK int author_id FK
int book_id FK int book_id FK
} }
GENRE_BOOK { GENRE_BOOK_LINK {
int genre_id FK int genre_id FK
int book_id FK int book_id FK
} }
USER ||--o{ USER_ROLE : "имеет роли" BOOK_USER_LINK {
USER ||--o{ LOAN : "берёт книги" int id PK
LOAN }o--|| BOOK : "выдача" int book_id FK
int user_id FK
datetime borrowed_at
datetime due_date
datetime returned_at
}
AUTHOR ||--o{ AUTHOR_BOOK : "пишет" USER ||--o{ USER_ROLE_LINK : "имеет"
AUTHOR_BOOK }o--|| BOOK : "авторство" ROLE ||--o{ USER_ROLE_LINK : "назначена"
USER ||--o{ BOOK_USER_LINK : "берет"
GENRE ||--o{ GENRE_BOOK : "содержит" BOOK ||--o{ BOOK_USER_LINK : "выдана"
GENRE_BOOK }o--|| BOOK : "жанр" 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, известный своей скоростью и простотой использования. - **FastAPI**: Современный веб-фреймворк для построения API на Python
- **Pydantic**: Библиотека для валидации данных и управления настройками, использующая аннотации типов Python. - **Pydantic**: Библиотека для валидации данных и управления настройками
- **SQLModel**: Библиотека для взаимодействия с базами данных с использованием классов Python, объединяющая функции SQLAlchemy и Pydantic. - **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic
- **Alembic**: Легковесный инструмент для миграции базы данных на основе SQLAlchemy. - **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy
- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных. - **PostgreSQL**: Реляционная система управления базами данных
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах. - **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker. - **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker
- **Tailwind**: CSS-фреймворк, позволяющий стилизовать веб-интерфейсы, применяя готовые низкоуровневые классы. - **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса
- **Cash**: Микро JavaScript-библиотека, созданная как очень быстрая и компактная альтернатива jQuery. - **Alpine.js**: Легковесный JavaScript-фреймворк для реактивности
- **Chart.js**: Библиотека для визуализации данных
+39 -12
View File
@@ -32,17 +32,17 @@ pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверка пароль по его хешу.""" """Проверяет пароль по его хешу"""
return pwd_context.verify(plain_password, hashed_password) return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
"""Хэширование пароля.""" """Хэширует пароль"""
return pwd_context.hash(password) return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""Создание JWT access токена.""" """Создает JWT access токен"""
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.now(timezone.utc) + 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: def create_refresh_token(data: dict) -> str:
"""Создание JWT refresh токена.""" """Создает JWT refresh токен"""
to_encode = data.copy() to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"}) 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: def decode_token(token: str, expected_type: str = "access") -> TokenData:
"""Декодирование и проверка JWT токенов.""" """Декодирует и проверяет JWT токен"""
token_error = HTTPException( token_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"}, 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: def authenticate_user(session: Session, username: str, password: str) -> User | None:
"""Аутентификация пользователя по имени пользователя и паролю.""" """Аутентифицирует пользователя по имени и паролю"""
statement = select(User).where(User.username == username) statement = select(User).where(User.username == username)
user = session.exec(statement).first() user = session.exec(statement).first()
if not user or not verify_password(password, user.hashed_password): 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)], token: Annotated[str, Depends(oauth2_scheme)],
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> User: ) -> User:
"""Получить текущего авторизованного пользователя.""" """Возвращает текущего авторизованного пользователя"""
token_data = decode_token(token) token_data = decode_token(token)
user = session.get(User, token_data.user_id) user = session.get(User, token_data.user_id)
@@ -116,7 +116,7 @@ def get_current_user(
def get_current_active_user( def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
) -> User: ) -> User:
"""Получить текущего активного пользователя.""" """Проверяет активность пользователя и возвращает его"""
if not current_user.is_active: if not current_user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
@@ -125,7 +125,7 @@ def get_current_active_user(
def require_role(role_name: str): def require_role(role_name: str):
"""Dependency, требующая выполнения определенной роли.""" """Создает dependency для проверки наличия определенной роли"""
def role_checker(current_user: User = Depends(get_current_active_user)) -> User: def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
user_roles = [role.name for role in current_user.roles] user_roles = [role.name for role in current_user.roles]
@@ -139,15 +139,42 @@ def require_role(role_name: str):
return role_checker 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 # Создание dependencies
RequireAuth = Annotated[User, Depends(get_current_active_user)] RequireAuth = Annotated[User, Depends(get_current_active_user)]
RequireAdmin = Annotated[User, Depends(require_role("admin"))] RequireAdmin = Annotated[User, Depends(require_role("admin"))]
RequireMember = Annotated[User, Depends(require_role("member"))] RequireMember = Annotated[User, Depends(require_role("member"))]
RequireLibrarian = Annotated[User, Depends(require_role("librarian"))] 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]: def seed_roles(session: Session) -> dict[str, Role]:
"""Создаёт роли по умолчанию, если их нет.""" """Создает роли по умолчанию, если их нет"""
default_roles = [ default_roles = [
{"name": "admin", "description": "Администратор системы", "payroll": 80000}, {"name": "admin", "description": "Администратор системы", "payroll": 80000},
{"name": "librarian", "description": "Библиотекарь", "payroll": 55000}, {"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: def seed_admin(session: Session, admin_role: Role) -> User | None:
"""Создаёт администратора по умолчанию, если нет ни одного.""" """Создает администратора по умолчанию, если нет ни одного"""
existing_admins = session.exec( existing_admins = session.exec(
select(User).join(User.roles).where(Role.name == "admin") select(User).join(User.roles).where(Role.name == "admin")
).all() ).all()
@@ -219,6 +246,6 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
def run_seeds(session: Session) -> None: def run_seeds(session: Session) -> None:
"""Запускаем создание ролей и администратора.""" """Запускает создание ролей и администратора"""
roles = seed_roles(session) roles = seed_roles(session)
seed_admin(session, roles["admin"]) seed_admin(session, roles["admin"])
+2
View File
@@ -7,6 +7,7 @@ from .user import User
from .links import ( from .links import (
AuthorBookLink, AuthorBookLink,
GenreBookLink, GenreBookLink,
BookUserLink,
UserRoleLink UserRoleLink
) )
@@ -18,5 +19,6 @@ __all__ = [
"User", "User",
"AuthorBookLink", "AuthorBookLink",
"GenreBookLink", "GenreBookLink",
"BookUserLink",
"UserRoleLink", "UserRoleLink",
] ]
+2
View File
@@ -19,6 +19,8 @@ class LoanCreate(LoanBase):
class LoanUpdate(SQLModel): class LoanUpdate(SQLModel):
"""Модель для обновления записи о выдаче""" """Модель для обновления записи о выдаче"""
user_id: int | None = None
due_date: datetime | None = None
returned_at: datetime | None = None returned_at: datetime | None = None
+4
View File
@@ -5,15 +5,19 @@ from .auth import router as auth_router
from .authors import router as authors_router from .authors import router as authors_router
from .books import router as books_router from .books import router as books_router
from .genres import router as genres_router from .genres import router as genres_router
from .loans import router as loans_router
from .relationships import router as relationships_router from .relationships import router as relationships_router
from .misc import router as misc_router from .misc import router as misc_router
api_router = APIRouter() api_router = APIRouter()
# Подключение всех маршрутов # Подключение всех маршрутов
api_router.include_router(misc_router) api_router.include_router(misc_router)
api_router.include_router(auth_router, prefix="/api") api_router.include_router(auth_router, prefix="/api")
api_router.include_router(authors_router, prefix="/api") api_router.include_router(authors_router, prefix="/api")
api_router.include_router(books_router, prefix="/api") api_router.include_router(books_router, prefix="/api")
api_router.include_router(genres_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") api_router.include_router(relationships_router, prefix="/api")
+12 -13
View File
@@ -9,10 +9,11 @@ from sqlmodel import Session, select
from library_service.models.db import Role, User from library_service.models.db import Role, User
from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList
from library_service.settings import get_session 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, authenticate_user, get_password_hash, decode_token,
create_access_token, create_refresh_token) create_access_token, create_refresh_token)
router = APIRouter(prefix="/auth", tags=["authentication"]) router = APIRouter(prefix="/auth", tags=["authentication"])
@@ -24,8 +25,7 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
description="Создает нового пользователя в системе", description="Создает нового пользователя в системе",
) )
def register(user_data: UserCreate, session: Session = Depends(get_session)): def register(user_data: UserCreate, session: Session = Depends(get_session)):
"""Эндпоинт регистрации пользователя""" """Регистрирует нового пользователя в системе"""
# Проверка если username существует
existing_user = session.exec( existing_user = session.exec(
select(User).where(User.username == user_data.username) select(User).where(User.username == user_data.username)
).first() ).first()
@@ -35,7 +35,6 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
detail="Username already registered", detail="Username already registered",
) )
# Проверка если email существует
existing_email = session.exec( existing_email = session.exec(
select(User).where(User.email == user_data.email) select(User).where(User.email == user_data.email)
).first() ).first()
@@ -70,7 +69,7 @@ def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт аутентификации и получения JWT токена""" """Аутентифицирует пользователя и возвращает JWT токены"""
user = authenticate_user(session, form_data.username, form_data.password) user = authenticate_user(session, form_data.username, form_data.password)
if not user: if not user:
raise HTTPException( raise HTTPException(
@@ -103,7 +102,7 @@ def refresh_token(
refresh_token: str = Body(..., embed=True), refresh_token: str = Body(..., embed=True),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт для обновления токенов.""" """Обновляет пару токенов (access и refresh)"""
try: try:
token_data = decode_token(refresh_token, expected_type="refresh") token_data = decode_token(refresh_token, expected_type="refresh")
except HTTPException: except HTTPException:
@@ -149,7 +148,7 @@ def refresh_token(
description="Получить информацию о текущем авторизованном пользователе", description="Получить информацию о текущем авторизованном пользователе",
) )
def get_my_profile(current_user: RequireAuth): def get_my_profile(current_user: RequireAuth):
"""Эндпоинт получения информации о себе""" """Возвращает информацию о текущем пользователе"""
return UserRead( return UserRead(
**current_user.model_dump(), roles=[role.name for role in current_user.roles] **current_user.model_dump(), roles=[role.name for role in current_user.roles]
) )
@@ -166,7 +165,7 @@ def update_user_me(
current_user: RequireAuth, current_user: RequireAuth,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления пользователя""" """Обновляет профиль текущего пользователя"""
if user_update.email: if user_update.email:
current_user.email = user_update.email current_user.email = user_update.email
if user_update.full_name: if user_update.full_name:
@@ -190,12 +189,12 @@ def update_user_me(
description="Получить список всех пользователей (только для админов)", description="Получить список всех пользователей (только для админов)",
) )
def read_users( def read_users(
admin: RequireAdmin, current_user: RequireStaff,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт получения списка всех пользователей""" """Возвращает список всех пользователей"""
users = session.exec(select(User).offset(skip).limit(limit)).all() users = session.exec(select(User).offset(skip).limit(limit)).all()
return UserList( return UserList(
users=[ users=[
@@ -218,7 +217,7 @@ def add_role_to_user(
admin: RequireAdmin, admin: RequireAdmin,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт добавления роли пользователю""" """Добавляет роль пользователю"""
user = session.get(User, user_id) user = session.get(User, user_id)
if not user: if not user:
raise HTTPException( raise HTTPException(
@@ -259,7 +258,7 @@ def remove_role_from_user(
admin: RequireAdmin, admin: RequireAdmin,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления роли у пользователя""" """Удаляет роль у пользователя"""
user = session.get(User, user_id) user = session.get(User, user_id)
if not user: if not user:
raise HTTPException( raise HTTPException(
@@ -298,7 +297,7 @@ def get_roles(
auth: RequireAuth, auth: RequireAuth,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт получения списа ролей""" """Возвращает список ролей в системе"""
user_roles = [role.name for role in auth.roles] user_roles = [role.name for role in auth.roles]
exclude = {"payroll"} if "admin" in user_roles else set() exclude = {"payroll"} if "admin" in user_roles else set()
roles = session.exec(select(Role)).all() roles = session.exec(select(Role)).all()
+10 -9
View File
@@ -2,12 +2,13 @@
from fastapi import APIRouter, Depends, HTTPException, Path from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select 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.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import (BookRead, AuthorWithBooks, from library_service.models.dto import (BookRead, AuthorWithBooks,
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate) AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
router = APIRouter(prefix="/authors", tags=["authors"]) router = APIRouter(prefix="/authors", tags=["authors"])
@@ -18,11 +19,11 @@ router = APIRouter(prefix="/authors", tags=["authors"])
description="Добавляет автора в систему", description="Добавляет автора в систему",
) )
def create_author( def create_author(
current_user: RequireAuth, current_user: RequireStaff,
author: AuthorCreate, author: AuthorCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт создания автора""" """Создает нового автора в системе"""
db_author = Author(**author.model_dump()) db_author = Author(**author.model_dump())
session.add(db_author) session.add(db_author)
session.commit() session.commit()
@@ -37,7 +38,7 @@ def create_author(
description="Возвращает список всех авторов в системе", description="Возвращает список всех авторов в системе",
) )
def read_authors(session: Session = Depends(get_session)): def read_authors(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка авторов""" """Возвращает список всех авторов"""
authors = session.exec(select(Author)).all() authors = session.exec(select(Author)).all()
return AuthorList( return AuthorList(
authors=[AuthorRead(**author.model_dump()) for author in authors], 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), author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт чтения конкретного автора""" """Возвращает информацию об авторе и его книгах"""
author = session.get(Author, author_id) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
@@ -79,12 +80,12 @@ def get_author(
description="Обновляет информацию об авторе в системе", description="Обновляет информацию об авторе в системе",
) )
def update_author( def update_author(
current_user: RequireAuth, current_user: RequireStaff,
author: AuthorUpdate, author: AuthorUpdate,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления автора""" """Обновляет информацию об авторе"""
db_author = session.get(Author, author_id) db_author = session.get(Author, author_id)
if not db_author: if not db_author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
@@ -105,11 +106,11 @@ def update_author(
description="Удаляет автора из системы", description="Удаляет автора из системы",
) )
def delete_author( def delete_author(
current_user: RequireAuth, current_user: RequireStaff,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления автора""" """Удаляет автора из системы"""
author = session.get(Author, author_id) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
+51 -17
View File
@@ -1,12 +1,14 @@
"""Модуль работы с книгами""" """Модуль работы с книгами"""
from datetime import datetime
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlmodel import Session, select, col, func 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.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 import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
from library_service.models.dto.combined import ( from library_service.models.dto.combined import (
BookWithAuthorsAndGenres, BookWithAuthorsAndGenres,
@@ -17,6 +19,19 @@ from library_service.models.dto.combined import (
router = APIRouter(prefix="/books", tags=["books"]) 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( @router.get(
"/filter", "/filter",
response_model=BookFilteredList, response_model=BookFilteredList,
@@ -31,7 +46,7 @@ def filter_books(
page: int = Query(1, gt=0, description="Номер страницы"), page: int = Query(1, gt=0, description="Номер страницы"),
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"), size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
): ):
"""Эндпоинт получения отфильтрованного списка книг""" """Возвращает отфильтрованный список книг с пагинацией"""
statement = select(Book).distinct() statement = select(Book).distinct()
if q: if q:
@@ -72,9 +87,11 @@ def filter_books(
description="Добавляет книгу в систему", description="Добавляет книгу в систему",
) )
def create_book( 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()) db_book = Book(**book.model_dump())
session.add(db_book) session.add(db_book)
session.commit() session.commit()
@@ -89,7 +106,7 @@ def create_book(
description="Возвращает список всех книг в системе", description="Возвращает список всех книг в системе",
) )
def read_books(session: Session = Depends(get_session)): def read_books(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка книг""" """Возвращает список всех книг"""
books = session.exec(select(Book)).all() books = session.exec(select(Book)).all()
return BookList( return BookList(
books=[BookRead(**book.model_dump()) for book in books], total=len(books) 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), book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт чтения конкретной книги""" """Возвращает информацию о книге с авторами и жанрами"""
book = session.get(Book, book_id) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
@@ -137,22 +154,39 @@ def get_book(
description="Обновляет информацию о книге в системе", description="Обновляет информацию о книге в системе",
) )
def update_book( def update_book(
current_user: RequireAuth, current_user: RequireStaff,
book: BookUpdate, book_update: BookUpdate,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), book_id: int = Path(..., gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления книги""" """Обновляет информацию о книге"""
db_book = session.get(Book, book_id) db_book = session.get(Book, book_id)
if not db_book: if not db_book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
db_book.title = book.title or db_book.title if book_update.status is not None:
db_book.description = book.description or db_book.description if book_update.status == BookStatus.BORROWED:
db_book.status = book.status or db_book.status 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.commit()
session.refresh(db_book) session.refresh(db_book)
return db_book
return BookRead(**db_book.model_dump())
@router.delete( @router.delete(
@@ -162,11 +196,11 @@ def update_book(
description="Удаляет книгу их системы", description="Удаляет книгу их системы",
) )
def delete_book( def delete_book(
current_user: RequireAuth, current_user: RequireStaff,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления книги""" """Удаляет книгу из системы"""
book = session.get(Book, book_id) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
+12 -11
View File
@@ -2,11 +2,12 @@
from fastapi import APIRouter, Depends, HTTPException, Path from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select 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.db import Book, Genre, GenreBookLink
from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks
from library_service.settings import get_session from library_service.settings import get_session
router = APIRouter(prefix="/genres", tags=["genres"]) router = APIRouter(prefix="/genres", tags=["genres"])
@@ -17,11 +18,11 @@ router = APIRouter(prefix="/genres", tags=["genres"])
description="Добавляет жанр книг в систему", description="Добавляет жанр книг в систему",
) )
def create_genre( def create_genre(
current_user: RequireAuth, current_user: RequireStaff,
genre: GenreCreate, genre: GenreCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт создания жанра""" """Создает новый жанр в системе"""
db_genre = Genre(**genre.model_dump()) db_genre = Genre(**genre.model_dump())
session.add(db_genre) session.add(db_genre)
session.commit() session.commit()
@@ -36,7 +37,7 @@ def create_genre(
description="Возвращает список всех жанров в системе", description="Возвращает список всех жанров в системе",
) )
def read_genres(session: Session = Depends(get_session)): def read_genres(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка жанров""" """Возвращает список всех жанров"""
genres = session.exec(select(Genre)).all() genres = session.exec(select(Genre)).all()
return GenreList( return GenreList(
genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres) 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), genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт чтения конкретного жанра""" """Возвращает информацию о жанре и книгах с ним"""
genre = session.get(Genre, genre_id) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
@@ -73,16 +74,16 @@ def get_genre(
@router.put( @router.put(
"/{genre_id}", "/{genre_id}",
response_model=GenreRead, response_model=GenreRead,
summary="Обновляет информацию о жанре", summary="Обновить информацию о жанре",
description="Обновляет информацию о жанре в системе", description="Обновляет информацию о жанре в системе",
) )
def update_genre( def update_genre(
current_user: RequireAuth, current_user: RequireStaff,
genre: GenreUpdate, genre: GenreUpdate,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления жанра""" """Обновляет информацию о жанре"""
db_genre = session.get(Genre, genre_id) db_genre = session.get(Genre, genre_id)
if not db_genre: if not db_genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
@@ -100,14 +101,14 @@ def update_genre(
"/{genre_id}", "/{genre_id}",
response_model=GenreRead, response_model=GenreRead,
summary="Удалить жанр", summary="Удалить жанр",
description="Удаляет автора из системы", description="Удаляет жанр из системы",
) )
def delete_genre( def delete_genre(
current_user: RequireAuth, current_user: RequireStaff,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления жанра""" """Удаляет жанр из системы"""
genre = session.get(Genre, genre_id) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
+506
View File
@@ -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())
+32 -20
View File
@@ -18,7 +18,7 @@ templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates"
def get_info(app) -> Dict: def get_info(app) -> Dict:
"""Форматированная информация о приложении""" """Возвращает информацию о приложении"""
return { return {
"status": "ok", "status": "ok",
"app_info": { "app_info": {
@@ -32,103 +32,115 @@ def get_info(app) -> Dict:
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
async def root(request: Request): async def root(request: Request):
"""Эндпоинт главной страницы""" """Рендерит главную страницу"""
return templates.TemplateResponse(request, "index.html") return templates.TemplateResponse(request, "index.html")
@router.get("/genre/create", include_in_schema=False) @router.get("/genre/create", include_in_schema=False)
async def create_genre(request: Request): async def create_genre(request: Request):
"""Эндпоинт страницы создания жанра""" """Рендерит страницу создания жанра"""
return templates.TemplateResponse(request, "create_genre.html") return templates.TemplateResponse(request, "create_genre.html")
@router.get("/genre/{genre_id}/edit", include_in_schema=False) @router.get("/genre/{genre_id}/edit", include_in_schema=False)
async def edit_genre(request: Request, genre_id: int): async def edit_genre(request: Request, genre_id: int):
"""Эндпоинт страницы редактирования жанра""" """Рендерит страницу редактирования жанра"""
return templates.TemplateResponse(request, "edit_genre.html") return templates.TemplateResponse(request, "edit_genre.html")
@router.get("/authors", include_in_schema=False) @router.get("/authors", include_in_schema=False)
async def authors(request: Request): async def authors(request: Request):
"""Эндпоинт страницы выбора автора""" """Рендерит страницу списка авторов"""
return templates.TemplateResponse(request, "authors.html") return templates.TemplateResponse(request, "authors.html")
@router.get("/author/create", include_in_schema=False) @router.get("/author/create", include_in_schema=False)
async def create_author(request: Request): async def create_author(request: Request):
"""Эндпоинт страницы создания автора""" """Рендерит страницу создания автора"""
return templates.TemplateResponse(request, "create_author.html") return templates.TemplateResponse(request, "create_author.html")
@router.get("/author/{author_id}/edit", include_in_schema=False) @router.get("/author/{author_id}/edit", include_in_schema=False)
async def edit_author(request: Request, author_id: int): async def edit_author(request: Request, author_id: int):
"""Эндпоинт страницы редактирования автора""" """Рендерит страницу редактирования автора"""
return templates.TemplateResponse(request, "edit_author.html") return templates.TemplateResponse(request, "edit_author.html")
@router.get("/author/{author_id}", include_in_schema=False) @router.get("/author/{author_id}", include_in_schema=False)
async def author(request: Request, author_id: int): async def author(request: Request, author_id: int):
"""Эндпоинт страницы автора""" """Рендерит страницу просмотра автора"""
return templates.TemplateResponse(request, "author.html") return templates.TemplateResponse(request, "author.html")
@router.get("/books", include_in_schema=False) @router.get("/books", include_in_schema=False)
async def books(request: Request): async def books(request: Request):
"""Эндпоинт страницы выбора книг""" """Рендерит страницу списка книг"""
return templates.TemplateResponse(request, "books.html") return templates.TemplateResponse(request, "books.html")
@router.get("/book/create", include_in_schema=False) @router.get("/book/create", include_in_schema=False)
async def create_book(request: Request): async def create_book(request: Request):
"""Эндпоинт страницы создания книги""" """Рендерит страницу создания книги"""
return templates.TemplateResponse(request, "create_book.html") return templates.TemplateResponse(request, "create_book.html")
@router.get("/book/{book_id}/edit", include_in_schema=False) @router.get("/book/{book_id}/edit", include_in_schema=False)
async def edit_book(request: Request, book_id: int): async def edit_book(request: Request, book_id: int):
"""Эндпоинт страницы редактирования книги""" """Рендерит страницу редактирования книги"""
return templates.TemplateResponse(request, "edit_book.html") return templates.TemplateResponse(request, "edit_book.html")
@router.get("/book/{book_id}", include_in_schema=False) @router.get("/book/{book_id}", include_in_schema=False)
async def book(request: Request, book_id: int): async def book(request: Request, book_id: int):
"""Эндпоинт страницы книги""" """Рендерит страницу просмотра книги"""
return templates.TemplateResponse(request, "book.html") return templates.TemplateResponse(request, "book.html")
@router.get("/auth", include_in_schema=False) @router.get("/auth", include_in_schema=False)
async def auth(request: Request): async def auth(request: Request):
"""Эндпоинт страницы авторизации""" """Рендерит страницу авторизации"""
return templates.TemplateResponse(request, "auth.html") return templates.TemplateResponse(request, "auth.html")
@router.get("/profile", include_in_schema=False) @router.get("/profile", include_in_schema=False)
async def profile(request: Request): async def profile(request: Request):
"""Эндпоинт страницы профиля""" """Рендерит страницу профиля пользователя"""
return templates.TemplateResponse(request, "profile.html") return templates.TemplateResponse(request, "profile.html")
@router.get("/users", include_in_schema=False) @router.get("/users", include_in_schema=False)
async def users(request: Request): async def users(request: Request):
"""Эндпоинт страницы управления пользователями""" """Рендерит страницу управления пользователями"""
return templates.TemplateResponse(request, "users.html") 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) @router.get("/api", include_in_schema=False)
async def api(request: Request, app=Depends(lambda: get_app())): async def api(request: Request, app=Depends(lambda: get_app())):
"""Страница с сылками на документацию API""" """Рендерит страницу с ссылками на документацию API"""
return templates.TemplateResponse(request, "api.html", get_info(app)) return templates.TemplateResponse(request, "api.html", get_info(app))
@router.get("/favicon.ico", include_in_schema=False) @router.get("/favicon.ico", include_in_schema=False)
def redirect_favicon(): def redirect_favicon():
"""Редирект иконки вкладки""" """Редиректит на favicon.svg"""
return RedirectResponse("/favicon.svg") return RedirectResponse("/favicon.svg")
@router.get("/favicon.svg", include_in_schema=False) @router.get("/favicon.svg", include_in_schema=False)
async def favicon(): async def favicon():
"""Эндпоинт иконки вкладки""" """Возвращает иконку сайта"""
return FileResponse( return FileResponse(
"library_service/static/favicon.svg", media_type="image/svg+xml" "library_service/static/favicon.svg", media_type="image/svg+xml"
) )
@@ -140,7 +152,7 @@ async def favicon():
description="Возвращает общую информацию о системе", description="Возвращает общую информацию о системе",
) )
async def api_info(app=Depends(lambda: get_app())): async def api_info(app=Depends(lambda: get_app())):
"""Эндпоинт информации об API""" """Возвращает информацию о сервисе"""
return JSONResponse(content=get_info(app)) return JSONResponse(content=get_info(app))
@@ -150,7 +162,7 @@ async def api_info(app=Depends(lambda: get_app())):
description="Возвращает статистическую информацию о системе", description="Возвращает статистическую информацию о системе",
) )
async def api_stats(session: Session = Depends(get_session)): async def api_stats(session: Session = Depends(get_session)):
"""Эндпоинт стстистики системы""" """Возвращает статистику системы"""
authors = select(func.count()).select_from(Author) authors = select(func.count()).select_from(Author)
books = select(func.count()).select_from(Book) books = select(func.count()).select_from(Book)
genres = select(func.count()).select_from(Genre) genres = select(func.count()).select_from(Genre)
+17 -17
View File
@@ -4,7 +4,7 @@ from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select 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.db import Author, AuthorBookLink, Book, Genre, GenreBookLink
from library_service.models.dto import AuthorRead, BookRead, GenreRead from library_service.models.dto import AuthorRead, BookRead, GenreRead
from library_service.settings import get_session 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): def check_entity_exists(session, model, entity_id, entity_name):
"""Проверка существования связи между сущностями в БД""" """Проверяет существование сущности в базе данных"""
entity = session.get(model, entity_id) entity = session.get(model, entity_id)
if not entity: if not entity:
raise HTTPException(status_code=404, detail=f"{entity_name} not found") 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): def add_relationship(session, link_model, id1, field1, id2, field2, detail):
"""Создание связи между сущностями в БД""" """Создает связь между сущностями в базе данных"""
existing_link = session.exec( existing_link = session.exec(
select(link_model) select(link_model)
.where(getattr(link_model, field1) == id1) .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): def remove_relationship(session, link_model, id1, field1, id2, field2):
"""Удаление связи между сущностями в БД""" """Удаляет связь между сущностями в базе данных"""
link = session.exec( link = session.exec(
select(link_model) select(link_model)
.where(getattr(link_model, field1) == id1) .where(getattr(link_model, field1) == id1)
@@ -66,7 +66,7 @@ def get_related(
link_related_field, link_related_field,
read_model read_model
): ):
"""Получение связанных в БД сущностей""" """Возвращает список связанных сущностей"""
check_entity_exists(session, main_model, main_id, main_name) check_entity_exists(session, main_model, main_id, main_name)
related = session.exec( related = session.exec(
@@ -84,12 +84,12 @@ def get_related(
description="Добавляет связь между автором и книгой в систему", description="Добавляет связь между автором и книгой в систему",
) )
def add_author_to_book( def add_author_to_book(
current_user: RequireAuth, current_user: RequireStaff,
author_id: int, author_id: int,
book_id: int, book_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт добавления автора к книге""" """Добавляет связь между автором и книгой"""
check_entity_exists(session, Author, author_id, "Author") check_entity_exists(session, Author, author_id, "Author")
check_entity_exists(session, Book, book_id, "Book") check_entity_exists(session, Book, book_id, "Book")
@@ -104,12 +104,12 @@ def add_author_to_book(
description="Удаляет связь между автором и книгой в системе", description="Удаляет связь между автором и книгой в системе",
) )
def remove_author_from_book( def remove_author_from_book(
current_user: RequireAuth, current_user: RequireStaff,
author_id: int, author_id: int,
book_id: int, book_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления автора из книги""" """Удаляет связь между автором и книгой"""
return remove_relationship(session, AuthorBookLink, return remove_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id") author_id, "author_id", book_id, "book_id")
@@ -121,7 +121,7 @@ def remove_author_from_book(
description="Возвращает все книги в системе, написанные автором", description="Возвращает все книги в системе, написанные автором",
) )
def get_books_for_author(author_id: int, session: Session = Depends(get_session)): def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
"""Эндпоинт получения книг, написанных автором""" """Возвращает список книг автора"""
return get_related(session, return get_related(session,
Author, author_id, "Author", Book, Author, author_id, "Author", Book,
AuthorBookLink, "author_id", "book_id", BookRead) AuthorBookLink, "author_id", "book_id", BookRead)
@@ -134,7 +134,7 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
description="Возвращает всех авторов книги в системе", description="Возвращает всех авторов книги в системе",
) )
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
"""Эндпоинт получения авторов книги""" """Возвращает список авторов книги"""
return get_related(session, return get_related(session,
Book, book_id, "Book", Author, Book, book_id, "Book", Author,
AuthorBookLink, "book_id", "author_id", AuthorRead) AuthorBookLink, "book_id", "author_id", AuthorRead)
@@ -147,12 +147,12 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
description="Добавляет связь между книгой и жанром в систему", description="Добавляет связь между книгой и жанром в систему",
) )
def add_genre_to_book( def add_genre_to_book(
current_user: RequireAuth, current_user: RequireStaff,
genre_id: int, genre_id: int,
book_id: int, book_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт добавления жанра к книге""" """Добавляет связь между жанром и книгой"""
check_entity_exists(session, Genre, genre_id, "Genre") check_entity_exists(session, Genre, genre_id, "Genre")
check_entity_exists(session, Book, book_id, "Book") check_entity_exists(session, Book, book_id, "Book")
@@ -167,12 +167,12 @@ def add_genre_to_book(
description="Удаляет связь между жанром и книгой в системе", description="Удаляет связь между жанром и книгой в системе",
) )
def remove_genre_from_book( def remove_genre_from_book(
current_user: RequireAuth, current_user: RequireStaff,
genre_id: int, genre_id: int,
book_id: int, book_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления жанра из книги""" """Удаляет связь между жанром и книгой"""
return remove_relationship(session, GenreBookLink, return remove_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id") genre_id, "genre_id", book_id, "book_id")
@@ -184,7 +184,7 @@ def remove_genre_from_book(
description="Возвращает все книги в системе в этом жанре", description="Возвращает все книги в системе в этом жанре",
) )
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)): def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
"""Эндпоинт получения книг с жанром""" """Возвращает список книг в жанре"""
return get_related(session, return get_related(session,
Genre, genre_id, "Genre", Book, Genre, genre_id, "Genre", Book,
GenreBookLink, "genre_id", "book_id", BookRead) GenreBookLink, "genre_id", "book_id", BookRead)
@@ -197,7 +197,7 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
description="Возвращает все жанры книги в системе", description="Возвращает все жанры книги в системе",
) )
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)): def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
"""Эндпоинт получения жанров книги""" """Возвращает список жанров книги"""
return get_related(session, return get_related(session,
Book, book_id, "Book", Genre, Book, book_id, "Book", Genre,
GenreBookLink, "book_id", "genre_id", GenreRead) GenreBookLink, "book_id", "genre_id", GenreRead)
+7 -3
View File
@@ -13,7 +13,7 @@ with open("pyproject.toml", 'r', encoding='utf-8') as f:
def get_app(lifespan=None, /) -> FastAPI: def get_app(lifespan=None, /) -> FastAPI:
"""Dependency для получения экземпляра FastAPI application""" """Возвращает экземпляр FastAPI приложения"""
if not hasattr(get_app, 'instance'): if not hasattr(get_app, 'instance'):
get_app.instance = FastAPI( get_app.instance = FastAPI(
title=config["tool"]["poetry"]["name"], title=config["tool"]["poetry"]["name"],
@@ -37,6 +37,10 @@ def get_app(lifespan=None, /) -> FastAPI:
"name": "genres", "name": "genres",
"description": "Действия с жанрами.", "description": "Действия с жанрами.",
}, },
{
"name": "loans",
"description": "Действия с выдачами.",
},
{ {
"name": "relations", "name": "relations",
"description": "Действия с связями.", "description": "Действия с связями.",
@@ -64,11 +68,11 @@ engine = create_engine(POSTGRES_DATABASE_URL, echo=False, future=True)
def get_session(): def get_session():
"""Dependency, для получение сессии БД""" """Возвращает сессию базы данных"""
with Session(engine) as session: with Session(engine) as session:
yield session yield session
def get_logger(name: str = "uvicorn"): def get_logger(name: str = "uvicorn"):
"""Dependency, для получение логгера""" """Возвращает логгер с указанным именем"""
return logging.getLogger(name) return logging.getLogger(name)
+262
View File
@@ -0,0 +1,262 @@
$(document).ready(() => {
if (!window.isAdmin()) {
$(".container").html(
'<div class="bg-white rounded-xl shadow-sm p-8 text-center border border-gray-100"><svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg><h3 class="text-lg font-medium text-gray-900 mb-2">Доступ запрещён</h3><p class="text-gray-500 mb-4">Только администраторы могут просматривать аналитику</p><a href="/" class="inline-block px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition">На главную</a></div>'
);
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(
'<div class="text-center text-gray-500 py-8">Нет данных</div>'
);
return;
}
topBooks.forEach((book, index) => {
const $item = $(`
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100 hover:bg-gray-100 transition-colors">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="w-7 h-7 bg-gray-600 text-white rounded-full flex items-center justify-center font-medium text-xs flex-shrink-0">
${index + 1}
</div>
<div class="flex-1 min-w-0">
<a href="/book/${book.book_id}" class="text-sm font-medium text-gray-900 hover:text-gray-600 transition-colors block truncate">
${Utils.escapeHtml(book.title)}
</a>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
<span class="px-2.5 py-1 bg-gray-200 text-gray-700 rounded-full text-xs font-medium">
${book.loan_count} ${book.loan_count === 1 ? "выдача" : book.loan_count < 5 ? "выдачи" : "выдач"}
</span>
</div>
</div>
`);
$container.append($item);
});
}
});
+413 -105
View File
@@ -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(
'<p class="text-center text-red-500 w-full p-4">Ошибка загрузки</p>',
);
});
}
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(
'<div class="text-center text-red-500 py-4">Ошибка загрузки выдач</div>',
);
}
}
function renderLoans(loans) {
const $container = $("#loans-container");
$container.empty();
if (!loans || loans.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>',
);
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 = $(`
<div class="border border-gray-200 rounded-lg p-4 ${
isOverdue ? "bg-red-50 border-red-300" : "bg-gray-50"
}">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="font-medium text-gray-900">ID выдачи: ${loan.id}</span>
${
isOverdue
? '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full">Просрочена</span>'
: ""
}
</div>
<p class="text-sm text-gray-600 mb-1">
<span class="font-medium">Дата выдачи:</span> ${borrowedDate}
</p>
<p class="text-sm text-gray-600 mb-1">
<span class="font-medium">Срок возврата:</span> ${dueDate}
</p>
<p class="text-sm text-gray-600">
<span class="font-medium">Пользователь ID:</span> ${loan.user_id}
</p>
</div>
<div class="flex flex-col gap-2">
${
!loan.returned_at && currentBook.status === "reserved"
? `<button class="confirm-loan-btn px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors" data-loan-id="${loan.id}">
Подтвердить
</button>`
: ""
}
${
!loan.returned_at
? `<button class="return-loan-btn px-3 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors" data-loan-id="${loan.id}">
Вернуть
</button>`
: ""
}
</div>
</div>
</div>
`);
$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) { function getStatusConfig(status) {
return ( return (
STATUS_CONFIG[status] || { 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(
'<p class="text-center text-red-500 w-full p-4">Ошибка загрузки</p>',
);
});
function renderBook(book) { function renderBook(book) {
$("#book-title").text(book.title); $("#book-title").text(book.title);
$("#book-id").text(`ID: ${book.id}`); $("#book-id").text(`ID: ${book.id}`);
@@ -81,8 +241,10 @@ $(document).ready(() => {
renderStatusWidget(book); renderStatusWidget(book);
if (!window.canManage && book.status === "active") { if (!window.canManage() && book.status === "active") {
renderReserveButton(); renderReserveButton();
} else {
$("#book-actions-container").empty();
} }
if (book.genres && book.genres.length > 0) { if (book.genres && book.genres.length > 0) {
@@ -91,10 +253,10 @@ $(document).ready(() => {
$genres.empty(); $genres.empty();
book.genres.forEach((g) => { book.genres.forEach((g) => {
$genres.append(` $genres.append(`
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200"> <a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
${Utils.escapeHtml(g.name)} ${Utils.escapeHtml(g.name)}
</a> </a>
`); `);
}); });
} }
@@ -104,13 +266,13 @@ $(document).ready(() => {
$authors.empty(); $authors.empty();
book.authors.forEach((a) => { book.authors.forEach((a) => {
$authors.append(` $authors.append(`
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group"> <a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors"> <div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
${a.name.charAt(0).toUpperCase()} ${a.name.charAt(0).toUpperCase()}
</div> </div>
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span> <span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
</a> </a>
`); `);
}); });
} }
@@ -125,86 +287,96 @@ $(document).ready(() => {
if (window.canManage()) { if (window.canManage()) {
const $dropdownHTML = $(` const $dropdownHTML = $(`
<div class="relative inline-block text-left w-full md:w-auto"> <div class="relative inline-block text-left w-full md:w-auto">
<button id="status-toggle-btn" type="button" class="w-full justify-center md:w-auto inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition-all shadow-sm ${config.bgClass} ${config.textClass} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400"> <button id="status-toggle-btn" type="button" class="w-full justify-center md:w-auto inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition-all shadow-sm ${config.bgClass} ${config.textClass} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400">
${config.icon} ${config.icon}
<span class="ml-2">${config.label}</span> <span class="ml-2">${config.label}</span>
<svg class="ml-2 -mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="ml-2 -mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg> </svg>
</button> </button>
<div id="status-menu" class="hidden absolute left-0 md:left-1/2 md:-translate-x-1/2 mt-2 w-56 rounded-xl shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none overflow-hidden z-20"> <div id="status-menu" class="hidden absolute left-0 md:left-1/2 md:-translate-x-1/2 mt-2 w-56 rounded-xl shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none overflow-hidden z-20">
<div class="py-1" role="menu"> <div class="py-1" role="menu">
${Object.entries(STATUS_CONFIG) ${Object.entries(STATUS_CONFIG)
.map( .map(([key, conf]) => {
([key, conf]) => ` const isCurrent = book.status === key;
<button class="status-option w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center gap-2 ${book.status === key ? "bg-gray-50 font-medium" : "text-gray-700"}" return `
data-status="${key}"> <button class="status-option w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center gap-2 ${isCurrent ? "bg-gray-50 font-medium" : "text-gray-700"}"
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${conf.bgClass} ${conf.textClass}"> data-status="${key}">
${conf.icon} <span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${conf.bgClass} ${conf.textClass}">
</span> ${conf.icon}
<span>${conf.label}</span> </span>
${book.status === key ? '<svg class="ml-auto h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""} <span>${conf.label}</span>
</button> ${isCurrent ? '<svg class="ml-auto h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""}
`, </button>
) `;
.join("")} })
</div> .join("")}
</div>
</div> </div>
`); </div>
</div>
`);
$container.append($dropdownHTML); $container.append($dropdownHTML);
const $toggleBtn = $("#status-toggle-btn"); $("#status-toggle-btn").on("click", (e) => {
const $menu = $("#status-menu");
$toggleBtn.on("click", (e) => {
e.stopPropagation(); e.stopPropagation();
$menu.toggleClass("hidden"); $("#status-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-option").on("click", function () { $(".status-option").on("click", function () {
const newStatus = $(this).data("status"); 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); updateBookStatus(newStatus);
} }
$menu.addClass("hidden");
}); });
} else { } else {
$container.append(` $container.append(`
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium ${config.bgClass} ${config.textClass} shadow-sm"> <span class="inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium ${config.bgClass} ${config.textClass} shadow-sm">
${config.icon} ${config.icon}
${config.label} ${config.label}
</span> </span>
`); `);
} }
} }
function renderReserveButton() { function renderReserveButton() {
const $container = $("#book-actions-container"); const $container = $("#book-actions-container");
$container.html(` $container.html(`
<button id="reserve-btn" class="w-full flex items-center justify-center px-4 py-2.5 bg-gray-800 text-white font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all shadow-sm"> <button id="reserve-btn" class="w-full flex items-center justify-center px-4 py-2.5 bg-gray-800 text-white font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all shadow-sm">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg> </svg>
Зарезервировать Зарезервировать
</button> </button>
`); `);
$("#reserve-btn").on("click", function () { $("#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(); const originalContent = $toggleBtn.html();
$toggleBtn.prop("disabled", true).addClass("opacity-75").html(` $toggleBtn.prop("disabled", true).addClass("opacity-75").html(`
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> <svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
Обновление... Обновление...
`); `);
try { try {
const payload = { const payload = {
title: currentBook.title,
description: currentBook.description,
status: newStatus, status: newStatus,
}; };
@@ -229,17 +399,155 @@ $(document).ready(() => {
payload, payload,
); );
currentBook = updatedBook; currentBook = updatedBook;
Utils.showToast("Статус успешно изменен", "success"); Utils.showToast("Статус успешно изменен", "success");
renderStatusWidget(updatedBook); renderStatusWidget(updatedBook);
loadLoans();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
Utils.showToast("Ошибка при смене статуса", "error"); Utils.showToast(error.message || "Ошибка при смене статуса", "error");
$toggleBtn $toggleBtn
.prop("disabled", false) .prop("disabled", false)
.removeClass("opacity-75") .removeClass("opacity-75")
.html(originalContent); .html(originalContent);
} }
} }
function openLoanModal() {
$("#loan-modal").removeClass("hidden");
$("#user-search-input").val("")[0].focus();
$("#users-list-container").html(
'<div class="p-4 text-center text-gray-500 text-sm">Загрузка списка пользователей...</div>',
);
$("#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(
'<div class="p-4 text-center text-red-500 text-sm">Ошибка загрузки пользователей</div>',
);
}
}
function renderUsersList(users) {
const $container = $("#users-list-container");
$container.empty();
if (!users || users.length === 0) {
$container.html(
'<div class="p-4 text-center text-gray-500 text-sm">Пользователи не найдены</div>',
);
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 `<span class="text-xs px-2 py-0.5 rounded-full ${color} mr-1">${r}</span>`;
})
.join("");
const $item = $(`
<div class="user-item p-3 hover:bg-blue-50 cursor-pointer transition-colors flex items-center justify-between group" data-id="${user.id}">
<div>
<div class="font-medium text-gray-900">${Utils.escapeHtml(user.full_name || user.username)}</div>
<div class="text-xs text-gray-500">@${Utils.escapeHtml(user.username)}${Utils.escapeHtml(user.email)}</div>
</div>
<div>${roleBadges}</div>
</div>
`);
$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);
}
}
}); });
+248
View File
@@ -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(
'<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>'
);
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 = $(`
<div class="border border-blue-200 rounded-lg p-4 bg-blue-50">
<div class="flex items-start justify-between">
<div class="flex-1">
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
${Utils.escapeHtml(book.title)}
</a>
<p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
</p>
<div class="mt-3 space-y-1 text-sm text-gray-600">
<p><span class="font-medium">Дата бронирования:</span> ${borrowedDate}</p>
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
</div>
<div class="mt-2">
<span class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
Забронирована
</span>
</div>
</div>
<button
class="cancel-reservation-btn ml-4 px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors"
data-loan-id="${loan.id}"
data-book-id="${book.id}"
>
Отменить бронь
</button>
</div>
</div>
`);
$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(
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>'
);
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 = $(`
<div class="border ${isOverdue ? "border-red-300 bg-red-50" : "border-yellow-200 bg-yellow-50"} rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-yellow-600 transition-colors">
${Utils.escapeHtml(book.title)}
</a>
<p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
</p>
<div class="mt-3 space-y-1 text-sm text-gray-600">
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
</div>
<div class="mt-2 flex items-center gap-2">
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
Выдана
</span>
${isOverdue ? '<span class="inline-flex items-center px-2 py-1 bg-red-100 text-red-800 rounded-full text-xs font-medium">Просрочена</span>' : ""}
</div>
</div>
</div>
</div>
`);
$container.append($card);
});
}
function renderReturned(returned) {
const $container = $("#returned-container");
$("#returned-count").text(returned.length);
$container.empty();
if (returned.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>'
);
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 = $(`
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div class="flex items-start justify-between">
<div class="flex-1">
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-gray-600 transition-colors">
${Utils.escapeHtml(book.title)}
</a>
<p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
</p>
<div class="mt-3 space-y-1 text-sm text-gray-600">
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
<p><span class="font-medium">Дата возврата:</span> ${returnedDate}</p>
</div>
<div class="mt-2">
<span class="inline-flex items-center px-2 py-1 bg-gray-100 text-gray-800 rounded-full text-xs font-medium">
Возвращена
</span>
</div>
</div>
</div>
</div>
`);
$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");
}
}
});
+142
View File
@@ -0,0 +1,142 @@
{% extends "base.html" %} {% block title %}Аналитика - LiB{% endblock %} {% block content %}
<div class="container mx-auto p-4 max-w-7xl">
<div class="mb-8">
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
<p class="text-sm text-gray-500">Статистика и графики по выдачам книг</p>
</div>
<!-- Период анализа -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6 border border-gray-100">
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
<select id="period-select" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 transition bg-white">
<option value="7">7 дней</option>
<option value="30" selected>30 дней</option>
<option value="90">90 дней</option>
<option value="180">180 дней</option>
<option value="365">365 дней</option>
</select>
<button id="refresh-btn" class="px-3 py-1.5 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm font-medium">
Обновить
</button>
</div>
</div>
<!-- Общая статистика -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Всего выдач</p>
<p class="text-2xl font-semibold text-gray-900" id="total-loans"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Активные выдачи</p>
<p class="text-2xl font-semibold text-gray-900" id="active-loans"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Возвращено</p>
<p class="text-2xl font-semibold text-gray-900" id="returned-loans"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Просрочено</p>
<p class="text-2xl font-semibold text-red-600" id="overdue-loans"></p>
</div>
<div class="w-10 h-10 bg-red-50 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Забронировано</p>
<p class="text-2xl font-semibold text-gray-900" id="reserved-books"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Выдано сейчас</p>
<p class="text-2xl font-semibold text-gray-900" id="borrowed-books"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
</div>
</div>
</div>
<!-- Графики -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h2 class="text-base font-medium text-gray-700 mb-6">Выдачи по дням</h2>
<div class="h-64">
<canvas id="loans-chart"></canvas>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h2 class="text-base font-medium text-gray-700 mb-6">Возвраты по дням</h2>
<div class="h-64">
<canvas id="returns-chart"></canvas>
</div>
</div>
</div>
<!-- Топ книг -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h2 class="text-base font-medium text-gray-700 mb-6">Топ книг по выдачам</h2>
<div id="top-books-container" class="space-y-2">
<div class="text-center text-gray-500 py-8">Загрузка данных...</div>
</div>
</div>
</div>
{% endblock %} {% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% endblock %} {% block scripts %}
<script src="/static/analytics.js"></script>
{% endblock %}
+39 -18
View File
@@ -162,25 +162,46 @@
<template <template
x-if="user.roles && user.roles.includes('admin')" x-if="user.roles && user.roles.includes('admin')"
> >
<a <div>
href="/users" <a
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700" href="/users"
> class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
<svg
class="w-4 h-4 mr-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <svg
stroke-linecap="round" class="w-4 h-4 mr-3 text-gray-400"
stroke-linejoin="round" fill="none"
stroke-width="2" stroke="currentColor"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" viewBox="0 0 24 24"
></path> >
</svg> <path
Пользователи stroke-linecap="round"
</a> stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
Пользователи
</a>
<a
href="/analytics"
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
>
<svg
class="w-4 h-4 mr-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
Аналитика
</a>
</div>
</template> </template>
<div class="border-t border-gray-200"> <div class="border-t border-gray-200">
<button <button
+157 -7
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% block content %} {% extends "base.html" %} {% block content %}
<div class="container mx-auto p-4 max-w-4xl"> <div class="container mx-auto p-4 max-w-6xl">
<div id="book-card" class="bg-white rounded-lg shadow-md p-6"> <div id="book-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<a <a
href="/books" href="/books"
@@ -84,10 +84,11 @@
</div> </div>
<div <div
id="book-status-container" id="book-status-container"
class="relative w-full flex justify-center z-10" class="relative w-full flex justify-center z-10 mb-4"
></div> ></div>
<div id="book-actions-container" class="mt-4 w-full"></div> <div id="book-actions-container" class="w-full"></div>
</div> </div>
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<div <div
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2" class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
@@ -105,7 +106,6 @@
id="book-authors-text" id="book-authors-text"
class="text-lg text-gray-600 font-medium mb-6" class="text-lg text-gray-600 font-medium mb-6"
></p> ></p>
<div class="prose prose-gray max-w-none mb-8"> <div class="prose prose-gray max-w-none mb-8">
<h3 <h3
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2" class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
@@ -117,7 +117,6 @@
class="text-gray-700 leading-relaxed" class="text-gray-700 leading-relaxed"
></p> ></p>
</div> </div>
<div id="genres-section" class="mb-6 hidden"> <div id="genres-section" class="mb-6 hidden">
<h3 <h3
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2" class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
@@ -129,7 +128,6 @@
class="flex flex-wrap gap-2" class="flex flex-wrap gap-2"
></div> ></div>
</div> </div>
<div id="authors-section" class="mb-6 hidden"> <div id="authors-section" class="mb-6 hidden">
<h3 <h3
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2" class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
@@ -144,6 +142,158 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Секция выдачи для библиотекарей и администраторов -->
<div id="loans-section" class="hidden bg-white rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Выдачи книги</h2>
<button
id="refresh-loans-btn"
class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="Обновить список выдач"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
></path>
</svg>
</button>
</div>
<div id="loans-container" class="space-y-3">
<div class="text-center text-gray-500 py-8">
Загрузка информации о выдачах...
</div>
</div>
</div>
</div>
<!-- Модальное окно для выдачи книги -->
<div
id="loan-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"
></div>
<span
class="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>&#8203;</span
>
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"
>
<svg
class="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
></path>
</svg>
</div>
<div
class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full"
>
<h3
class="text-lg leading-6 font-medium text-gray-900"
id="modal-title"
>
Оформить выдачу книги
</h3>
<div class="mt-4">
<div class="relative">
<input
type="text"
id="user-search-input"
class="w-full border border-gray-300 rounded-md px-4 py-2 pl-10 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Поиск пользователя (имя, email)..."
/>
<svg
class="w-5 h-5 text-gray-400 absolute left-3 top-2.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</div>
<div
id="users-list-container"
class="mt-4 border border-gray-200 rounded-md max-h-60 overflow-y-auto divide-y divide-gray-100"
>
<div
class="p-4 text-center text-gray-500 text-sm"
>
Начните ввод для поиска...
</div>
</div>
<div class="mt-4">
<label
class="block text-sm font-medium text-gray-700"
>Срок возврата</label
>
<input
type="date"
id="loan-due-date"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
>
<button
type="button"
id="confirm-loan-btn"
disabled
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-base font-medium text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Выдать
</button>
<button
type="button"
id="cancel-loan-btn"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Отмена
</button>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/book.js"></script> <script src="/static/book.js"></script>
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %} {% block content %}
<div class="container mx-auto p-4 max-w-6xl">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
<p class="text-gray-600">Управление вашими бронированиями и выдачами</p>
</div>
<!-- Бронирования -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Мои бронирования</h2>
<span id="reservations-count" class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">0</span>
</div>
<div id="reservations-container" class="space-y-3">
<div class="text-center text-gray-500 py-8">
Загрузка бронирований...
</div>
</div>
</div>
<!-- Активные выдачи -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Активные выдачи</h2>
<span id="active-loans-count" class="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-sm font-medium">0</span>
</div>
<div id="active-loans-container" class="space-y-3">
<div class="text-center text-gray-500 py-8">
Загрузка активных выдач...
</div>
</div>
</div>
<!-- Возвращенные книги -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">История возвратов</h2>
<span id="returned-count" class="px-3 py-1 bg-gray-100 text-gray-800 rounded-full text-sm font-medium">0</span>
</div>
<div id="returned-container" class="space-y-3">
<div class="text-center text-gray-500 py-8">
Загрузка истории...
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/my_books.js"></script>
{% endblock %}