Compare commits

...

21 Commits

Author SHA1 Message Date
a336d50ad0 превью и улучшение seo 2026-02-01 20:10:21 +03:00
38642a6910 удаление файла после транскодирования 2026-02-01 19:28:36 +03:00
d442a37820 транскодирование изображений 2026-02-01 19:21:06 +03:00
80acdceba6 изображение книги 2026-02-01 16:55:43 +03:00
4368ee0d3c Merge remote-tracking branch 'origin/main' 2026-01-31 23:50:22 +03:00
4f9c472a54 удаление .env 2026-01-31 23:50:09 +03:00
a6811a3e86 удаление .env 2026-01-31 23:43:00 +03:00
19d322c9d9 Единый тип ответа авторизации, добавление кнопки создания автора на странице авторы 2026-01-31 23:41:56 +03:00
dfa4d14afc Добавление мета-тэгов 2026-01-31 15:29:15 +03:00
6014db3c81 Исправление репликации 2026-01-31 01:30:07 +03:00
0e159df16e Локальный мердж 2026-01-31 00:55:45 +03:00
2f3d6f0e1e Страница 404, более подробная инофрмация об ошибках, улучшение фронтэнда и логирования, исправление docker-compose 2026-01-31 00:49:05 +03:00
657f1b96f2 Доавление векторного поиска и репликации 2026-01-29 00:58:48 +03:00
9f814e7271 Доавление векторного поиска и репликации 2026-01-29 00:42:52 +03:00
09d5739256 Динамическое создание er-диаграммы по моделям 2026-01-25 20:19:55 +03:00
ec1c32a5bd Улучшение документации и KDF с шифрованием totp 2026-01-24 10:52:08 +03:00
c1ac0ca246 Добавление catpcha при регистрации, фильтрация по количеству страниц 2026-01-23 23:32:09 +03:00
7c3074e8fe Добавление количества страниц книгам 2026-01-23 01:31:50 +03:00
1e0c3478a1 Улучшение админки 2026-01-20 01:01:42 +03:00
e507896b7a Улучшение безопасности 2026-01-19 23:23:39 +03:00
d6ecd4066f Улучшение безопасности 2026-01-19 23:22:29 +03:00
117 changed files with 7178 additions and 3577 deletions
+3
View File
@@ -0,0 +1,3 @@
*.log
__pycache__/
-23
View File
@@ -1,23 +0,0 @@
# Postgres
POSTGRES_HOST="db"
POSTGRES_PORT="5432"
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="lib"
# DEFAULT_ADMIN_USERNAME="admin"
# DEFAULT_ADMIN_EMAIL="admin@example.com"
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
# JWT
ALGORITHM="HS256"
REFRESH_TOKEN_EXPIRE_DAYS="7"
ACCESS_TOKEN_EXPIRE_MINUTES="15"
# SECRET_KEY="your-secret-key-change-in-production"
# Hash
ARGON2_TYPE="id"
ARGON2_TIME_COST="3"
ARGON2_MEMORY_COST="65536"
ARGON2_PARALLELISM="4"
ARGON2_SALT_LENGTH="16"
Vendored
+1
View File
@@ -1,4 +1,5 @@
.env .env
library_service/static/books/
*.log *.log
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
-1
View File
@@ -19,7 +19,6 @@ RUN uv sync --group dev --no-install-project
COPY ./library_service /code/library_service COPY ./library_service /code/library_service
COPY ./alembic.ini /code/ COPY ./alembic.ini /code/
COPY ./data.py /code/
RUN useradd app && \ RUN useradd app && \
chown -R app:app /code && \ chown -R app:app /code && \
+114 -92
View File
@@ -19,16 +19,17 @@
1. Клонируйте репозиторий: 1. Клонируйте репозиторий:
```bash ```bash
git clone https://github.com/wowlikon/libraryapi.git git clone https://github.com/wowlikon/LiB.git
``` ```
2. Перейдите в каталог проекта: 2. Перейдите в каталог проекта:
```bash ```bash
cd libraryapi cd LiB
``` ```
3. Настройте переменные окружения: 3. Настройте переменные окружения:
```bash ```bash
cp example-docker.env .env # или example-local.env для запуска без docker
edit .env edit .env
``` ```
@@ -44,22 +45,12 @@
Для создания новых миграций: Для создания новых миграций:
```bash ```bash
alembic revision --autogenerate -m "Migration name" uv run alembic revision --autogenerate -m "Migration name"
``` ```
Для запуска тестов:
```bash
docker compose up test
```
Для добавления данных для примера используйте:
```bash
python data.py
```
### **Роли пользователей** ### **Роли пользователей**
- **Админ**: Полный доступ ко всем функциям системы - **admin**: Полный доступ ко всем функциям системы
- **librarian**: Управление книгами, авторами, жанрами и выдачами - **librarian**: Управление книгами, авторами, жанрами и выдачами
- **member**: Просмотр каталога и управление своими выдачами - **member**: Просмотр каталога и управление своими выдачами
@@ -67,105 +58,133 @@
#### **Аутентификация** (`/api/auth`) #### **Аутентификация** (`/api/auth`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|-----------------------------------------------|----------------|------------------------------------------| |--------|------------------------------|----------------|------------------------------------------|
| POST | `/api/auth/register` | Публичный | Регистрация нового пользователя | | POST | `/register` | Публичный | Регистрация нового пользователя |
| POST | `/api/auth/token` | Публичный | Получение JWT токенов (access + refresh) | | POST | `/token` | Публичный | Получение JWT токенов (access + refresh) |
| POST | `/api/auth/refresh` | Публичный | Обновление пары токенов | | POST | `/refresh` | Публичный | Обновление пары токенов |
| GET | `/api/auth/me` | Авторизованный | Информация о текущем пользователе | | GET | `/me` | Авторизованный | Информация о текущем пользователе |
| PUT | `/api/auth/me` | Авторизованный | Обновление профиля текущего пользователя | | PUT | `/me` | Авторизованный | Обновление профиля текущего пользователя |
| GET | `/api/auth/users` | Сотрудник | Список всех пользователей | | GET | `/2fa` | Авторизованный | Создаёт QR-код для включения 2FA |
| POST | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю | | POST | `/2fa/verify` | Неполный вход | Завершает вход при включеной 2FA |
| DELETE | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя | | POST | `/2fa/enable` | Авторизованный | Включает двухваткорную аутентификацию |
| GET | `/api/auth/roles` | Авторизованный | Список ролей в системе | | POST | `/2fa/disable` | Авторизованный | Выключает двухваткорную аутентификацию |
| GET | `/recovery-codes/status` | Авторизованный | Проверяет состояние кодов восстановления |
| POST | `/recovery-codes/regenerate` | Авторизованный | Пересоздает коды восстановления пароля |
| POST | `/password/reset` | Публичный | Сброс пароля с помощью одноразового кода |
#### **Авторы** (`/api/authors`) #### **Авторы** (`/api/authors`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|---------------------------------| |--------|----------|-----------|---------------------------------|
| POST | `/api/authors` | Сотрудник | Создать нового автора | | POST | `/` | Сотрудник | Создать нового автора |
| GET | `/api/authors` | Публичный | Получить список всех авторов | | GET | `/` | Публичный | Получить список всех авторов |
| GET | `/api/authors/{id}` | Публичный | Получить автора по ID с книгами | | GET | `/{id}` | Публичный | Получить автора по ID с книгами |
| PUT | `/api/authors/{id}` | Сотрудник | Обновить автора по ID | | PUT | `/{id}` | Сотрудник | Обновить автора по ID |
| DELETE | `/api/authors/{id}` | Сотрудник | Удалить автора по ID | | DELETE | `/{id}` | Сотрудник | Удалить автора по ID |
#### **Книги** (`/api/books`) #### **Книги** (`/api/books`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|-----------------------------------------------------------| |--------|-----------|-----------|----------------------------------------------|
| GET | `/api/books/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам с пагинацией | | POST | `/` | Сотрудник | Создать новую книгу |
| POST | `/api/books` | Сотрудник | Создать новую книгу | | GET | `/` | Публичный | Получить список всех книг |
| GET | `/api/books` | Публичный | Получить список всех книг | | GET | `/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
| GET | `/api/books/{id}` | Публичный | Получить книгу по ID с авторами и жанрами | | PUT | `/{id}` | Сотрудник | Обновить книгу по ID |
| PUT | `/api/books/{id}` | Сотрудник | Обновить книгу по ID | | DELETE | `/{id}` | Сотрудник | Удалить книгу по ID |
| DELETE | `/api/books/{id}` | Сотрудник | Удалить книгу по ID | | GET | `/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам |
#### **Жанры** (`/api/genres`) #### **Жанры** (`/api/genres`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|--------------------|-----------|-------------------------------| |--------|----------|-----------|-------------------------------|
| POST | `/api/genres` | Сотрудник | Создать новый жанр | | POST | `/` | Сотрудник | Создать новый жанр |
| GET | `/api/genres` | Публичный | Получить список всех жанров | | GET | `/` | Публичный | Получить список всех жанров |
| GET | `/api/genres/{id}` | Публичный | Получить жанр по ID с книгами | | GET | `/{id}` | Публичный | Получить жанр по ID с книгами |
| PUT | `/api/genres/{id}` | Сотрудник | Обновить жанр по ID | | PUT | `/{id}` | Сотрудник | Обновить жанр по ID |
| DELETE | `/api/genres/{id}` | Сотрудник | Удалить жанр по ID | | DELETE | `/{id}` | Сотрудник | Удалить жанр по ID |
#### **Выдачи** (`/api/loans`) #### **Выдачи** (`/api/loans`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|------------------------------------|----------------|--------------------------------------------------------------| |--------|-------------------------|----------------|------------------------------------------------------------|
| POST | `/api/loans` | Авторизованный | Создать выдачу/бронь (читатели для себя, Сотрудник для всех) | | POST | `/` | Авторизованный | Создать выдачу/бронь (читатели на себя, cотрудник на всех) |
| GET | `/api/loans` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) | | GET | `/` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
| GET | `/api/loans/analytics` | Админ | Аналитика выдач и возвратов | | GET | `{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
| GET | `/api/loans/{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) | | PUT | `{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
| PUT | `/api/loans/{id}` | Авторизованный | Обновить выдачу (читатели только свои) | | DELETE | `{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
| POST | `/api/loans/{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) | | POST | `{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
| POST | `/api/loans/{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу | | POST | `{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
| DELETE | `/api/loans/{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) | | GET | `book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
| GET | `/api/loans/book/{book_id}/active` | Сотрудник | Получить активную выдачу книги | | POST | `issue` | Админ | Выдать книгу напрямую без бронирования |
| POST | `/api/loans/issue` | Админ | Выдать книгу напрямую без бронирования | | GET | `analytics` | Админ | Аналитика выдач и возвратов |
#### **Связи** (`/api`) #### **Связи** (`/api`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|----------------------------------|-----------|-------------------------------| |--------|------------------------------|-----------|-------------------------------|
| POST | `/api/relationships/author-book` | Сотрудник | Связать автора и книгу | | POST | `/relationships/author-book` | Сотрудник | Связать автора и книгу |
| DELETE | `/api/relationships/author-book` | Сотрудник | Удалить связь автор-книга | | DELETE | `/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
| GET | `/api/authors/{id}/books` | Публичный | Получить список книг автора | | GET | `/authors/{id}/books` | Публичный | Получить список книг автора |
| GET | `/api/books/{id}/authors` | Публичный | Получить список авторов книги | | GET | `/books/{id}/authors` | Публичный | Получить список авторов книги |
| POST | `/api/relationships/genre-book` | Сотрудник | Связать жанр и книгу | | POST | `/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
| DELETE | `/api/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга | | DELETE | `/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
| GET | `/api/genres/{id}/books` | Публичный | Получить список книг жанра | | GET | `/genres/{id}/books` | Публичный | Получить список книг жанра |
| GET | `/api/books/{id}/genres` | Публичный | Получить список жанров книги | | GET | `/books/{id}/genres` | Публичный | Получить список жанров книги |
#### **Пользователи** (`/api/users`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|--------------------------------|----------------|------------------------------|
| POST | `/` | Админ | Создать нового пользователя |
| GET | `/` | Админ | Список всех пользователей |
| GET | `/{id}` | Админ | Получить пользователя по ID |
| PUT | `/{id}` | Админ | Обновить пользователя по ID |
| DELETE | `/{id}` | Админ | Удалить пользователя по ID |
| POST | `/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю |
| DELETE | `/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя |
| GET | `/roles` | Авторизованный | Список ролей в системе |
#### **CAPTCHA** (`/api/cap`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|---------------|-----------|-----------------|
| POST | `/challenge` | Публичный | Создание задачи |
| POST | `/redeem` | Публичный | Проверка задачи |
#### **Прочее** (`/api`) #### **Прочее** (`/api`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|-------|--------------|-----------|----------------------| |-------|-----------|-----------|----------------------|
| GET | `/api/info` | Публичный | Информация о сервисе | | GET | `/info` | Публичный | Информация о сервисе |
| GET | `/api/stats` | Публичный | Статистика системы | | GET | `/stats` | Публичный | Статистика системы |
| GET | `/schema` | Публичный | Схема базы данных |
### **Веб-страницы** ### **Веб-страницы**
| Путь | Доступ | Описание | | Путь | Доступ | Описание |
|---------------------|----------------|-----------------------------------------| |---------------------|----------------|-----------------------------|
| `/` | Публичный | Главная страница | | `/` | Публичный | Главная страница |
| `/auth` | Публичный | Страница авторизации | | `/api` | Публичный | Ссылки на документацию |
| `/profile` | Авторизованный | Профиль пользователя | | `/auth` | Публичный | Страница авторизации |
| `/books` | Публичный | Каталог книг с фильтрацией | | `/profile` | Авторизованный | Профиль пользователя |
| `/book/{id}` | Публичный | Страница просмотра книги | | `/books` | Публичный | Каталог книг с фильтрацией |
| `/book/create` | Сотрудник | Создание новой книги | | `/book/{id}` | Публичный | Страница просмотра книги |
| `/book/{id}/edit` | Сотрудник | Редактирование книги | | `/book/create` | Сотрудник | Создание новой книги |
| `/authors` | Публичный | Список авторов | | `/book/{id}/edit` | Сотрудник | Редактирование книги |
| `/author/{id}` | Публичный | Страница автора | | `/authors` | Публичный | Список авторов |
| `/author/create` | Сотрудник | Создание автора | | `/author/{id}` | Публичный | Страница автора |
| `/author/{id}/edit` | Сотрудник | Редактирование автора | | `/author/create` | Сотрудник | Создание автора |
| `/genre/create` | Сотрудник | Создание жанра | | `/author/{id}/edit` | Сотрудник | Редактирование автора |
| `/genre/{id}/edit` | Сотрудник | Редактирование жанра | | `/genre/create` | Сотрудник | Создание жанра |
| `/my-books` | Авторизованный | Мои выдачи | | `/genre/{id}/edit` | Сотрудник | Редактирование жанра |
| `/users` | Сотрудник | Управление пользователями | | `/my-books` | Авторизованный | Мои выдачи |
| `/analytics` | Админ | Аналитика выдач и возвратов | | `/users` | Админ | Управление пользователями |
| `/api` | Публичный | Страница с ссылками на документацию API | | `/analytics` | Админ | Аналитика выдач и возвратов |
### **Схема базы данных** ### **Схема базы данных**
@@ -246,6 +265,8 @@ erDiagram
- **ACTIVE**: Книга доступна для выдачи - **ACTIVE**: Книга доступна для выдачи
- **RESERVED**: Книга забронирована (ожидает подтверждения) - **RESERVED**: Книга забронирована (ожидает подтверждения)
- **BORROWED**: Книга выдана пользователю - **BORROWED**: Книга выдана пользователю
- **RESTORATION**: Книга на реставрации
- **WRITTEN_OFF**: Книга списана
### **Используемые технологии** ### **Используемые технологии**
@@ -254,6 +275,7 @@ erDiagram
- **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic - **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic
- **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy - **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy
- **PostgreSQL**: Реляционная система управления базами данных - **PostgreSQL**: Реляционная система управления базами данных
- **Ollama**: Инструмент для локального запуска и управления большими языковыми моделями
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах - **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker - **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker
- **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса - **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса
-356
View File
@@ -1,356 +0,0 @@
import requests
from typing import Optional
# Конфигурация
USERNAME = "admin"
PASSWORD = "your-password-here"
BASE_URL = "http://localhost:8000"
class LibraryAPI:
def __init__(self, base_url: str):
self.base_url = base_url
self.token: Optional[str] = None
self.session = requests.Session()
def login(self, username: str, password: str) -> bool:
"""Авторизация и получение токена"""
response = self.session.post(
f"{self.base_url}/api/auth/token",
data={"username": username, "password": password},
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
if response.status_code == 200:
self.token = response.json()["access_token"]
self.session.headers.update({"Authorization": f"Bearer {self.token}"})
print(f"✓ Авторизация успешна для пользователя: {username}")
return True
else:
print(f"✗ Ошибка авторизации: {response.text}")
return False
def register(self, username: str, email: str, password: str, full_name: str = None) -> bool:
"""Регистрация нового пользователя"""
data = {
"username": username,
"email": email,
"password": password
}
if full_name:
data["full_name"] = full_name
response = self.session.post(
f"{self.base_url}/api/auth/register",
json=data
)
if response.status_code == 201:
print(f"✓ Пользователь {username} зарегистрирован")
return True
else:
print(f"✗ Ошибка регистрации: {response.text}")
return False
def create_author(self, name: str) -> Optional[int]:
"""Создание автора"""
response = self.session.post(
f"{self.base_url}/api/authors/",
json={"name": name}
)
if response.status_code == 200:
author_id = response.json()["id"]
print(f" ✓ Автор создан: {name} (ID: {author_id})")
return author_id
else:
print(f" ✗ Ошибка создания автора {name}: {response.text}")
return None
def create_book(self, title: str, description: str) -> Optional[int]:
"""Создание книги"""
response = self.session.post(
f"{self.base_url}/api/books/",
json={"title": title, "description": description}
)
if response.status_code == 200:
book_id = response.json()["id"]
print(f" ✓ Книга создана: {title} (ID: {book_id})")
return book_id
else:
print(f" ✗ Ошибка создания книги {title}: {response.text}")
return None
def create_genre(self, name: str) -> Optional[int]:
"""Создание жанра"""
response = self.session.post(
f"{self.base_url}/api/genres/",
json={"name": name}
)
if response.status_code == 200:
genre_id = response.json()["id"]
print(f" ✓ Жанр создан: {name} (ID: {genre_id})")
return genre_id
else:
print(f" ✗ Ошибка создания жанра {name}: {response.text}")
return None
def link_author_book(self, author_id: int, book_id: int) -> bool:
"""Связь автора и книги"""
response = self.session.post(
f"{self.base_url}/api/relationships/author-book",
params={"author_id": author_id, "book_id": book_id}
)
if response.status_code == 200:
print(f" ↔ Связь автор-книга: {author_id}{book_id}")
return True
else:
print(f" ✗ Ошибка связи автор-книга: {response.text}")
return False
def link_genre_book(self, genre_id: int, book_id: int) -> bool:
"""Связь жанра и книги"""
response = self.session.post(
f"{self.base_url}/api/relationships/genre-book",
params={"genre_id": genre_id, "book_id": book_id}
)
if response.status_code == 200:
print(f" ↔ Связь жанр-книга: {genre_id}{book_id}")
return True
else:
print(f" ✗ Ошибка связи жанр-книга: {response.text}")
return False
def main():
api = LibraryAPI(BASE_URL)
# Авторизация
if not api.login(USERNAME, PASSWORD):
print("Не удалось авторизоваться. Проверьте логин и пароль.")
return
print("\n📚 Создание авторов...")
authors_data = [
"Лев Толстой",
"Фёдор Достоевский",
"Антон Чехов",
"Александр Пушкин",
"Михаил Булгаков",
"Николай Гоголь",
"Иван Тургенев",
"Борис Пастернак",
"Михаил Лермонтов",
"Александр Солженицын",
"Максим Горький",
"Иван Бунин"
]
authors = {}
for name in authors_data:
author_id = api.create_author(name)
if author_id:
authors[name] = author_id
print("\n🏷️ Создание жанров...")
genres_data = [
"Роман",
"Повесть",
"Рассказ",
"Поэзия",
"Драма",
"Философская проза",
"Историческая проза",
"Сатира"
]
genres = {}
for name in genres_data:
genre_id = api.create_genre(name)
if genre_id:
genres[name] = genre_id
print("\n📖 Создание книг...")
books_data = [
{
"title": "Война и мир",
"description": "Роман-эпопея Льва Толстого, описывающий русское общество в эпоху войн против Наполеона в 1805—1812 годах. Одно из величайших произведений мировой литературы.",
"authors": ["Лев Толстой"],
"genres": ["Роман", "Историческая проза"]
},
{
"title": "Анна Каренина",
"description": "Роман Льва Толстого о трагической любви замужней дамы Анны Карениной к блестящему офицеру Вронскому. История страсти, ревности и роковых решений.",
"authors": ["Лев Толстой"],
"genres": ["Роман", "Драма"]
},
{
"title": "Преступление и наказание",
"description": "Социально-психологический роман Фёдора Достоевского о бедном студенте Раскольникове, совершившем убийство и мучающемся угрызениями совести.",
"authors": ["Фёдор Достоевский"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Братья Карамазовы",
"description": "Последний роман Достоевского, история семьи Карамазовых, затрагивающая глубокие вопросы веры, свободы воли и морали.",
"authors": ["Фёдор Достоевский"],
"genres": ["Роман", "Философская проза", "Драма"]
},
{
"title": "Идиот",
"description": "Роман о князе Мышкине — человеке с чистой душой, который сталкивается с жестокостью и корыстью петербургского общества.",
"authors": ["Фёдор Достоевский"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Вишнёвый сад",
"description": "Пьеса Антона Чехова о разорении дворянского гнезда и продаже родового имения с вишнёвым садом.",
"authors": ["Антон Чехов"],
"genres": ["Драма"]
},
{
"title": "Чайка",
"description": "Пьеса Чехова о любви, искусстве и несбывшихся мечтах, разворачивающаяся в усадьбе на берегу озера.",
"authors": ["Антон Чехов"],
"genres": ["Драма"]
},
{
"title": "Палата № 6",
"description": "Повесть о враче психиатрической больницы, который начинает сомневаться в границах между нормой и безумием.",
"authors": ["Антон Чехов"],
"genres": ["Повесть", "Философская проза"]
},
{
"title": "Евгений Онегин",
"description": "Роман в стихах Александра Пушкина — энциклопедия русской жизни начала XIX века и история несчастной любви.",
"authors": ["Александр Пушкин"],
"genres": ["Роман", "Поэзия"]
},
{
"title": "Капитанская дочка",
"description": "Исторический роман Пушкина о событиях Пугачёвского восстания, любви и чести.",
"authors": ["Александр Пушкин"],
"genres": ["Роман", "Историческая проза"]
},
{
"title": "Пиковая дама",
"description": "Повесть о молодом офицере Германне, одержимом желанием узнать тайну трёх карт.",
"authors": ["Александр Пушкин"],
"genres": ["Повесть"]
},
{
"title": "Мастер и Маргарита",
"description": "Роман Михаила Булгакова о визите дьявола в Москву 1930-х годов, переплетённый с историей Понтия Пилата.",
"authors": ["Михаил Булгаков"],
"genres": ["Роман", "Сатира", "Философская проза"]
},
{
"title": "Собачье сердце",
"description": "Повесть-сатира о профессоре Преображенском, превратившем бродячего пса в человека.",
"authors": ["Михаил Булгаков"],
"genres": ["Повесть", "Сатира"]
},
{
"title": "Белая гвардия",
"description": "Роман о семье Турбиных в Киеве во время Гражданской войны 1918-1919 годов.",
"authors": ["Михаил Булгаков"],
"genres": ["Роман", "Историческая проза"]
},
{
"title": "Мёртвые души",
"description": "Поэма Николая Гоголя о похождениях Чичикова, скупающего «мёртвые души» крепостных крестьян.",
"authors": ["Николай Гоголь"],
"genres": ["Роман", "Сатира"]
},
{
"title": "Ревизор",
"description": "Комедия о чиновниках уездного города, принявших проезжего за ревизора из Петербурга.",
"authors": ["Николай Гоголь"],
"genres": ["Драма", "Сатира"]
},
{
"title": "Шинель",
"description": "Повесть о маленьком человеке — титулярном советнике Акакии Башмачкине и его мечте о новой шинели.",
"authors": ["Николай Гоголь"],
"genres": ["Повесть"]
},
{
"title": "Отцы и дети",
"description": "Роман Ивана Тургенева о конфликте поколений и нигилизме на примере Евгения Базарова.",
"authors": ["Иван Тургенев"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Записки охотника",
"description": "Цикл рассказов Тургенева о русской деревне и крестьянах, написанный с глубоким сочувствием к народу.",
"authors": ["Иван Тургенев"],
"genres": ["Рассказ"]
},
{
"title": "Доктор Живаго",
"description": "Роман Бориса Пастернака о судьбе русского интеллигента в эпоху революции и Гражданской войны.",
"authors": ["Борис Пастернак"],
"genres": ["Роман", "Историческая проза", "Поэзия"]
},
{
"title": "Герой нашего времени",
"description": "Роман Михаила Лермонтова о Печорине — «лишнем человеке», скучающем и разочарованном в жизни.",
"authors": ["Михаил Лермонтов"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Архипелаг ГУЛАГ",
"description": "Документально-художественное исследование Александра Солженицына о системе советских лагерей.",
"authors": ["Александр Солженицын"],
"genres": ["Историческая проза"]
},
{
"title": "Один день Ивана Денисовича",
"description": "Повесть о одном дне заключённого советского лагеря, положившая начало лагерной прозе.",
"authors": ["Александр Солженицын"],
"genres": ["Повесть", "Историческая проза"]
},
{
"title": "На дне",
"description": "Пьеса Максима Горького о жителях ночлежки для бездомных — людях, оказавшихся на дне жизни.",
"authors": ["Максим Горький"],
"genres": ["Драма", "Философская проза"]
},
{
"title": "Тёмные аллеи",
"description": "Сборник рассказов Ивана Бунина о любви — трагической, мимолётной и прекрасной.",
"authors": ["Иван Бунин"],
"genres": ["Рассказ"]
}
]
books = {}
for book in books_data:
book_id = api.create_book(book["title"], book["description"])
if book_id:
books[book["title"]] = {
"id": book_id,
"authors": book["authors"],
"genres": book["genres"]
}
print("\n🔗 Создание связей...")
for book_title, book_info in books.items():
book_id = book_info["id"]
for author_name in book_info["authors"]:
if author_name in authors:
api.link_author_book(authors[author_name], book_id)
for genre_name in book_info["genres"]:
if genre_name in genres:
api.link_genre_book(genres[genre_name], book_id)
print("\n" + "=" * 50)
print("📊 ИТОГИ:")
print(f" • Авторов создано: {len(authors)}")
print(f" • Жанров создано: {len(genres)}")
print(f" • Книг создано: {len(books)}")
print("=" * 50)
if __name__ == "__main__":
main()
+61 -23
View File
@@ -1,6 +1,6 @@
services: services:
db: db:
image: postgres:17 image: pgvector/pgvector:pg17
container_name: db container_name: db
restart: unless-stopped restart: unless-stopped
logging: logging:
@@ -11,26 +11,81 @@ services:
- ./data/db:/var/lib/postgresql/data - ./data/db:/var/lib/postgresql/data
networks: networks:
- proxy - proxy
ports: # !сменить внешний порт перед использованием!
- 5432:5432
env_file: env_file:
- ./.env - ./.env
command:
- "postgres"
- "-c"
- "wal_level=logical"
- "-c"
- "max_replication_slots=10"
- "-c"
- "max_wal_senders=10"
- "-c"
- "listen_addresses=*"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
replication-setup:
image: postgres:17-alpine
container_name: replication-setup
restart: "no"
networks:
- proxy
env_file:
- ./.env
volumes:
- ./setup-replication.sh:/setup-replication.sh
entrypoint: ["/bin/sh", "/setup-replication.sh"]
depends_on:
api:
condition: service_started
db:
condition: service_healthy
llm:
image: ollama/ollama:latest
container_name: llm
restart: unless-stopped
logging:
options:
max-size: "10m"
max-file: "3"
volumes:
- ./data/llm:/root/.ollama
networks:
- proxy
ports: # !только локальный тест!
- 11434:11434
env_file:
- ./.env
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 5g
api: api:
build: . build: .
container_name: api container_name: api
restart: unless-stopped restart: unless-stopped
command: bash -c "uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips=*" command: python library_service/main.py
logging: logging:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
networks: networks:
- proxy - proxy
ports: ports: # !только локальный тест!
- 8000:8000 - 8000:8000
env_file: env_file:
- ./.env - ./.env
@@ -39,25 +94,8 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
llm:
tests: condition: service_healthy
container_name: tests
build: .
command: bash -c "pytest tests"
restart: no
logging:
options:
max-size: "10m"
max-file: "3"
networks:
- proxy
env_file:
- ./.env
volumes:
- .:/code
depends_on:
db:
condition: service_healthy
networks: networks:
proxy: # Рекомендуется использовать через реверс-прокси proxy: # Рекомендуется использовать через реверс-прокси
+47
View File
@@ -0,0 +1,47 @@
# Postgres
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=lib
REMOTE_HOST=
REMOTE_PORT=
NODE_ID=
# Ollama
OLLAMA_URL="http://llm:11434"
OLLAMA_MAX_LOADED_MODELS=1
OLLAMA_NUM_THREADS=4
OLLAMA_KEEP_ALIVE=5m
# Default admin account
DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_EMAIL="admin@example.com"
DEFAULT_ADMIN_PASSWORD="Password12345"
SECRET_KEY="your-secret-key-change-in-production"
DOMAIN="mydomain.com"
# JWT
ALGORITHM=HS256
REFRESH_TOKEN_EXPIRE_DAYS=7
ACCESS_TOKEN_EXPIRE_MINUTES=15
PARTIAL_TOKEN_EXPIRE_MINUTES=5
# Hash
ARGON2_TYPE=id
ARGON2_TIME_COST=3
ARGON2_MEMORY_COST=65536
ARGON2_PARALLELISM=4
ARGON2_SALT_LENGTH=16
ARGON2_HASH_LENGTH=48
# Recovery codes
RECOVERY_CODES_COUNT=10
RECOVERY_CODE_SEGMENTS=4
RECOVERY_CODE_SEGMENT_BYTES=2
RECOVERY_MIN_REMAINING_WARNING=3
RECOVERY_MAX_AGE_DAYS=365
# TOTP_2FA
TOTP_ISSUER=LiB
TOTP_VALID_WINDOW=1
+44
View File
@@ -0,0 +1,44 @@
# Postgres
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=lib
# Ollama
OLLAMA_URL="http://localhost:11434"
OLLAMA_MAX_LOADED_MODELS=1
OLLAMA_NUM_THREADS=4
OLLAMA_KEEP_ALIVE=5m
# Default admin account
DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_EMAIL="admin@example.com"
DEFAULT_ADMIN_PASSWORD="Password12345"
SECRET_KEY="your-secret-key-change-in-production"
DOMAIN="mydomain.com"
# JWT
ALGORITHM=HS256
REFRESH_TOKEN_EXPIRE_DAYS=7
ACCESS_TOKEN_EXPIRE_MINUTES=15
PARTIAL_TOKEN_EXPIRE_MINUTES=5
# Hash
ARGON2_TYPE=id
ARGON2_TIME_COST=3
ARGON2_MEMORY_COST=65536
ARGON2_PARALLELISM=4
ARGON2_SALT_LENGTH=16
ARGON2_HASH_LENGTH=48
# Recovery codes
RECOVERY_CODES_COUNT=10
RECOVERY_CODE_SEGMENTS=4
RECOVERY_CODE_SEGMENT_BYTES=2
RECOVERY_MIN_REMAINING_WARNING=3
RECOVERY_MAX_AGE_DAYS=365
# TOTP_2FA
TOTP_ISSUER=LiB
TOTP_VALID_WINDOW=1
+4
View File
@@ -0,0 +1,4 @@
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SELECT pg_reload_conf();
+117
View File
@@ -0,0 +1,117 @@
"""Пакет авторизации и аутентификации"""
from .core import (
SECRET_KEY,
ALGORITHM,
PARTIAL_TOKEN_EXPIRE_MINUTES,
ACCESS_TOKEN_EXPIRE_MINUTES,
REFRESH_TOKEN_EXPIRE_DAYS,
ARGON2_TIME_COST,
ARGON2_MEMORY_COST,
ARGON2_PARALLELISM,
ARGON2_SALT_LENGTH,
ARGON2_HASH_LENGTH,
RECOVERY_CODES_COUNT,
RECOVERY_CODE_SEGMENTS,
RECOVERY_CODE_SEGMENT_BYTES,
RECOVERY_MIN_REMAINING_WARNING,
RECOVERY_MAX_AGE_DAYS,
KeyDeriver,
deriver,
AES256Cipher,
cipher,
verify_password,
get_password_hash,
create_access_token,
create_refresh_token,
create_partial_token,
decode_token,
authenticate_user,
get_current_user,
get_current_active_user,
get_user_from_partial_token,
require_role,
require_any_role,
is_user_staff,
is_user_admin,
RequireAuth,
RequireAdmin,
RequireMember,
RequireLibrarian,
RequirePartialAuth,
RequireStaff,
)
from .seed import (
seed_roles,
seed_admin,
run_seeds,
)
from .recovery import (
generate_codes_for_user,
verify_and_use_code,
get_codes_status,
)
from .totp import (
generate_secret,
get_provisioning_uri,
verify_totp_code,
qr_to_bitmap_b64,
generate_totp_setup,
TOTP_ISSUER,
TOTP_VALID_WINDOW,
)
__all__ = [
"SECRET_KEY",
"ALGORITHM",
"ACCESS_TOKEN_EXPIRE_MINUTES",
"REFRESH_TOKEN_EXPIRE_DAYS",
"ARGON2_TIME_COST",
"ARGON2_MEMORY_COST",
"ARGON2_PARALLELISM",
"ARGON2_SALT_LENGTH",
"ARGON2_HASH_LENGTH",
"RECOVERY_CODES_COUNT",
"RECOVERY_CODE_SEGMENTS",
"RECOVERY_CODE_SEGMENT_BYTES",
"RECOVERY_MIN_REMAINING_WARNING",
"RECOVERY_MAX_AGE_DAYS",
"KeyDeriver",
"deriver",
"AES256Cipher",
"cipher",
"verify_password",
"get_password_hash",
"create_access_token",
"create_refresh_token",
"decode_token",
"authenticate_user",
"get_current_user",
"get_current_active_user",
"require_role",
"require_any_role",
"is_user_staff",
"is_user_admin",
"RequireAuth",
"RequireAdmin",
"RequireMember",
"RequireLibrarian",
"RequireStaff",
"seed_roles",
"seed_admin",
"run_seeds",
"generate_secre",
"get_provisioning_uri",
"verify_totp_code",
"qr_to_bitmap_b64",
"generate_totp_setup," "generate_codes_for_user",
"verify_and_use_code",
"get_codes_status",
"CODES_COUNT",
"MIN_REMAINING_WARNING",
"TOTP_ISSUER",
"TOTP_VALID_WINDOW",
]
@@ -1,47 +1,115 @@
"""Модуль авторизации и аутентификации""" """Модуль основного функционала авторизации и аутентификации"""
import os
import base64
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Annotated from typing import Annotated
from uuid import uuid4 from uuid import uuid4
import hashlib
import os
from argon2.low_level import hash_secret_raw, Type
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError, ExpiredSignatureError from jose import jwt, JWTError, ExpiredSignatureError
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlmodel import Session, select from sqlmodel import Session, select
import pyotp
import qrcode
from library_service.models.db import Role, User from library_service.models.db import User
from library_service.models.dto import TokenData from library_service.models.dto import TokenData
from library_service.settings import get_session, get_logger from library_service.settings import get_session, get_logger
# Конфигурация JWT из переменных окружения # Конфигурация JWT из переменных окружения
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = os.getenv("ALGORITHM", "HS256") ALGORITHM = os.getenv("ALGORITHM", "HS256")
PARTIAL_TOKEN_EXPIRE_MINUTES = int(os.getenv("PARTIAL_TOKEN_EXPIRE_MINUTES", "5"))
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15")) ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")) REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# Конфигурация хэширования паролей из переменных окружения # Конфигурация хэширования из переменных окружения
ARGON2_TYPE = os.getenv("ARGON2_TYPE", "id") ARGON2_TYPE = os.getenv("ARGON2_TYPE", "id")
ARGON2_TIME_COST = int(os.getenv("ARGON2_TIME_COST", "3")) ARGON2_TIME_COST = int(os.getenv("ARGON2_TIME_COST", "3"))
ARGON2_MEMORY_COST = int(os.getenv("ARGON2_MEMORY_COST", "65536")) ARGON2_MEMORY_COST = int(os.getenv("ARGON2_MEMORY_COST", "131072"))
ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "4")) ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "2"))
ARGON2_SALT_LENGTH = int(os.getenv("ARGON2_SALT_LENGTH", "16")) ARGON2_SALT_LENGTH = int(os.getenv("ARGON2_SALT_LENGTH", "16"))
ARGON2_HASH_LENGTH = int(os.getenv("ARGON2_HASH_LENGTH", "48"))
# Получение логгера # Конфигурация кодов восстановления
RECOVERY_CODES_COUNT = int(os.getenv("RECOVERY_CODES_COUNT", "10"))
RECOVERY_CODE_SEGMENTS = int(os.getenv("RECOVERY_CODE_SEGMENTS", "4"))
RECOVERY_CODE_SEGMENT_BYTES = int(os.getenv("RECOVERY_CODE_SEGMENT_BYTES", "2"))
RECOVERY_MIN_REMAINING_WARNING = int(os.getenv("RECOVERY_MIN_REMAINING_WARNING", "3"))
RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365"))
SECRET_KEY = os.getenv("SECRET_KEY")
# Получение логгера
logger = get_logger() logger = get_logger()
# OAuth2 схема # OAuth2 схема
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
class KeyDeriver:
def __init__(self, master_key: bytes):
self.master_key = master_key
def derive(
self,
context: str,
key_len: int = 32,
time_cost: int = 12,
memory_cost: int = 512 * 1024,
parallelism: int = 4,
) -> bytes:
"""
Формирование разных ключей из одного.
context: любая строка, например "aes", "hmac", "totp"
"""
salt = hashlib.sha256(context.encode("utf-8")).digest()
key = hash_secret_raw(
secret=self.master_key,
salt=salt,
time_cost=time_cost,
memory_cost=memory_cost,
parallelism=parallelism,
hash_len=key_len,
type=Type.ID,
)
return key
class AES256Cipher:
def __init__(self, key: bytes):
if len(key) != 32:
raise ValueError("AES-256 требует ключ длиной 32 байта")
self.key = key
self.aesgcm = AESGCM(key)
def encrypt(self, plaintext: bytes, nonce_len: int = 12) -> bytes:
"""Зашифровывает данные с помощью AES256-GCM"""
nonce = os.urandom(nonce_len)
ct = self.aesgcm.encrypt(nonce, plaintext, associated_data=None)
return nonce + ct
def decrypt(self, data: bytes, nonce_len: int = 12) -> bytes:
"""Расшифровывает данные с помощью AES256-GCM"""
nonce = data[:nonce_len]
ct = data[nonce_len:]
return self.aesgcm.decrypt(nonce, ct, associated_data=None)
# Проверка секретного ключа # Проверка секретного ключа
if not SECRET_KEY: if not SECRET_KEY:
raise RuntimeError("SECRET_KEY environment variable is required") raise RuntimeError("SECRET_KEY environment variable is required")
deriver = KeyDeriver(SECRET_KEY.encode())
jwt_key = deriver.derive("jwt", key_len=32)
aes_key = deriver.derive("totp", key_len=32)
cipher = AES256Cipher(aes_key)
# Хэширование паролей # Хэширование паролей
pwd_context = CryptContext( pwd_context = CryptContext(
schemes=["argon2"], schemes=["argon2"],
@@ -51,6 +119,7 @@ pwd_context = CryptContext(
argon2__memory_cost=ARGON2_MEMORY_COST, argon2__memory_cost=ARGON2_MEMORY_COST,
argon2__parallelism=ARGON2_PARALLELISM, argon2__parallelism=ARGON2_PARALLELISM,
argon2__salt_len=ARGON2_SALT_LENGTH, argon2__salt_len=ARGON2_SALT_LENGTH,
argon2__hash_len=ARGON2_HASH_LENGTH,
) )
@@ -64,13 +133,30 @@ def get_password_hash(password: str) -> str:
return pwd_context.hash(password) return pwd_context.hash(password)
def _create_token(data: dict, expires_delta: timedelta, token_type: str) -> str: def _create_token(
data: dict,
expires_delta: timedelta,
token_type: str,
is_partial: bool = False,
) -> str:
"""Базовая функция создания токена""" """Базовая функция создания токена"""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
to_encode = {**data, "iat": now, "exp": now + expires_delta, "type": token_type} to_encode = {
**data,
"iat": now,
"exp": now + expires_delta,
"type": token_type,
"partial": is_partial,
}
if token_type == "refresh": if token_type == "refresh":
to_encode.update({"jti": str(uuid4())}) to_encode.update({"jti": str(uuid4())})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, jwt_key, algorithm=ALGORITHM)
def create_partial_token(data: dict) -> str:
"""Создает partial токен для незавершённой 2FA аутентификации"""
delta = timedelta(minutes=PARTIAL_TOKEN_EXPIRE_MINUTES)
return _create_token(data, delta, "partial", is_partial=True)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
@@ -84,24 +170,36 @@ def create_refresh_token(data: dict) -> str:
return _create_token(data, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh") return _create_token(data, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh")
def decode_token(token: str, expected_type: str = "access") -> TokenData: def decode_token(
token: str,
expected_type: str = "access",
allow_partial: bool = False,
) -> 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"},
) )
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, jwt_key, algorithms=[ALGORITHM])
username: str | None = payload.get("sub") username: str | None = payload.get("sub")
user_id: int | None = payload.get("user_id") user_id: int | None = payload.get("user_id")
token_type: str | None = payload.get("type") token_type: str | None = payload.get("type")
if token_type != expected_type: is_partial: bool = payload.get("partial", False)
if token_type == "partial":
if not allow_partial:
token_error.detail = "2FA verification required"
raise token_error
elif token_type != expected_type:
token_error.detail = f"Invalid token type. Expected {expected_type}" token_error.detail = f"Invalid token type. Expected {expected_type}"
raise token_error raise token_error
if username is None or user_id is None: if username is None or user_id is None:
token_error.detail = "Could not validate credentials" token_error.detail = "Could not validate credentials"
raise token_error raise token_error
return TokenData(username=username, user_id=user_id)
return TokenData(username=username, user_id=user_id, is_partial=is_partial)
except ExpiredSignatureError: except ExpiredSignatureError:
token_error.detail = "Token expired" token_error.detail = "Token expired"
raise token_error raise token_error
@@ -147,6 +245,29 @@ def get_current_active_user(
return current_user return current_user
def get_user_from_partial_token(
token: Annotated[str, Depends(oauth2_scheme)],
session: Session = Depends(get_session),
) -> User:
"""Возвращает пользователя из partial токена (для 2FA верификации)"""
token_data = decode_token(token, expected_type="access", allow_partial=True)
if not token_data.is_partial:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Full token provided, 2FA not required",
)
user = session.get(User, token_data.user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_role(role_name: str): def require_role(role_name: str):
"""Создает dependency для проверки наличия определенной роли""" """Создает dependency для проверки наличия определенной роли"""
@@ -182,6 +303,7 @@ 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"))]
RequirePartialAuth = Annotated[User, Depends(get_user_from_partial_token)]
RequireStaff = Annotated[User, Depends(require_any_role(["admin", "librarian"]))] RequireStaff = Annotated[User, Depends(require_any_role(["admin", "librarian"]))]
@@ -195,121 +317,3 @@ def is_user_admin(user: User) -> bool:
"""Проверяет, является ли пользователь администратором""" """Проверяет, является ли пользователь администратором"""
roles = {role.name for role in user.roles} roles = {role.name for role in user.roles}
return "admin" in roles return "admin" in roles
def seed_roles(session: Session) -> dict[str, Role]:
"""Создает роли по умолчанию, если их нет"""
default_roles = [
{"name": "admin", "description": "Администратор системы", "payroll": 80000},
{"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
{"name": "member", "description": "Посетитель библиотеки", "payroll": 0},
]
roles = {}
for role_data in default_roles:
existing = session.exec(
select(Role).where(Role.name == role_data["name"])
).first()
if existing:
roles[role_data["name"]] = existing
else:
role = Role(**role_data)
session.add(role)
session.commit()
session.refresh(role)
roles[role_data["name"]] = role
logger.info(f"[+] Created role: {role_data['name']}")
return roles
def seed_admin(session: Session, admin_role: Role) -> User | None:
"""Создает администратора по умолчанию, если нет ни одного"""
existing_admins = session.exec(
select(User).join(User.roles).where(Role.name == "admin")
).all()
if existing_admins:
logger.info(
f"[=] Admin already exists: {existing_admins[0].username}, skipping creation"
)
return None
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
generated = False
if not admin_password:
import secrets
admin_password = secrets.token_urlsafe(16)
generated = True
admin_user = User(
username=admin_username,
email=admin_email,
full_name="Системный администратор",
hashed_password=get_password_hash(admin_password),
is_active=True,
is_verified=True,
)
admin_user.roles.append(admin_role)
session.add(admin_user)
session.commit()
session.refresh(admin_user)
logger.info(f"[+] Created admin user: {admin_username}")
if generated:
logger.warning("=" * 52)
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
logger.warning("[!] Save this password! It won't be shown again!")
logger.warning("=" * 52)
return admin_user
def run_seeds(session: Session) -> None:
"""Запускает создание ролей и администратора"""
roles = seed_roles(session)
seed_admin(session, roles["admin"])
def qr_to_bitmap_b64(data: str) -> dict:
"""
Конвертирует данные в QR-код и возвращает как base64 bitmap.
0 = чёрный, 1 = белый
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=0,
)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
size = len(matrix)
bits = []
for row in matrix:
for cell in row:
bits.append(0 if cell else 1)
padding = (8 - len(bits) % 8) % 8
bits.extend([0] * padding)
bytes_array = bytearray()
for i in range(0, len(bits), 8):
byte = 0
for j in range(8):
byte = (byte << 1) | bits[i + j]
bytes_array.append(byte)
b64 = base64.b64encode(bytes_array).decode("ascii")
return {"size": size, "padding": padding, "bitmap_b64": b64}
+148
View File
@@ -0,0 +1,148 @@
"""Модуль резервных кодов восстановления пароля"""
import secrets
from datetime import datetime, timezone, timedelta
import argon2
from sqlmodel import Session
from .core import (
ARGON2_TIME_COST,
ARGON2_MEMORY_COST,
ARGON2_PARALLELISM,
ARGON2_SALT_LENGTH,
ARGON2_HASH_LENGTH,
RECOVERY_CODES_COUNT,
RECOVERY_CODE_SEGMENTS,
RECOVERY_CODE_SEGMENT_BYTES,
RECOVERY_MIN_REMAINING_WARNING,
RECOVERY_MAX_AGE_DAYS,
)
from library_service.settings import get_logger
logger = get_logger()
# Argon2 для кодов
_recovery_hasher = argon2.PasswordHasher(
type=argon2.Type.ID,
time_cost=ARGON2_TIME_COST,
hash_len=ARGON2_HASH_LENGTH,
salt_len=ARGON2_SALT_LENGTH,
memory_cost=ARGON2_MEMORY_COST,
parallelism=ARGON2_PARALLELISM,
)
def generate_code() -> str:
"""Генерация кода в формате xxxx-xxxx-xxxx-xxxx"""
segments = [
secrets.token_hex(RECOVERY_CODE_SEGMENT_BYTES)
for _ in range(RECOVERY_CODE_SEGMENTS)
]
return "-".join(segments)
def normalize_code(code: str) -> str:
"""Нормализация: убираем дефисы, lowercase"""
return code.replace("-", "").lower().strip()
def hash_code(code: str) -> str:
"""Хеширование кода"""
return _recovery_hasher.hash(normalize_code(code))
def verify_code(plain_code: str, hashed: str) -> bool:
"""Проверка кода"""
if not hashed:
return False
try:
_recovery_hasher.verify(hashed, normalize_code(plain_code))
return True
except argon2.exceptions.VerifyMismatchError:
return False
except argon2.exceptions.InvalidHashError:
logger.warning("Invalid recovery code hash format")
return False
def generate_codes_for_user(session: Session, user) -> list[str]:
"""Генерация новых резервных кодов для пользователя."""
plain_codes: list[str] = []
hashed_codes: list[str] = []
for _ in range(RECOVERY_CODES_COUNT):
code = generate_code()
plain_codes.append(code)
hashed_codes.append(hash_code(code))
user.recovery_code_hashes = " ".join(hashed_codes)
user.recovery_codes_generated_at = datetime.now(timezone.utc)
session.add(user)
session.commit()
session.refresh(user)
logger.info(f"Generated {RECOVERY_CODES_COUNT} recovery codes for user {user.id}")
return plain_codes
def verify_and_use_code(session: Session, user, code: str) -> bool:
"""Проверка и использование кода. При успехе хеш заменяется на пустую строку"""
if not user.recovery_code_hashes:
return False
hashes = user.recovery_code_hashes.split(" ")
for i, stored_hash in enumerate(hashes):
if stored_hash and verify_code(code, stored_hash):
hashes[i] = ""
user.recovery_code_hashes = " ".join(hashes)
session.add(user)
session.commit()
logger.info(
f"Recovery code #{i + 1} used for user {user.id}, "
f"remaining: {sum(1 for h in hashes if h)}"
)
return True
logger.warning(f"Invalid recovery code attempt for user {user.id}")
return False
def get_codes_status(user) -> dict:
"""Статус резервных кодов"""
if not user.recovery_code_hashes:
return {
"total": 0,
"remaining": 0,
"used_codes": [],
"generated_at": None,
"should_regenerate": True,
}
hashes = user.recovery_code_hashes.split(" ")
used_codes = [h == "" for h in hashes]
remaining = sum(1 for h in hashes if h)
total = len(hashes)
generated_at = user.recovery_codes_generated_at
should_regenerate = remaining <= RECOVERY_MIN_REMAINING_WARNING
if generated_at:
generated_at = generated_at.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - generated_at
if age > timedelta(days=RECOVERY_MAX_AGE_DAYS):
should_regenerate = True
return {
"total": total,
"remaining": remaining,
"used_codes": used_codes,
"generated_at": generated_at,
"should_regenerate": should_regenerate,
}
+95
View File
@@ -0,0 +1,95 @@
"""Модуль создания начальных ролей и администратора"""
import os
from sqlmodel import Session, select
from library_service.models.db import Role, User
from .core import get_password_hash
from library_service.settings import get_logger
# Получение логгера
logger = get_logger()
def seed_roles(session: Session) -> dict[str, Role]:
"""Создает роли по умолчанию, если их нет"""
default_roles = [
{"name": "admin", "description": "Администратор системы", "payroll": 80000},
{"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
{"name": "member", "description": "Посетитель библиотеки", "payroll": 0},
]
roles = {}
for role_data in default_roles:
existing = session.exec(
select(Role).where(Role.name == role_data["name"])
).first()
if existing:
roles[role_data["name"]] = existing
else:
role = Role(**role_data)
session.add(role)
session.commit()
session.refresh(role)
roles[role_data["name"]] = role
logger.info(f"[+] Created role: {role_data['name']}")
return roles
def seed_admin(session: Session, admin_role: Role) -> User | None:
"""Создает администратора по умолчанию, если нет ни одного"""
existing_admins = session.exec(
select(User)
.join(User.roles) # ty: ignore[invalid-argument-type]
.where(Role.name == "admin")
).all()
if existing_admins:
logger.info(
f"[=] Admin already exists: {existing_admins[0].username}, skipping creation"
)
return None
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
generated = False
if not admin_password:
import secrets
admin_password = secrets.token_urlsafe(16)
generated = True
admin_user = User(
username=admin_username,
email=admin_email,
full_name="Системный администратор",
hashed_password=get_password_hash(admin_password),
is_active=True,
is_verified=True,
)
admin_user.roles.append(admin_role)
session.add(admin_user)
session.commit()
session.refresh(admin_user)
logger.info(f"[+] Created admin user: {admin_username}")
if generated:
logger.warning("=" * 52)
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
logger.warning("[!] Save this password! It won't be shown again!")
logger.warning("=" * 52)
return admin_user
def run_seeds(session: Session) -> None:
"""Запускает создание ролей и администратора"""
roles = seed_roles(session)
seed_admin(session, roles["admin"])
+78
View File
@@ -0,0 +1,78 @@
"""Модуль TOTP 2FA"""
import base64
import os
import pyotp
import qrcode
# Настройкт из переменных окружения
TOTP_ISSUER = os.getenv("TOTP_ISSUER", "LiB")
TOTP_VALID_WINDOW = int(os.getenv("TOTP_VALID_WINDOW", "1"))
def generate_secret() -> str:
"""Генерация нового TOTP секрета"""
return pyotp.random_base32()
def get_provisioning_uri(secret: str, username: str) -> str:
"""Получение URI для QR-кода"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name=TOTP_ISSUER)
def verify_totp_code(secret: str, code: str) -> bool:
"""Проверка TOTP кода"""
if not secret or not code:
return False
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=TOTP_VALID_WINDOW)
def qr_to_bitmap_b64(data: str) -> dict:
"""Конвертирует данные в QR-код и возвращает как base64 bitmap"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=0,
)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
size = len(matrix)
bits = []
for row in matrix:
for cell in row:
bits.append(0 if cell else 1)
padding = (8 - len(bits) % 8) % 8
bits.extend([0] * padding)
bytes_array = bytearray()
for i in range(0, len(bits), 8):
byte = 0
for j in range(8):
byte = (byte << 1) | bits[i + j]
bytes_array.append(byte)
b64 = base64.b64encode(bytes_array).decode("ascii")
return {"size": size, "padding": padding, "bitmap_b64": b64}
def generate_totp_setup(username: str) -> dict:
"""Генерация данных для настройки TOTP"""
secret = generate_secret()
uri = get_provisioning_uri(secret, username)
bitmap_data = qr_to_bitmap_b64(uri)
return {
"secret": secret,
"username": username,
"issuer": TOTP_ISSUER,
**bitmap_data,
}
+58 -3
View File
@@ -1,4 +1,6 @@
"""Основной модуль""" """Основной модуль"""
import asyncio, sys, traceback
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -7,19 +9,25 @@ from uuid import uuid4
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from fastapi import Request, Response from fastapi import FastAPI, Depends, Request, Response, status, HTTPException
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
from ollama import Client, ResponseError
from sqlmodel import Session from sqlmodel import Session
from library_service.auth import run_seeds from library_service.auth import run_seeds
from library_service.routers import api_router from library_service.routers import api_router
from library_service.routers.misc import unknown
from library_service.services.captcha import limiter, cleanup_task, require_captcha
from library_service.settings import ( from library_service.settings import (
LOGGING_CONFIG, LOGGING_CONFIG,
engine, engine,
get_app, get_app,
get_logger, get_logger,
OLLAMA_URL,
) )
SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"}) SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"})
@@ -47,6 +55,14 @@ async def lifespan(_):
except Exception as e: except Exception as e:
logger.error(f"[-] Seeding failed: {e}") logger.error(f"[-] Seeding failed: {e}")
logger.info("[+] Loading ollama models...")
ollama_client = Client(host=OLLAMA_URL)
try:
ollama_client.pull("mxbai-embed-large")
ollama_client.pull("llama3.2")
except ResponseError as e:
logger.error(f"[-] Failed to pull models {e}")
asyncio.create_task(cleanup_task())
logger.info("[+] Starting application...") logger.info("[+] Starting application...")
yield # Обработка запросов yield # Обработка запросов
logger.info("[+] Application shutdown") logger.info("[+] Application shutdown")
@@ -55,7 +71,40 @@ async def lifespan(_):
app = get_app(lifespan) app = get_app(lifespan)
# Улучшеное логгирование @app.exception_handler(status.HTTP_404_NOT_FOUND)
async def custom_not_found_handler(request: Request, exc: HTTPException):
path = request.url.path
if path.startswith("/api"):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": "API endpoint not found", "path": path},
)
return await unknown(request, app)
@app.middleware("http")
async def catch_exceptions_middleware(request: Request, call_next):
"""Middleware для подробного json-описания Internal error"""
try:
return await call_next(request)
except Exception as exc:
exc_type, exc_value, exc_tb = sys.exc_info()
logger = get_logger()
logger.exception(exc)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"message": str(exc),
"type": exc_type.__name__ if exc_type else "Unknown",
"path": str(request.url),
"method": request.method,
},
)
@app.middleware("http") @app.middleware("http")
async def log_requests(request: Request, call_next): async def log_requests(request: Request, call_next):
"""Middleware для логирования HTTP-запросов""" """Middleware для логирования HTTP-запросов"""
@@ -113,7 +162,10 @@ async def log_requests(request: Request, call_next):
}, },
exc_info=True, exc_info=True,
) )
return Response(status_code=500, content="Internal Server Error") return Response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content="Internal Server Error",
)
# Подключение маршрутов # Подключение маршрутов
@@ -127,10 +179,13 @@ app.mount(
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run( uvicorn.run(
"library_service.main:app", "library_service.main:app",
host="0.0.0.0", host="0.0.0.0",
port=8000, port=8000,
proxy_headers=True,
forwarded_allow_ips="*",
log_config=LOGGING_CONFIG, log_config=LOGGING_CONFIG,
access_log=False, access_log=False,
) )
+5 -1
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей авторов""" """Модуль DB-моделей авторов"""
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
class Author(AuthorBase, table=True): class Author(AuthorBase, table=True):
"""Модель автора в базе данных""" """Модель автора в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
books: List["Book"] = Relationship( books: List["Book"] = Relationship(
back_populates="authors", link_model=AuthorBookLink back_populates="authors", link_model=AuthorBookLink
) )
+15 -4
View File
@@ -1,6 +1,9 @@
"""Модуль DB-моделей книг""" """Модуль DB-моделей книг"""
from typing import TYPE_CHECKING, List
from typing import TYPE_CHECKING, List
from uuid import UUID
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, String from sqlalchemy import Column, String
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
@@ -15,15 +18,23 @@ if TYPE_CHECKING:
class Book(BookBase, table=True): class Book(BookBase, table=True):
"""Модель книги в базе данных""" """Модель книги в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
status: BookStatus = Field( status: BookStatus = Field(
default=BookStatus.ACTIVE, default=BookStatus.ACTIVE,
sa_column=Column(String, nullable=False, default="active") sa_column=Column(String, nullable=False, default="active"),
description="Статус",
) )
embedding: list[float] | None = Field(sa_column=Column(Vector(1024)), description="Эмбэдинг для векторного поиска")
preview_id: UUID | None = Field(default=None, unique=True, index=True, description="UUID файла изображения")
authors: List["Author"] = Relationship( authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink back_populates="books", link_model=AuthorBookLink
) )
genres: List["Genre"] = Relationship( genres: List["Genre"] = Relationship(
back_populates="books", link_model=GenreBookLink back_populates="books", link_model=GenreBookLink
) )
loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"}) loans: List["BookUserLink"] = Relationship( # ty: ignore[unresolved-reference]
sa_relationship_kwargs={"cascade": "all, delete"}
)
+5 -1
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей жанров""" """Модуль DB-моделей жанров"""
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
class Genre(GenreBase, table=True): class Genre(GenreBase, table=True):
"""Модель жанра в базе данных""" """Модель жанра в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
books: List["Book"] = Relationship( books: List["Book"] = Relationship(
back_populates="genres", link_model=GenreBookLink back_populates="genres", link_model=GenreBookLink
) )
+52 -13
View File
@@ -1,28 +1,57 @@
"""Модуль связей между сущностями в БД""" """Модуль связей между сущностями в БД"""
from datetime import datetime
from datetime import datetime, timezone
from sqlmodel import SQLModel, Field from sqlmodel import SQLModel, Field
class AuthorBookLink(SQLModel, table=True): class AuthorBookLink(SQLModel, table=True):
"""Модель связи автора и книги""" """Модель связи автора и книги"""
author_id: int | None = Field( author_id: int | None = Field(
default=None, foreign_key="author.id", primary_key=True default=None, foreign_key="author.id", primary_key=True
) )
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True) book_id: int | None = Field(
default=None,
foreign_key="book.id",
primary_key=True,
description="Идентификатор книги",
)
class GenreBookLink(SQLModel, table=True): class GenreBookLink(SQLModel, table=True):
"""Модель связи жанра и книги""" """Модель связи жанра и книги"""
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True) genre_id: int | None = Field(
default=None,
foreign_key="genre.id",
primary_key=True,
description="Идентификатор жанра",
)
book_id: int | None = Field(
default=None,
foreign_key="book.id",
primary_key=True,
description="Идентификатор книги",
)
class UserRoleLink(SQLModel, table=True): class UserRoleLink(SQLModel, table=True):
"""Модель связи роли и пользователя""" """Модель связи роли и пользователя"""
__tablename__ = "user_roles" __tablename__ = "user_roles"
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True) user_id: int | None = Field(
role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True) default=None,
foreign_key="users.id",
primary_key=True,
description="Идентификатор пользователя",
)
role_id: int | None = Field(
default=None,
foreign_key="roles.id",
primary_key=True,
description="Идентификатор роли",
)
class BookUserLink(SQLModel, table=True): class BookUserLink(SQLModel, table=True):
@@ -30,13 +59,23 @@ class BookUserLink(SQLModel, table=True):
Модель истории выдачи книг (Loan). Модель истории выдачи книг (Loan).
Связывает книгу и пользователя с фиксацией времени. Связывает книгу и пользователя с фиксацией времени.
""" """
__tablename__ = "book_loans"
id: int | None = Field(default=None, primary_key=True, index=True) __tablename__ = "loans"
book_id: int = Field(foreign_key="book.id") id: int | None = Field(
user_id: int = Field(foreign_key="users.id") default=None, primary_key=True, index=True, description="Идентификатор"
)
borrowed_at: datetime = Field(default_factory=datetime.utcnow) book_id: int = Field(foreign_key="book.id", description="Идентификатор")
due_date: datetime user_id: int = Field(
returned_at: datetime | None = Field(default=None) foreign_key="users.id", description="Идентификатор пользователя"
)
borrowed_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="Дата и время выдачи",
)
due_date: datetime = Field(description="Дата и время запланированного возврата")
returned_at: datetime | None = Field(
default=None, description="Дата и время фактического возврата"
)
+5 -1
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей ролей""" """Модуль DB-моделей ролей"""
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
@@ -12,8 +13,11 @@ if TYPE_CHECKING:
class Role(RoleBase, table=True): class Role(RoleBase, table=True):
"""Модель роли в базе данных""" """Модель роли в базе данных"""
__tablename__ = "roles" __tablename__ = "roles"
id: int | None = Field(default=None, primary_key=True, index=True) id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink) users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
+66 -9
View File
@@ -1,5 +1,6 @@
"""Модуль DB-моделей пользователей""" """Модуль DB-моделей пользователей"""
from datetime import datetime
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
@@ -13,17 +14,73 @@ if TYPE_CHECKING:
class User(UserBase, table=True): class User(UserBase, table=True):
"""Модель пользователя в базе данных""" """Модель пользователя в базе данных"""
__tablename__ = "users" __tablename__ = "users"
id: int | None = Field(default=None, primary_key=True, index=True) id: int | None = Field(
hashed_password: str = Field(nullable=False) default=None, primary_key=True, index=True, description="Идентификатор"
is_active: bool = Field(default=True) )
is_verified: bool = Field(default=False) hashed_password: str = Field(nullable=False, description="Argon2id хэш пароля")
created_at: datetime = Field(default_factory=datetime.utcnow) is_2fa_enabled: bool = Field(default=False, description="Включен TOTP 2FA")
totp_secret: str | None = Field(
default=None, max_length=80, description="Зашифрованный секрет TOTP"
)
recovery_code_hashes: str | None = Field(
default=None,
max_length=1500,
description="Argon2id хэши одноразовыхкодов восстановления",
)
recovery_codes_generated_at: datetime | None = Field(
default=None, description="Дата и время создания кодов восстановления"
)
is_active: bool = Field(default=True, description="Не является ли заблокированым")
is_verified: bool = Field(default=False, description="Является ли верифицированным")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="Дата и время создания",
)
updated_at: datetime | None = Field( updated_at: datetime | None = Field(
default=None, sa_column_kwargs={"onupdate": datetime.utcnow} default=None,
sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)},
description="Дата и время последнего обновления",
) )
# Связи
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink) roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"}) loans: List["BookUserLink"] = Relationship( # ty: ignore[unresolved-reference]
sa_relationship_kwargs={"cascade": "all, delete"}
)
@property
def recovery_codes_list(self) -> list[str]:
"""Список хешей"""
if not self.recovery_code_hashes:
return []
return self.recovery_code_hashes.split(" ")
@property
def recovery_codes_total(self) -> int:
"""Общее количество слотов"""
if not self.recovery_code_hashes:
return 0
return len(self.recovery_codes_list)
@property
def recovery_codes_remaining(self) -> int:
"""Количество неиспользованных кодов"""
return sum(1 for h in self.recovery_codes_list if h)
@property
def recovery_codes_used(self) -> int:
"""Количество использованных кодов"""
return self.recovery_codes_total - self.recovery_codes_remaining
def get_recovery_code_positions(self) -> dict[str, list[int]]:
"""Возвращает позиции использованных и оставшихся кодов"""
used = []
remaining = []
for i, h in enumerate(self.recovery_codes_list, start=1):
if h:
remaining.append(i)
else:
used.append(i)
return {"used": used, "remaining": remaining}
+32 -4
View File
@@ -1,13 +1,31 @@
"""Модуль DTO-моделей""" """Модуль DTO-моделей"""
from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
from .token import Token, TokenData from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres, from .token import TokenData
BookWithAuthorsAndGenres, BookFilteredList, BookStatusUpdate, LoanWithBook) from .misc import (
AuthorWithBooks,
GenreWithBooks,
BookWithAuthors,
BookWithGenres,
BookWithAuthorsAndGenres,
BookFilteredList,
BookStatusUpdate,
LoanWithBook,
LoginResponse,
RegisterResponse,
UserCreateByAdmin,
UserUpdateByAdmin,
TOTPSetupResponse,
TOTPVerifyRequest,
TOTPDisableRequest,
PasswordResetResponse,
)
__all__ = [ __all__ = [
"AuthorBase", "AuthorBase",
@@ -44,6 +62,16 @@ __all__ = [
"RoleUpdate", "RoleUpdate",
"RoleRead", "RoleRead",
"RoleList", "RoleList",
"Token",
"TokenData", "TokenData",
"TOTPSetupResponse",
"TOTPVerifyRequest",
"TOTPDisableRequest",
"RecoveryCodeUse",
"UserCreateByAdmin",
"UserUpdateByAdmin",
"LoginResponse",
"RegisterResponse",
"RecoveryCodesStatus",
"PasswordResetResponse",
"RecoveryCodesResponse",
] ]
+14 -8
View File
@@ -1,35 +1,41 @@
"""Модуль DTO-моделей авторов""" """Модуль DTO-моделей авторов"""
from typing import List from typing import List
from pydantic import ConfigDict from pydantic import ConfigDict
from sqlmodel import SQLModel from sqlmodel import SQLModel, Field
class AuthorBase(SQLModel): class AuthorBase(SQLModel):
"""Базовая модель автора""" """Базовая модель автора"""
name: str
model_config = ConfigDict( # pyright: ignore name: str = Field(description="Псевдоним")
json_schema_extra={"example": {"name": "author_name"}}
model_config = ConfigDict(
json_schema_extra={"example": {"name": "John Doe"}}
) )
class AuthorCreate(AuthorBase): class AuthorCreate(AuthorBase):
"""Модель автора для создания""" """Модель автора для создания"""
pass pass
class AuthorUpdate(SQLModel): class AuthorUpdate(SQLModel):
"""Модель автора для обновления""" """Модель автора для обновления"""
name: str | None = None
name: str | None = Field(None, description="Псевдоним")
class AuthorRead(AuthorBase): class AuthorRead(AuthorBase):
"""Модель автора для чтения""" """Модель автора для чтения"""
id: int
id: int = Field(description="Идентификатор")
class AuthorList(SQLModel): class AuthorList(SQLModel):
"""Список авторов""" """Список авторов"""
authors: List[AuthorRead]
total: int authors: List[AuthorRead] = Field(description="Список авторов")
total: int = Field(description="Количество авторов")
+24 -11
View File
@@ -1,43 +1,56 @@
"""Модуль DTO-моделей книг""" """Модуль DTO-моделей книг"""
from typing import List from typing import List
from pydantic import ConfigDict from pydantic import ConfigDict
from sqlmodel import SQLModel from sqlmodel import SQLModel, Field
from library_service.models.enums import BookStatus from library_service.models.enums import BookStatus
class BookBase(SQLModel): class BookBase(SQLModel):
"""Базовая модель книги""" """Базовая модель книги"""
title: str
description: str title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(gt=0, description="Количество страниц")
model_config = ConfigDict( # pyright: ignore model_config = ConfigDict( # pyright: ignore
json_schema_extra={ json_schema_extra={
"example": {"title": "book_title", "description": "book_description"} "example": {
"title": "book_title",
"description": "book_description",
"page_count": 1,
}
} }
) )
class BookCreate(BookBase): class BookCreate(BookBase):
"""Модель книги для создания""" """Модель книги для создания"""
pass pass
class BookUpdate(SQLModel): class BookUpdate(SQLModel):
"""Модель книги для обновления""" """Модель книги для обновления"""
title: str | None = None
description: str | None = None title: str | None = Field(None, description="Название")
status: BookStatus | None = None description: str | None = Field(None, description="Описание")
page_count: int | None = Field(None, description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
class BookRead(BookBase): class BookRead(BookBase):
"""Модель книги для чтения""" """Модель книги для чтения"""
id: int
status: BookStatus id: int = Field(description="Идентификатор")
status: BookStatus = Field(description="Статус")
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
class BookList(SQLModel): class BookList(SQLModel):
"""Список книг""" """Список книг"""
books: List[BookRead]
total: int books: List[BookRead] = Field(description="Список книг")
total: int = Field(description="Количество книг")
-63
View File
@@ -1,63 +0,0 @@
"""Модуль объединёных объектов"""
from typing import List
from sqlmodel import SQLModel, Field
from .author import AuthorRead
from .genre import GenreRead
from .book import BookRead
from .loan import LoanRead
from ..enums import BookStatus
class AuthorWithBooks(SQLModel):
"""Модель автора с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
class GenreWithBooks(SQLModel):
"""Модель жанра с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
class BookWithAuthors(SQLModel):
"""Модель книги с авторами"""
id: int
title: str
description: str
authors: List[AuthorRead] = Field(default_factory=list)
class BookWithGenres(SQLModel):
"""Модель книги с жанрами"""
id: int
title: str
description: str
status: BookStatus | None = None
genres: List[GenreRead] = Field(default_factory=list)
class BookWithAuthorsAndGenres(SQLModel):
"""Модель с авторами и жанрами"""
id: int
title: str
description: str
status: BookStatus | None = None
authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list)
class BookFilteredList(SQLModel):
"""Список книг с фильтрацией"""
books: List[BookWithAuthorsAndGenres]
total: int
class LoanWithBook(LoanRead):
"""Модель выдачи, включающая данные о книге"""
book: BookRead
class BookStatusUpdate(SQLModel):
"""Модель для ручного изменения статуса библиотекарем"""
status: str
+12 -6
View File
@@ -1,13 +1,15 @@
"""Модуль DTO-моделей жанров""" """Модуль DTO-моделей жанров"""
from typing import List from typing import List
from pydantic import ConfigDict from pydantic import ConfigDict
from sqlmodel import SQLModel from sqlmodel import SQLModel, Field
class GenreBase(SQLModel): class GenreBase(SQLModel):
"""Базовая модель жанра""" """Базовая модель жанра"""
name: str
name: str = Field(description="Название")
model_config = ConfigDict( # pyright: ignore model_config = ConfigDict( # pyright: ignore
json_schema_extra={"example": {"name": "genre_name"}} json_schema_extra={"example": {"name": "genre_name"}}
@@ -16,20 +18,24 @@ class GenreBase(SQLModel):
class GenreCreate(GenreBase): class GenreCreate(GenreBase):
"""Модель жанра для создания""" """Модель жанра для создания"""
pass pass
class GenreUpdate(SQLModel): class GenreUpdate(SQLModel):
"""Модель жанра для обновления""" """Модель жанра для обновления"""
name: str | None = None
name: str | None = Field(None, description="Название")
class GenreRead(GenreBase): class GenreRead(GenreBase):
"""Модель жанра для чтения""" """Модель жанра для чтения"""
id: int
id: int = Field(description="Идентификатор")
class GenreList(SQLModel): class GenreList(SQLModel):
"""Списко жанров""" """Списко жанров"""
genres: List[GenreRead]
total: int genres: List[GenreRead] = Field(description="Список жанров")
total: int = Field(description="Количество жанров")
+24 -12
View File
@@ -1,37 +1,49 @@
"""Модуль DTO-моделей для выдачи книг""" """Модуль DTO-моделей для выдачи книг"""
from typing import List from typing import List
from datetime import datetime from datetime import datetime
from sqlmodel import SQLModel from sqlmodel import SQLModel, Field
class LoanBase(SQLModel): class LoanBase(SQLModel):
"""Базовая модель выдачи""" """Базовая модель выдачи"""
book_id: int
user_id: int book_id: int = Field(description="Идентификатор книги")
due_date: datetime user_id: int = Field(description="Идентификатор пользователя")
due_date: datetime = Field(description="Дата и время планируемого возврата")
class LoanCreate(LoanBase): class LoanCreate(LoanBase):
"""Модель для создания записи о выдаче""" """Модель для создания записи о выдаче"""
pass pass
class LoanUpdate(SQLModel): class LoanUpdate(SQLModel):
"""Модель для обновления записи о выдаче""" """Модель для обновления записи о выдаче"""
user_id: int | None = None
due_date: datetime | None = None user_id: int | None = Field(None, description="Идентификатор пользователя")
returned_at: datetime | None = None due_date: datetime | None = Field(
None, description="дата и время планируемого возврата"
)
returned_at: datetime | None = Field(
None, description="Дата и время фактического возврата"
)
class LoanRead(LoanBase): class LoanRead(LoanBase):
"""Модель чтения записи о выдаче""" """Модель чтения записи о выдаче"""
id: int
borrowed_at: datetime id: int = Field(description="Идентификатор")
returned_at: datetime | None = None borrowed_at: datetime = Field(description="Дата и время выдачи")
returned_at: datetime | None = Field(
None, description="Дата и время фактического возврата"
)
class LoanList(SQLModel): class LoanList(SQLModel):
"""Список выдач""" """Список выдач"""
loans: List[LoanRead]
total: int loans: List[LoanRead] = Field(description="Список выдач")
total: int = Field(description="Количество выдач")
+162
View File
@@ -0,0 +1,162 @@
"""Модуль разных моделей"""
from datetime import datetime
from typing import List
from sqlmodel import SQLModel, Field
from .author import AuthorRead
from .genre import GenreRead
from .book import BookRead
from .loan import LoanRead
from ..enums import BookStatus
from .user import UserCreate, UserRead, UserUpdate
from .recovery import RecoveryCodesResponse
class AuthorWithBooks(SQLModel):
"""Модель автора с книгами"""
id: int = Field(description="Идентификатор")
name: str = Field(description="Псевдоним")
books: List[BookRead] = Field(default_factory=list, description="Список книг")
class GenreWithBooks(SQLModel):
"""Модель жанра с книгами"""
id: int = Field(description="Идентификатор")
name: str = Field(description="Название")
books: List[BookRead] = Field(default_factory=list, description="Список книг")
class BookWithAuthors(SQLModel):
"""Модель книги с авторами"""
id: int = Field(description="Идентификатор")
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
class BookWithGenres(SQLModel):
"""Модель книги с жанрами"""
id: int = Field(description="Идентификатор")
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
class BookWithAuthorsAndGenres(SQLModel):
"""Модель с авторами и жанрами"""
id: int = Field(description="Идентификатор")
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
class BookFilteredList(SQLModel):
"""Список книг с фильтрацией"""
books: List[BookWithAuthorsAndGenres] = Field(
description="Список отфильтрованных книг"
)
total: int = Field(description="Количество книг")
class LoanWithBook(LoanRead):
"""Модель выдачи, включающая данные о книге"""
book: BookRead = Field(description="Книга")
class BookStatusUpdate(SQLModel):
"""Модель для ручного изменения статуса библиотекарем"""
status: str = Field(description="Статус книги")
class UserCreateByAdmin(UserCreate):
"""Создание пользователя администратором"""
is_active: bool = Field(True, description="Не является ли заблокированным")
roles: list[str] | None = Field(None, description="Роли")
class UserUpdateByAdmin(UserUpdate):
"""Обновление пользователя администратором"""
is_active: bool = Field(True, description="Не является ли заблокированным")
roles: list[str] | None = Field(None, description="Роли")
class LoginResponse(SQLModel):
"""Модель для авторизации пользователя"""
access_token: str | None = Field(None, description="Токен доступа")
partial_token: str | None = Field(None, description="Частичный токен")
refresh_token: str | None = Field(None, description="Токен обновления")
token_type: str = Field("bearer", description="Тип токена")
requires_2fa: bool = Field(False, description="Требуется ли TOTP=код")
class RegisterResponse(SQLModel):
"""Модель для регистрации пользователя"""
user: UserRead = Field(description="Пользователь")
recovery_codes: RecoveryCodesResponse = Field(description="Коды восстановления")
class PasswordResetResponse(SQLModel):
"""Модель для сброса пароля"""
total: int = Field(description="Общее количество кодов")
remaining: int = Field(description="Количество оставшихся кодов")
used_codes: list[bool] = Field(description="Количество использованых кодов")
generated_at: datetime | None = Field(description="Дата и время генерации")
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
class TOTPSetupResponse(SQLModel):
"""Модель для генерации данных для настройки TOTP"""
secret: str = Field(description="Секрет TOTP")
username: str = Field(description="Имя пользователя")
issuer: str = Field(description="Запрашивающий сервис")
size: int = Field(description="Размер кода")
padding: int = Field(description="Отступ")
bitmap_b64: str = Field(description="QR-код")
class TOTPVerifyRequest(SQLModel):
"""Модель для проверки TOTP кода"""
code: str = Field(
min_length=6,
max_length=6,
regex=r"^\d{6}$",
description="Шестизначный TOTP-код",
)
class TOTPDisableRequest(SQLModel):
"""Модель для отключения TOTP 2FA"""
password: str = Field(description="Пароль")
+54
View File
@@ -0,0 +1,54 @@
"""Модуль DTO-моделей для резервных кодов восстановления"""
from datetime import datetime
import re
from pydantic import field_validator
from sqlmodel import SQLModel, Field
class RecoveryCodesResponse(SQLModel):
"""Ответ при генерации резервных кодов"""
codes: list[str] = Field(description="Список кодов восстановления")
generated_at: datetime = Field(description="Дата и время генерации")
class RecoveryCodesStatus(SQLModel):
"""Статус резервных кодов пользователя"""
total: int = Field(description="Общее количество кодов")
remaining: int = Field(description="Количество оставшихся кодов")
used_codes: list[bool] = Field(description="Количество использованых кодов")
generated_at: datetime | None = Field(description="Дата и время генерации")
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
class RecoveryCodeUse(SQLModel):
"""Запрос на сброс пароля через резервный код"""
username: str = Field(description="Имя пользователя")
recovery_code: str = Field(
min_length=19, max_length=19, description="Код восстановления"
)
new_password: str = Field(min_length=8, max_length=100, description="Новый пароль")
@field_validator("recovery_code")
@classmethod
def validate_recovery_code(cls, v: str) -> str:
if not re.match(
r"^[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}$", v
):
raise ValueError("Invalid recovery code format")
return v.lower()
@field_validator("new_password")
@classmethod
def validate_password(cls, v: str) -> str:
if not re.search(r"[A-Z]", v):
raise ValueError("Password must contain uppercase")
if not re.search(r"[a-z]", v):
raise ValueError("Password must contain lowercase")
if not re.search(r"\d", v):
raise ValueError("Password must contain digit")
return v
+25 -8
View File
@@ -1,32 +1,49 @@
"""Модуль DTO-моделей ролей""" """Модуль DTO-моделей ролей"""
from typing import List from typing import List
from sqlmodel import SQLModel from pydantic import ConfigDict
from sqlmodel import SQLModel, Field
class RoleBase(SQLModel): class RoleBase(SQLModel):
"""Базовая модель роли""" """Базовая модель роли"""
name: str
description: str | None = None name: str = Field(description="Название")
payroll: int = 0 description: str | None = Field(None, description="Описание")
payroll: int = Field(0, description="Оплата")
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "admin",
"description": "system administrator",
"payroll": 500,
}
}
)
class RoleCreate(RoleBase): class RoleCreate(RoleBase):
"""Модель роли для создания""" """Модель роли для создания"""
pass pass
class RoleUpdate(SQLModel): class RoleUpdate(SQLModel):
"""Модель роли для обновления""" """Модель роли для обновления"""
name: str | None = None
name: str | None = Field(None, description="Название")
class RoleRead(RoleBase): class RoleRead(RoleBase):
"""Модель роли для чтения""" """Модель роли для чтения"""
id: int
id: int = Field(description="Идентификатор")
class RoleList(SQLModel): class RoleList(SQLModel):
"""Список ролей""" """Список ролей"""
roles: List[RoleRead]
total: int roles: List[RoleRead] = Field(description="Список ролей")
total: int = Field(description="Количество ролей")
+6 -10
View File
@@ -1,15 +1,11 @@
"""Модуль DTO-моделей токенов""" """Модуль DTO-модели токена"""
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
class Token(SQLModel):
"""Модель токена"""
access_token: str
token_type: str = "bearer"
refresh_token: str | None = None
class TokenData(SQLModel): class TokenData(SQLModel):
"""Модель содержимого токена""" """Модель содержимого токена"""
username: str | None = None
user_id: int | None = None username: str | None = Field(None, description="Имя пользователя")
user_id: int | None = Field(None, description="Идентификатор пользователя")
is_partial: bool = Field(False, description="Является ли токен частичным")
+30 -14
View File
@@ -1,4 +1,5 @@
"""Модуль DTO-моделей пользователей""" """Модуль DTO-моделей пользователей"""
import re import re
from typing import List from typing import List
@@ -8,9 +9,18 @@ from sqlmodel import Field, SQLModel
class UserBase(SQLModel): class UserBase(SQLModel):
"""Базовая модель пользователя""" """Базовая модель пользователя"""
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
email: EmailStr = Field(index=True, unique=True) username: str = Field(
full_name: str | None = Field(default=None, max_length=100) min_length=3,
max_length=50,
index=True,
unique=True,
description="Имя пользователя",
)
email: EmailStr = Field(index=True, unique=True, description="Email")
full_name: str | None = Field(
default=None, max_length=100, description="Полное имя"
)
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
@@ -25,7 +35,8 @@ class UserBase(SQLModel):
class UserCreate(UserBase): class UserCreate(UserBase):
"""Модель пользователя для создания""" """Модель пользователя для создания"""
password: str = Field(min_length=8, max_length=100)
password: str = Field(min_length=8, max_length=100, description="Пароль")
@field_validator("password") @field_validator("password")
@classmethod @classmethod
@@ -42,26 +53,31 @@ class UserCreate(UserBase):
class UserLogin(SQLModel): class UserLogin(SQLModel):
"""Модель аутентификации для пользователя""" """Модель аутентификации для пользователя"""
username: str
password: str username: str = Field(description="Имя пользователя")
password: str = Field(description="Пароль")
class UserRead(UserBase): class UserRead(UserBase):
"""Модель пользователя для чтения""" """Модель пользователя для чтения"""
id: int id: int
is_active: bool is_active: bool = Field(description="Не является ли заблокированым")
is_verified: bool is_verified: bool = Field(description="Является ли верифицированым")
roles: List[str] = [] is_2fa_enabled: bool = Field(description="Включен ли TOTP 2FA")
roles: List[str] = Field([], description="Роли")
class UserUpdate(SQLModel): class UserUpdate(SQLModel):
"""Модель пользователя для обновления""" """Модель пользователя для обновления"""
email: EmailStr | None = None
full_name: str | None = None email: EmailStr | None = Field(None, description="Email")
password: str | None = None full_name: str | None = Field(None, description="Полное имя")
password: str | None = Field(None, description="Пароль")
class UserList(SQLModel): class UserList(SQLModel):
"""Список пользователей""" """Список пользователей"""
users: List[UserRead]
total: int users: List[UserRead] = Field(description="Список пользователей")
total: int = Field(description="Количество пользователей")
+5
View File
@@ -1,4 +1,5 @@
"""Модуль объединения роутеров""" """Модуль объединения роутеров"""
from fastapi import APIRouter from fastapi import APIRouter
from .auth import router as auth_router from .auth import router as auth_router
@@ -7,6 +8,8 @@ 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 .loans import router as loans_router
from .relationships import router as relationships_router from .relationships import router as relationships_router
from .cap import router as cap_router
from .users import router as users_router
from .misc import router as misc_router from .misc import router as misc_router
@@ -20,4 +23,6 @@ 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(loans_router, prefix="/api")
api_router.include_router(cap_router, prefix="/api")
api_router.include_router(users_router, prefix="/api")
api_router.include_router(relationships_router, prefix="/api") api_router.include_router(relationships_router, prefix="/api")
+255 -148
View File
@@ -1,23 +1,34 @@
"""Модуль работы с авторизацией и аутентификацией пользователей""" """Модуль работы с авторизацией и аутентификацией пользователей"""
import base64
from datetime import timedelta from datetime import timedelta
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select from sqlmodel import Session, select
import pyotp
from library_service.services import require_captcha
from library_service.models.db import Role, User from library_service.models.db import Role, User
from library_service.models.dto import ( from library_service.models.dto import (
Token,
UserCreate, UserCreate,
UserRead, UserRead,
UserUpdate, UserUpdate,
UserList, UserList,
RoleRead, RoleRead,
RoleList, RoleList,
LoginResponse,
RecoveryCodeUse,
RegisterResponse,
RecoveryCodesStatus,
RecoveryCodesResponse,
PasswordResetResponse,
TOTPSetupResponse,
TOTPVerifyRequest,
TOTPDisableRequest,
) )
from library_service.settings import get_session from library_service.settings import get_session
from library_service.auth import ( from library_service.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES, ACCESS_TOKEN_EXPIRE_MINUTES,
@@ -29,23 +40,35 @@ from library_service.auth import (
decode_token, decode_token,
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
generate_totp_setup,
generate_codes_for_user,
verify_and_use_code,
get_codes_status,
verify_totp_code,
verify_password,
qr_to_bitmap_b64, qr_to_bitmap_b64,
create_partial_token,
RequirePartialAuth,
verify_and_use_code,
cipher,
) )
from pathlib import Path
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
router = APIRouter(prefix="/auth", tags=["authentication"]) router = APIRouter(prefix="/auth", tags=["authentication"])
@router.post( @router.post(
"/register", "/register",
response_model=UserRead, response_model=RegisterResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="Регистрация нового пользователя", summary="Регистрация нового пользователя",
description="Создает нового пользователя в системе", description="Создает нового пользователя и возвращает резервные коды",
) )
def register(user_data: UserCreate, session: Session = Depends(get_session)): def register(
user_data: UserCreate,
_=Depends(require_captcha),
session: Session = Depends(get_session),
):
"""Регистрирует нового пользователя в системе""" """Регистрирует нового пользователя в системе"""
existing_user = session.exec( existing_user = session.exec(
select(User).where(User.username == user_data.username) select(User).where(User.username == user_data.username)
@@ -61,7 +84,8 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
).first() ).first()
if existing_email: if existing_email:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
) )
db_user = User( db_user = User(
@@ -77,14 +101,25 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
session.commit() session.commit()
session.refresh(db_user) session.refresh(db_user)
return UserRead(**db_user.model_dump(), roles=[role.name for role in db_user.roles]) recovery_codes = generate_codes_for_user(session, db_user)
return RegisterResponse(
user=UserRead(
**db_user.model_dump(),
roles=[role.name for role in db_user.roles],
),
recovery_codes=RecoveryCodesResponse(
codes=recovery_codes,
generated_at=db_user.recovery_codes_generated_at,
),
)
@router.post( @router.post(
"/token", "/token",
response_model=Token, response_model=LoginResponse,
summary="Получение токена", summary="Получение токена",
description="Аутентификация и получение JWT токена", description="Аутентификация и получение токенов",
) )
def login( def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
@@ -99,25 +134,34 @@ def login(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) token_data = {"sub": user.username, "user_id": user.id}
access_token = create_access_token(
data={"sub": user.username, "user_id": user.id},
expires_delta=access_token_expires,
)
refresh_token = create_refresh_token(
data={"sub": user.username, "user_id": user.id}
)
return Token( if user.is_2fa_enabled:
access_token=access_token, refresh_token=refresh_token, token_type="bearer" return LoginResponse(
partial_token=create_partial_token(token_data),
token_type="partial",
requires_2fa=True,
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
new_access_token = create_access_token(
data=token_data, expires_delta=access_token_expires
)
new_refresh_token = create_refresh_token(data=token_data)
return LoginResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
requires_2fa=False,
) )
@router.post( @router.post(
"/refresh", "/refresh",
response_model=Token, response_model=LoginResponse,
summary="Обновление токена", summary="Обновление токена",
description="Получение новой пары токенов (Access + Refresh) используя действующий Refresh токен", description="Получение новой пары токенов, используя действующий Refresh токен",
) )
def refresh_token( def refresh_token(
refresh_token: str = Body(..., embed=True), refresh_token: str = Body(..., embed=True),
@@ -146,19 +190,18 @@ def refresh_token(
detail="User is inactive", detail="User is inactive",
) )
token_data = {"sub": user.username, "user_id": user.id}
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
new_access_token = create_access_token( new_access_token = create_access_token(
data={"sub": user.username, "user_id": user.id}, data=token_data, expires_delta=access_token_expires
expires_delta=access_token_expires,
)
new_refresh_token = create_refresh_token(
data={"sub": user.username, "user_id": user.id}
) )
new_refresh_token = create_refresh_token(data=token_data)
return Token( return LoginResponse(
access_token=new_access_token, access_token=new_access_token,
refresh_token=new_refresh_token, refresh_token=new_refresh_token,
token_type="bearer", token_type="bearer",
requires_2fa=False,
) )
@@ -204,144 +247,208 @@ def update_user_me(
@router.get( @router.get(
"/users", "/2fa",
response_model=UserList, response_model=TOTPSetupResponse,
summary="Список пользователей", summary="Создание QR-кода TOTP 2FA",
description="Получить список всех пользователей (только для админов)", description="Генерирует секрет и QR-код для настройки TOTP",
) )
def read_users( def get_totp_qr_bitmap(
current_user: RequireStaff, current_user: RequireAuth,
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Возвращает список всех пользователей""" """Возвращает данные для настройки TOTP"""
users = session.exec(select(User).offset(skip).limit(limit)).all() totp_data = generate_totp_setup(current_user.username)
return UserList( encrypted = cipher.encrypt(totp_data["secret"].encode())
users=[
UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) current_user.totp_secret = base64.b64encode(encrypted).decode()
for user in users session.add(current_user)
], session.commit()
total=len(users),
return TOTPSetupResponse(**totp_data)
@router.post(
"/2fa/enable",
summary="Включение TOTP 2FA",
description="Подтверждает настройку и включает 2FA",
)
def enable_2fa(
data: TOTPVerifyRequest,
current_user: RequireAuth,
secret: str = Body(..., embed=True),
session: Session = Depends(get_session),
):
"""Включает 2FA после проверки кода"""
if current_user.is_2fa_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA already enabled",
)
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Secret key not generated"
)
if not verify_totp_code(secret, data.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid TOTP code",
)
decrypted = cipher.decrypt(base64.b64decode(current_user.totp_secret.encode()))
if secret != decrypted.decode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Incorret secret"
)
current_user.is_2fa_enabled = True
session.add(current_user)
session.commit()
return {"success": True}
@router.post(
"/2fa/disable",
summary="Отключение TOTP 2FA",
description="Отключает 2FA после проверки пароля и кода",
)
def disable_2fa(
data: TOTPDisableRequest,
current_user: RequireAuth,
session: Session = Depends(get_session),
):
"""Отключает 2FA"""
if not current_user.is_2fa_enabled or not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA not enabled",
)
if not verify_password(data.password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid password",
)
current_user.totp_secret = None
current_user.is_2fa_enabled = False
session.add(current_user)
session.commit()
return {"success": True}
@router.post(
"/2fa/verify",
response_model=LoginResponse,
summary="Верификация 2FA",
description="Завершает аутентификацию с помощью TOTP кода или резервного кода",
)
def verify_2fa(
data: TOTPVerifyRequest,
user: RequirePartialAuth,
session: Session = Depends(get_session),
):
"""Верифицирует 2FA и возвращает полный токен"""
if not data.code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Provide TOTP code",
)
verified = False
if data.code and user.totp_secret:
decrypted = cipher.decrypt(base64.b64decode(user.totp_secret.encode()))
if verify_totp_code(decrypted.decode(), data.code):
verified = True
if not verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code",
)
token_data = {"sub": user.username, "user_id": user.id}
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
new_access_token = create_access_token(
data=token_data, expires_delta=access_token_expires
)
new_refresh_token = create_refresh_token(data=token_data)
return LoginResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
requires_2fa=False,
)
@router.get(
"/recovery-codes/status",
response_model=RecoveryCodesStatus,
summary="Статус резервных кодов",
description="Показывает количество оставшихся кодов и какие использованы",
)
def get_recovery_codes_status(current_user: RequireAuth):
"""Возвращает статус резервных кодов"""
return RecoveryCodesStatus(**get_codes_status(current_user))
@router.post(
"/recovery-codes/regenerate",
response_model=RecoveryCodesResponse,
summary="Перегенерация резервных кодов",
description="Генерирует новые коды, старые аннулируются",
)
def regenerate_recovery_codes(
current_user: RequireAuth,
session: Session = Depends(get_session),
):
"""Генерирует новые резервные коды"""
codes = generate_codes_for_user(session, current_user)
return RecoveryCodesResponse(
codes=codes,
generated_at=current_user.recovery_codes_generated_at,
) )
@router.post( @router.post(
"/users/{user_id}/roles/{role_name}", "/password/reset",
response_model=UserRead, response_model=PasswordResetResponse,
summary="Назначить роль пользователю", summary="Сброс пароля через резервный код",
description="Добавить указанную роль пользователю", description="Устанавливает новый пароль используя резервный код",
) )
def add_role_to_user( def reset_password(
user_id: int, data: RecoveryCodeUse,
role_name: str,
admin: RequireAdmin,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Добавляет роль пользователю""" """Сброс пароля с использованием резервного кода"""
user = session.get(User, user_id) user = session.exec(select(User).where(User.username == data.username)).first()
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_400_BAD_REQUEST,
detail="User not found", detail="Invalid username or recovery code",
) )
role = session.exec(select(Role).where(Role.name == role_name)).first() if not user.is_active:
if not role:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{role_name}' not found", detail="Account is deactivated",
) )
if role in user.roles: if not verify_and_use_code(session, user, data.recovery_code):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="User already has this role", detail="Invalid username or recovery code",
) )
user.roles.append(role) user.hashed_password = get_password_hash(data.new_password)
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) return PasswordResetResponse(**get_codes_status(user))
@router.delete(
"/users/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Удалить роль у пользователя",
description="Убрать указанную роль у пользователя",
)
def remove_role_from_user(
user_id: int,
role_name: str,
admin: RequireAdmin,
session: Session = Depends(get_session),
):
"""Удаляет роль у пользователя"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role not in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User does not have this role",
)
user.roles.remove(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.get(
"/roles",
response_model=RoleList,
summary="Получить список ролей",
description="Возвращает список ролей",
)
def get_roles(
auth: RequireAuth,
session: Session = Depends(get_session),
):
"""Возвращает список ролей в системе"""
user_roles = [role.name for role in auth.roles]
exclude = {"payroll"} if "admin" in user_roles else set()
roles = session.exec(select(Role)).all()
return RoleList(
roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
total=len(roles),
)
@router.get(
"/2fa",
summary="Создание QR-кода TOTP 2FA",
description="Получить информацию о текущем авторизованном пользователе",
)
def get_totp_qr_bitmap(auth: RequireAuth):
"""Возвращает qr-код bitmap"""
issuer = "issuer"
username = auth.username
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(name=username, issuer_name=issuer)
bitmap_data = qr_to_bitmap_b64(provisioning_uri)
return {"secret": secret, "username": username, "issuer": issuer, **bitmap_data}
+19 -6
View File
@@ -1,12 +1,19 @@
"""Модуль работы с авторами""" """Модуль работы с авторами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireStaff 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 (
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate) BookRead,
AuthorWithBooks,
AuthorCreate,
AuthorList,
AuthorRead,
AuthorUpdate,
)
router = APIRouter(prefix="/authors", tags=["authors"]) router = APIRouter(prefix="/authors", tags=["authors"])
@@ -59,7 +66,9 @@ def get_author(
"""Возвращает информацию об авторе и его книгах""" """Возвращает информацию об авторе и его книгах"""
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=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
books = session.exec( books = session.exec(
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id) select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
@@ -88,7 +97,9 @@ def update_author(
"""Обновляет информацию об авторе""" """Обновляет информацию об авторе"""
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=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
update_data = author.model_dump(exclude_unset=True) update_data = author.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
@@ -113,7 +124,9 @@ def delete_author(
"""Удаляет автора из системы""" """Удаляет автора из системы"""
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=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
author_read = AuthorRead(**author.model_dump()) author_read = AuthorRead(**author.model_dump())
session.delete(author) session.delete(author)
+225 -65
View File
@@ -1,83 +1,122 @@
"""Модуль работы с книгами""" """Модуль работы с книгами"""
from datetime import datetime from library_service.services import transcode_image
import shutil
from uuid import uuid4
from pydantic import Field
from typing_extensions import Annotated
from sqlalchemy.orm import selectinload, defer
from sqlalchemy import text, case, distinct
from datetime import datetime, timezone
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query, status, UploadFile, File
from ollama import Client
from sqlmodel import Session, select, col, func from sqlmodel import Session, select, col, func
from library_service.auth import RequireStaff from library_service.auth import RequireStaff
from library_service.settings import get_session from library_service.settings import get_session, OLLAMA_URL, BOOKS_PREVIEW_DIR
from library_service.models.enums import BookStatus from library_service.models.enums import BookStatus
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre, BookUserLink from library_service.models.db import (
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead Author,
from library_service.models.dto.combined import ( AuthorBookLink,
Book,
GenreBookLink,
Genre,
BookUserLink,
)
from library_service.models.dto import (
AuthorRead,
BookCreate,
BookList,
BookRead,
BookUpdate,
GenreRead,
)
from library_service.models.dto.misc import (
BookWithAuthorsAndGenres, BookWithAuthorsAndGenres,
BookFilteredList BookFilteredList,
) )
router = APIRouter(prefix="/books", tags=["books"]) router = APIRouter(prefix="/books", tags=["books"])
ollama_client = Client(host=OLLAMA_URL)
def close_active_loan(session: Session, book_id: int) -> None: def close_active_loan(session: Session, book_id: int) -> None:
"""Закрывает активную выдачу книги при изменении статуса""" """Закрывает активную выдачу книги при изменении статуса"""
active_loan = session.exec( active_loan = session.exec(
select(BookUserLink) select(BookUserLink)
.where(BookUserLink.book_id == book_id) .where(BookUserLink.book_id == book_id) # ty: ignore
.where(BookUserLink.returned_at == None) # noqa: E711 .where(BookUserLink.returned_at == None) # ty: ignore
).first() ).first() # ty: ignore
if active_loan: if active_loan:
active_loan.returned_at = datetime.utcnow() active_loan.returned_at = datetime.now(timezone.utc)
session.add(active_loan) session.add(active_loan)
@router.get( from sqlalchemy import select, func, distinct, case, exists
"/filter", from sqlalchemy.orm import selectinload
response_model=BookFilteredList,
summary="Фильтрация книг",
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией" @router.get("/filter", response_model=BookFilteredList)
)
def filter_books( def filter_books(
session: Session = Depends(get_session), session: Session = Depends(get_session),
q: str | None = Query(None, max_length=50, description="Поиск"), q: str | None = Query(None, max_length=50, description="Поиск"),
author_ids: List[int] | None = Query(None, description="Список ID авторов"), min_page_count: int | None = Query(None, ge=0),
genre_ids: List[int] | None = Query(None, description="Список ID жанров"), max_page_count: int | None = Query(None, ge=0),
page: int = Query(1, gt=0, description="Номер страницы"), author_ids: List[Annotated[int, Field(gt=0)]] | None = Query(None),
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"), genre_ids: List[Annotated[int, Field(gt=0)]] | None = Query(None),
page: int = Query(1, gt=0),
size: int = Query(20, gt=0, le=100),
): ):
"""Возвращает отфильтрованный список книг с пагинацией""" statement = select(Book).options(
statement = select(Book).distinct() selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) # ty: ignore
)
if q: if min_page_count:
statement = statement.where( statement = statement.where(Book.page_count >= min_page_count) # ty: ignore
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%")) if max_page_count:
) statement = statement.where(Book.page_count <= max_page_count) # ty: ignore
if author_ids: if author_ids:
statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids)) statement = statement.where(
exists().where(
if genre_ids: AuthorBookLink.book_id == Book.id, # ty: ignore
statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids)) AuthorBookLink.author_id.in_(author_ids), # ty: ignore
total_statement = select(func.count()).select_from(statement.subquery())
total = session.exec(total_statement).one()
offset = (page - 1) * size
statement = statement.offset(offset).limit(size)
results = session.exec(statement).all()
books_with_data = []
for db_book in results:
books_with_data.append(
BookWithAuthorsAndGenres(
**db_book.model_dump(),
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
genres=[GenreRead(**g.model_dump()) for g in db_book.genres]
) )
) )
return BookFilteredList(books=books_with_data, total=total) if genre_ids:
for genre_id in genre_ids:
statement = statement.where(
exists().where(
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id # ty: ignore
)
)
count_statement = select(func.count()).select_from(statement.subquery())
total = session.scalar(count_statement)
if q:
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"]
distance_col = Book.embedding.cosine_distance(emb) # ty: ignore
statement = statement.where(Book.embedding.is_not(None)) # ty: ignore
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1) # ty: ignore
statement = statement.order_by(keyword_match, distance_col)
else:
statement = statement.order_by(Book.id) # ty: ignore
offset = (page - 1) * size
statement = statement.offset(offset).limit(size)
results = session.scalars(statement).unique().all()
return BookFilteredList(books=results, total=total)
@router.post( @router.post(
@@ -89,14 +128,24 @@ def filter_books(
def create_book( def create_book(
book: BookCreate, book: BookCreate,
current_user: RequireStaff, current_user: RequireStaff,
session: Session = Depends(get_session) session: Session = Depends(get_session),
): ):
"""Создает новую книгу в системе""" """Создает новую книгу в системе"""
db_book = Book(**book.model_dump()) full_text = book.title + " " + book.description
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book = Book(**book.model_dump(), embedding=emb["embedding"])
session.add(db_book) session.add(db_book)
session.commit() session.commit()
session.refresh(db_book) session.refresh(db_book)
return BookRead(**db_book.model_dump())
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{db_book.preview_id}.png",
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
"webp": f"/static/books/{db_book.preview_id}.webp",
} if db_book.preview_id else {}
return BookRead(**book_data)
@router.get( @router.get(
@@ -107,9 +156,21 @@ def create_book(
) )
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() # ty: ignore
books_data = []
for book in books:
book_data = book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{book.preview_id}.png",
"jpeg": f"/static/books/{book.preview_id}.jpg",
"webp": f"/static/books/{book.preview_id}.webp",
} if book.preview_id else {}
books_data.append(book_data)
return BookList( return BookList(
books=[BookRead(**book.model_dump()) for book in books], total=len(books) books=[BookRead(**book_data) for book_data in books_data],
total=len(books),
) )
@@ -126,21 +187,28 @@ def get_book(
"""Возвращает информацию о книге с авторами и жанрами""" """Возвращает информацию о книге с авторами и жанрами"""
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=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
authors = session.exec( authors = session.scalars(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) # ty: ignore
).all() ).all()
author_reads = [AuthorRead(**author.model_dump()) for author in authors] author_reads = [AuthorRead(**author.model_dump()) for author in authors]
genres = session.exec( genres = session.scalars(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) # ty: ignore
).all() ).all()
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres] genre_reads = [GenreRead(**genre.model_dump()) for genre in genres]
book_data = book.model_dump() book_data = book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{book.preview_id}.png",
"jpeg": f"/static/books/{book.preview_id}.jpg",
"webp": f"/static/books/{book.preview_id}.webp",
} if book.preview_id else {}
book_data["authors"] = author_reads book_data["authors"] = author_reads
book_data["genres"] = genre_reads book_data["genres"] = genre_reads
@@ -149,7 +217,7 @@ def get_book(
@router.put( @router.put(
"/{book_id}", "/{book_id}",
response_model=Book, response_model=BookRead,
summary="Обновить информацию о книге", summary="Обновить информацию о книге",
description="Обновляет информацию о книге в системе", description="Обновляет информацию о книге в системе",
) )
@@ -162,13 +230,15 @@ def update_book(
"""Обновляет информацию о книге""" """Обновляет информацию о книге"""
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=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book_update.status is not None: if book_update.status is not None:
if book_update.status == BookStatus.BORROWED: if book_update.status == BookStatus.BORROWED:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail="Статус 'borrowed' устанавливается только через выдачу книги" detail="Статус 'borrowed' устанавливается только через выдачу книги",
) )
if db_book.status == BookStatus.BORROWED: if db_book.status == BookStatus.BORROWED:
@@ -182,11 +252,29 @@ def update_book(
if book_update.description is not None: if book_update.description is not None:
db_book.description = book_update.description db_book.description = book_update.description
full_text = (
(book_update.title or db_book.title)
+ " "
+ (book_update.description or db_book.description)
)
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book.embedding = emb["embedding"]
if book_update.page_count is not None:
db_book.page_count = book_update.page_count
session.add(db_book) session.add(db_book)
session.commit() session.commit()
session.refresh(db_book) session.refresh(db_book)
return BookRead(**db_book.model_dump()) book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{db_book.preview_id}.png",
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
"webp": f"/static/books/{db_book.preview_id}.webp",
} if db_book.preview_id else {}
return BookRead(**book_data)
@router.delete( @router.delete(
@@ -203,10 +291,82 @@ def delete_book(
"""Удаляет книгу из системы""" """Удаляет книгу из системы"""
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=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
book_read = BookRead( book_read = BookRead(
id=(book.id or 0), title=book.title, description=book.description, status=book.status id=(book.id or 0),
title=book.title,
description=book.description,
page_count=book.page_count,
status=book.status,
) )
session.delete(book) session.delete(book)
session.commit() session.commit()
return book_read return book_read
@router.post("/{book_id}/preview")
async def upload_book_preview(
current_user: RequireStaff,
file: UploadFile = File(...),
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
if not (file.content_type or "").startswith("image/"):
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Image required")
if (file.size or 0) > 32 * 1024 * 1024:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB")
file_uuid= uuid4()
tmp_path = BOOKS_PREVIEW_DIR / f"{file_uuid}.upload"
with open(tmp_path, "wb") as f:
shutil.copyfileobj(file.file, f)
book = session.get(Book, book_id)
if not book:
tmp_path.unlink()
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
transcode_image(tmp_path)
tmp_path.unlink()
if book.preview_id:
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
if path.exists():
path.unlink(missing_ok=True)
book.preview_id = file_uuid
session.add(book)
session.commit()
return {
"preview": {
"png": f"/static/books/{file_uuid}.png",
"jpeg": f"/static/books/{file_uuid}.jpg",
"webp": f"/static/books/{file_uuid}.webp",
}
}
@router.delete("/{book_id}/preview")
async def remove_book_preview(
current_user: RequireStaff,
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
if book.preview_id:
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
if path.exists():
path.unlink(missing_ok=True)
book.preview_id = None
session.add(book)
session.commit()
return {"preview_urls": []}
+103
View File
@@ -0,0 +1,103 @@
import asyncio
import hashlib
import secrets
from fastapi import APIRouter, Request, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from library_service.services.captcha import (
limiter,
get_ip,
active_challenges,
challenges_by_ip,
MAX_CHALLENGES_PER_IP,
MAX_TOTAL_CHALLENGES,
CHALLENGE_TTL,
REDEEM_TTL,
prng,
now_ms,
redeem_tokens,
)
router = APIRouter(prefix="/cap", tags=["captcha"])
@router.post("/challenge", summary="Задача capjs")
@limiter.limit("15/minute")
async def challenge(request: Request, ip: str = Depends(get_ip)):
"""Возвращает задачу capjs"""
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges"
)
if len(active_challenges) >= MAX_TOTAL_CHALLENGES:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Server busy"
)
token = secrets.token_hex(25)
redeem = secrets.token_hex(25)
expires = now_ms() + CHALLENGE_TTL
active_challenges[token] = {
"c": 50,
"s": 32,
"d": 4,
"expires": expires,
"redeem_token": redeem,
"ip": ip,
}
challenges_by_ip[ip] += 1
return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires}
@router.post("/redeem", summary="Проверка задачи")
@limiter.limit("30/minute")
async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
"""Возвращает capjs_token"""
token = payload.get("token")
solutions = payload.get("solutions", [])
if token not in active_challenges:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid challenge"
)
ch = active_challenges.pop(token)
challenges_by_ip[ch["ip"]] -= 1
if now_ms() > ch["expires"]:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Expired")
if len(solutions) < ch["c"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Bad solutions"
)
def verify(i: int) -> bool:
salt = prng(f"{token}{i+1}", ch["s"])
target = prng(f"{token}{i+1}d", ch["d"])
h = hashlib.sha256((salt + str(solutions[i])).encode()).hexdigest()
return h.startswith(target)
results = await asyncio.gather(
*(asyncio.to_thread(verify, i) for i in range(ch["c"]))
)
if not all(results):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid solution"
)
r_token = ch["redeem_token"]
redeem_tokens[r_token] = now_ms() + REDEEM_TTL
resp = JSONResponse(
{"success": True, "token": r_token, "expires": redeem_tokens[r_token]}
)
resp.set_cookie(
key="capjs_token",
value=r_token,
httponly=True,
samesite="lax",
max_age=REDEEM_TTL // 1000,
)
return resp
+19 -5
View File
@@ -1,10 +1,18 @@
"""Модуль работы с жанрами""" """Модуль работы с жанрами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireStaff 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
@@ -57,7 +65,9 @@ def get_genre(
"""Возвращает информацию о жанре и книгах с ним""" """Возвращает информацию о жанре и книгах с ним"""
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=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
books = session.exec( books = session.exec(
select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id) select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
@@ -86,7 +96,9 @@ def update_genre(
"""Обновляет информацию о жанре""" """Обновляет информацию о жанре"""
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=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
update_data = genre.model_dump(exclude_unset=True) update_data = genre.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
@@ -111,7 +123,9 @@ def delete_genre(
"""Удаляет жанр из системы""" """Удаляет жанр из системы"""
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=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
genre_read = GenreRead(**genre.model_dump()) genre_read = GenreRead(**genre.model_dump())
session.delete(genre) session.delete(genre)
+96 -71
View File
@@ -1,5 +1,6 @@
"""Модуль работы с выдачей и бронированием книг""" """Модуль работы с выдачей и бронированием книг"""
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Dict, List from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
@@ -34,28 +35,32 @@ def create_loan(
if not is_staff and loan.user_id != current_user.id: if not is_staff and loan.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You can only create loans for yourself" detail="You can only create loans for yourself",
) )
book = session.get(Book, loan.book_id) book = session.get(Book, loan.book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status != BookStatus.ACTIVE: if book.status != BookStatus.ACTIVE:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Book is not available for loan (status: {book.status})" detail=f"Book is not available for loan (status: {book.status})",
) )
target_user = session.get(User, loan.user_id) target_user = session.get(User, loan.user_id)
if not target_user: if not target_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan = BookUserLink( db_loan = BookUserLink(
book_id=loan.book_id, book_id=loan.book_id,
user_id=loan.user_id, user_id=loan.user_id,
due_date=loan.due_date, due_date=loan.due_date,
borrowed_at=datetime.utcnow() borrowed_at=datetime.now(timezone.utc),
) )
book.status = BookStatus.RESERVED book.status = BookStatus.RESERVED
@@ -109,8 +114,7 @@ def read_loans(
loans = session.exec(statement).all() loans = session.exec(statement).all()
return LoanList( return LoanList(
loans=[LoanRead(**loan.model_dump()) for loan in loans], loans=[LoanRead(**loan.model_dump()) for loan in loans], total=total
total=total
) )
@@ -125,11 +129,12 @@ def get_loans_analytics(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Возвращает аналитику по выдачам и возвратам книг""" """Возвращает аналитику по выдачам и возвратам книг"""
end_date = datetime.utcnow() end_date = datetime.now(timezone.utc)
start_date = end_date - timedelta(days=days) start_date = end_date - timedelta(days=days)
total_loans = session.exec( total_loans = session.exec(
select(func.count(BookUserLink.id)) select(func.count(BookUserLink.id)).where(
.where(BookUserLink.borrowed_at >= start_date) BookUserLink.borrowed_at >= start_date
)
).one() ).one()
active_loans = session.exec( active_loans = session.exec(
@@ -156,7 +161,7 @@ def get_loans_analytics(
loans_by_date = session.exec( loans_by_date = session.exec(
select( select(
cast(BookUserLink.borrowed_at, Date).label("date"), cast(BookUserLink.borrowed_at, Date).label("date"),
func.count(BookUserLink.id).label("count") func.count(BookUserLink.id).label("count"),
) )
.where(BookUserLink.borrowed_at >= start_date) .where(BookUserLink.borrowed_at >= start_date)
.group_by(cast(BookUserLink.borrowed_at, Date)) .group_by(cast(BookUserLink.borrowed_at, Date))
@@ -166,9 +171,11 @@ def get_loans_analytics(
returns_by_date = session.exec( returns_by_date = session.exec(
select( select(
cast(BookUserLink.returned_at, Date).label("date"), cast(BookUserLink.returned_at, Date).label("date"),
func.count(BookUserLink.id).label("count") func.count(BookUserLink.id).label("count"),
)
.where(
BookUserLink.returned_at >= start_date # ty: ignore[unsupported-operator]
) )
.where(BookUserLink.returned_at >= start_date)
.where(BookUserLink.returned_at != None) # noqa: E711 .where(BookUserLink.returned_at != None) # noqa: E711
.group_by(cast(BookUserLink.returned_at, Date)) .group_by(cast(BookUserLink.returned_at, Date))
.order_by(cast(BookUserLink.returned_at, Date)) .order_by(cast(BookUserLink.returned_at, Date))
@@ -185,10 +192,7 @@ def get_loans_analytics(
daily_returns[date_str] = count daily_returns[date_str] = count
top_books = session.exec( top_books = session.exec(
select( select(BookUserLink.book_id, func.count(BookUserLink.id).label("loan_count"))
BookUserLink.book_id,
func.count(BookUserLink.id).label("loan_count")
)
.where(BookUserLink.borrowed_at >= start_date) .where(BookUserLink.borrowed_at >= start_date)
.group_by(BookUserLink.book_id) .group_by(BookUserLink.book_id)
.order_by(func.count(BookUserLink.id).desc()) .order_by(func.count(BookUserLink.id).desc())
@@ -201,38 +205,36 @@ def get_loans_analytics(
loan_count = row[1] if isinstance(row, tuple) else row.loan_count loan_count = row[1] if isinstance(row, tuple) else row.loan_count
book = session.get(Book, book_id) book = session.get(Book, book_id)
if book: if book:
top_books_data.append({ top_books_data.append(
"book_id": book_id, {"book_id": book_id, "title": book.title, "loan_count": loan_count}
"title": book.title, )
"loan_count": loan_count
})
reserved_count = session.exec( reserved_count = session.exec(
select(func.count(Book.id)) select(func.count(Book.id)).where(Book.status == BookStatus.RESERVED)
.where(Book.status == BookStatus.RESERVED)
).one() ).one()
borrowed_count = session.exec( borrowed_count = session.exec(
select(func.count(Book.id)) select(func.count(Book.id)).where(Book.status == BookStatus.BORROWED)
.where(Book.status == BookStatus.BORROWED)
).one() ).one()
return JSONResponse(content={ return JSONResponse(
"summary": { content={
"total_loans": total_loans, "summary": {
"active_loans": active_loans, "total_loans": total_loans,
"returned_loans": returned_loans, "active_loans": active_loans,
"overdue_loans": overdue_loans, "returned_loans": returned_loans,
"reserved_books": reserved_count, "overdue_loans": overdue_loans,
"borrowed_books": borrowed_count, "reserved_books": reserved_count,
}, "borrowed_books": borrowed_count,
"daily_loans": daily_loans, },
"daily_returns": daily_returns, "daily_loans": daily_loans,
"top_books": top_books_data, "daily_returns": daily_returns,
"period_days": days, "top_books": top_books_data,
"start_date": start_date.isoformat(), "period_days": days,
"end_date": end_date.isoformat(), "start_date": start_date.isoformat(),
}) "end_date": end_date.isoformat(),
}
)
@router.get( @router.get(
@@ -250,14 +252,15 @@ def get_loan(
loan = session.get(BookUserLink, loan_id) loan = session.get(BookUserLink, loan_id)
if not loan: if not loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user) is_staff = is_user_staff(current_user)
if not is_staff and loan.user_id != current_user.id: if not is_staff and loan.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this loan"
detail="Access denied to this loan"
) )
return LoanRead(**loan.model_dump()) return LoanRead(**loan.model_dump())
@@ -278,29 +281,35 @@ def update_loan(
"""Обновляет информацию о выдаче""" """Обновляет информацию о выдаче"""
db_loan = session.get(BookUserLink, loan_id) db_loan = session.get(BookUserLink, loan_id)
if not db_loan: if not db_loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user) is_staff = is_user_staff(current_user)
if not is_staff and db_loan.user_id != current_user.id: if not is_staff and db_loan.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You can only update your own loans" detail="You can only update your own loans",
) )
book = session.get(Book, db_loan.book_id) book = session.get(Book, db_loan.book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if loan_update.user_id is not None: if loan_update.user_id is not None:
if not is_staff: if not is_staff:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only staff can change loan user" detail="Only staff can change loan user",
) )
new_user = session.get(User, loan_update.user_id) new_user = session.get(User, loan_update.user_id)
if not new_user: if not new_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan.user_id = loan_update.user_id db_loan.user_id = loan_update.user_id
if loan_update.due_date is not None: if loan_update.due_date is not None:
@@ -309,8 +318,8 @@ def update_loan(
if loan_update.returned_at is not None: if loan_update.returned_at is not None:
if db_loan.returned_at is not None: if db_loan.returned_at is not None:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail="Loan is already returned" detail="Loan is already returned",
) )
db_loan.returned_at = loan_update.returned_at db_loan.returned_at = loan_update.returned_at
book.status = BookStatus.ACTIVE book.status = BookStatus.ACTIVE
@@ -337,19 +346,25 @@ def confirm_loan(
"""Подтверждает бронирование и меняет статус книги на BORROWED""" """Подтверждает бронирование и меняет статус книги на BORROWED"""
loan = session.get(BookUserLink, loan_id) loan = session.get(BookUserLink, loan_id)
if not loan: if not loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
if loan.returned_at: if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
)
book = session.get(Book, loan.book_id) book = session.get(Book, loan.book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]: if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot confirm loan for book with status: {book.status}" detail=f"Cannot confirm loan for book with status: {book.status}",
) )
book.status = BookStatus.BORROWED book.status = BookStatus.BORROWED
@@ -376,12 +391,16 @@ def return_loan(
"""Возвращает книгу и закрывает выдачу""" """Возвращает книгу и закрывает выдачу"""
loan = session.get(BookUserLink, loan_id) loan = session.get(BookUserLink, loan_id)
if not loan: if not loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
if loan.returned_at: if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
)
loan.returned_at = datetime.utcnow() loan.returned_at = datetime.now(timezone.utc)
book = session.get(Book, loan.book_id) book = session.get(Book, loan.book_id)
if book: if book:
@@ -409,22 +428,24 @@ def delete_loan(
"""Удаляет выдачу или бронирование (только для RESERVED статуса)""" """Удаляет выдачу или бронирование (только для RESERVED статуса)"""
loan = session.get(BookUserLink, loan_id) loan = session.get(BookUserLink, loan_id)
if not loan: if not loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user) is_staff = is_user_staff(current_user)
if not is_staff and loan.user_id != current_user.id: if not is_staff and loan.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You can only delete your own loans" detail="You can only delete your own loans",
) )
book = session.get(Book, loan.book_id) book = session.get(Book, loan.book_id)
if book and book.status != BookStatus.RESERVED: if book and book.status != BookStatus.RESERVED:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only delete reservations. Use update endpoint to return borrowed books" detail="Can only delete reservations. Use update endpoint to return borrowed books",
) )
loan_read = LoanRead(**loan.model_dump()) loan_read = LoanRead(**loan.model_dump())
@@ -477,23 +498,27 @@ def issue_book_directly(
"""Выдает книгу напрямую без бронирования (только для администраторов)""" """Выдает книгу напрямую без бронирования (только для администраторов)"""
book = session.get(Book, loan.book_id) book = session.get(Book, loan.book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status != BookStatus.ACTIVE: if book.status != BookStatus.ACTIVE:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Book is not available (status: {book.status})" detail=f"Book is not available (status: {book.status})",
) )
target_user = session.get(User, loan.user_id) target_user = session.get(User, loan.user_id)
if not target_user: if not target_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan = BookUserLink( db_loan = BookUserLink(
book_id=loan.book_id, book_id=loan.book_id,
user_id=loan.user_id, user_id=loan.user_id,
due_date=loan.due_date, due_date=loan.due_date,
borrowed_at=datetime.utcnow() borrowed_at=datetime.now(timezone.utc),
) )
book.status = BookStatus.BORROWED book.status = BookStatus.BORROWED
+66 -43
View File
@@ -1,4 +1,6 @@
"""Модуль прочих эндпоинтов""" """Модуль прочих эндпоинтов и веб-страниц"""
import os
import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -12,9 +14,12 @@ from sqlmodel import Session, select, func
from library_service.settings import get_app, get_session from library_service.settings import get_app, get_session
from library_service.models.db import Author, Book, Genre, User from library_service.models.db import Author, Book, Genre, User
from library_service.services import SchemaGenerator
from library_service import models
router = APIRouter(tags=["misc"]) router = APIRouter(tags=["misc"])
generator = SchemaGenerator(models.db, models.dto)
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
@@ -28,115 +33,117 @@ def get_info(app) -> Dict:
"description": app.description.rsplit("|", 1)[0], "description": app.description.rsplit("|", 1)[0],
}, },
"server_time": datetime.now().isoformat(), "server_time": datetime.now().isoformat(),
"domain": os.getenv("DOMAIN", ""),
} }
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
async def root(request: Request): async def root(request: Request, app=Depends(lambda: get_app())):
"""Рендерит главную страницу""" """Рендерит главную страницу"""
return templates.TemplateResponse(request, "index.html") return templates.TemplateResponse(request, "index.html", get_info(app) | {"request": request, "title": "LiB - Библиотека"})
@router.get("/unknown", include_in_schema=False)
async def unknown(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу 404 ошибки"""
return templates.TemplateResponse(request, "unknown.html", get_info(app) | {"request": request, "title": "LiB - Страница не найдена"})
@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, app=Depends(lambda: get_app())):
"""Рендерит страницу создания жанра""" """Рендерит страницу создания жанра"""
return templates.TemplateResponse(request, "create_genre.html") return templates.TemplateResponse(request, "create_genre.html", get_info(app) | {"request": request, "title": "LiB - Создать жанр"})
@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, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования жанра""" """Рендерит страницу редактирования жанра"""
return templates.TemplateResponse(request, "edit_genre.html") return templates.TemplateResponse(request, "edit_genre.html", get_info(app) | {"request": request, "title": "LiB - Редактировать жанр", "id": genre_id})
@router.get("/authors", include_in_schema=False) @router.get("/authors", include_in_schema=False)
async def authors(request: Request): async def authors(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу списка авторов""" """Рендерит страницу списка авторов"""
return templates.TemplateResponse(request, "authors.html") return templates.TemplateResponse(request, "authors.html", get_info(app) | {"request": request, "title": "LiB - Авторы"})
@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, app=Depends(lambda: get_app())):
"""Рендерит страницу создания автора""" """Рендерит страницу создания автора"""
return templates.TemplateResponse(request, "create_author.html") return templates.TemplateResponse(request, "create_author.html", get_info(app) | {"request": request, "title": "LiB - Создать автора"})
@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, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования автора""" """Рендерит страницу редактирования автора"""
return templates.TemplateResponse(request, "edit_author.html") return templates.TemplateResponse(request, "edit_author.html", get_info(app) | {"request": request, "title": "LiB - Редактировать автора", "id": author_id})
@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, app=Depends(lambda: get_app())):
"""Рендерит страницу просмотра автора""" """Рендерит страницу просмотра автора"""
return templates.TemplateResponse(request, "author.html") return templates.TemplateResponse(request, "author.html", get_info(app) | {"request": request, "title": "LiB - Автор", "id": author_id})
@router.get("/books", include_in_schema=False) @router.get("/books", include_in_schema=False)
async def books(request: Request): async def books(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу списка книг""" """Рендерит страницу списка книг"""
return templates.TemplateResponse(request, "books.html") return templates.TemplateResponse(request, "books.html", get_info(app) | {"request": request, "title": "LiB - Книги"})
@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, app=Depends(lambda: get_app())):
"""Рендерит страницу создания книги""" """Рендерит страницу создания книги"""
return templates.TemplateResponse(request, "create_book.html") return templates.TemplateResponse(request, "create_book.html", get_info(app) | {"request": request, "title": "LiB - Создать книгу"})
@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, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования книги""" """Рендерит страницу редактирования книги"""
return templates.TemplateResponse(request, "edit_book.html") return templates.TemplateResponse(request, "edit_book.html", get_info(app) | {"request": request, "title": "LiB - Редактировать книгу", "id": book_id})
@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, app=Depends(lambda: get_app()), session=Depends(get_session)):
"""Рендерит страницу просмотра книги""" """Рендерит страницу просмотра книги"""
return templates.TemplateResponse(request, "book.html") book = session.get(Book, book_id)
return templates.TemplateResponse(request, "book.html", get_info(app) | {"request": request, "title": "LiB - Книга", "id": book_id, "img": book.preview_id})
@router.get("/auth", include_in_schema=False) @router.get("/auth", include_in_schema=False)
async def auth(request: Request): async def auth(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу авторизации""" """Рендерит страницу авторизации"""
return templates.TemplateResponse(request, "auth.html") return templates.TemplateResponse(request, "auth.html", get_info(app) | {"request": request, "title": "LiB - Авторизация"})
@router.get("/set-2fa", include_in_schema=False) @router.get("/2fa", include_in_schema=False)
async def set2fa(request: Request): async def set2fa(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу установки двухфакторной аутентификации""" """Рендерит страницу установки двухфакторной аутентификации"""
return templates.TemplateResponse(request, "2fa.html") return templates.TemplateResponse(request, "2fa.html", get_info(app) | {"request": request, "title": "LiB - Двухфакторная аутентификация"})
@router.get("/profile", include_in_schema=False) @router.get("/profile", include_in_schema=False)
async def profile(request: Request): async def profile(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу профиля пользователя""" """Рендерит страницу профиля пользователя"""
return templates.TemplateResponse(request, "profile.html") return templates.TemplateResponse(request, "profile.html", get_info(app) | {"request": request, "title": "LiB - Профиль"})
@router.get("/users", include_in_schema=False) @router.get("/users", include_in_schema=False)
async def users(request: Request): async def users(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу управления пользователями""" """Рендерит страницу управления пользователями"""
return templates.TemplateResponse(request, "users.html") return templates.TemplateResponse(request, "users.html", get_info(app) | {"request": request, "title": "LiB - Пользователи"})
@router.get("/my-books", include_in_schema=False) @router.get("/my-books", include_in_schema=False)
async def my_books(request: Request): async def my_books(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу моих книг пользователя""" """Рендерит страницу моих книг пользователя"""
return templates.TemplateResponse(request, "my_books.html") return templates.TemplateResponse(request, "my_books.html", get_info(app) | {"request": request, "title": "LiB - Мои книги"})
@router.get("/analytics", include_in_schema=False) @router.get("/analytics", include_in_schema=False)
async def analytics(request: Request): async def analytics(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу аналитики выдач""" """Рендерит страницу аналитики выдач"""
return templates.TemplateResponse(request, "analytics.html") return templates.TemplateResponse(request, "analytics.html", get_info(app) | {"request": request, "title": "LiB - Аналитика"})
@router.get("/api", include_in_schema=False)
async def api(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу с ссылками на документацию API"""
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)
@@ -153,6 +160,12 @@ async def favicon():
) )
@router.get("/api", include_in_schema=False)
async def api(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу с ссылками на документацию API"""
return templates.TemplateResponse(request, "api.html", get_info(app))
@router.get( @router.get(
"/api/info", "/api/info",
summary="Информация о сервисе", summary="Информация о сервисе",
@@ -163,12 +176,22 @@ async def api_info(app=Depends(lambda: get_app())):
return JSONResponse(content=get_info(app)) return JSONResponse(content=get_info(app))
@router.get(
"/api/schema",
summary="Информация о таблицах и связях",
description="Возвращает схему базы данных с описаниями полей",
)
async def api_schema():
"""Возвращает информацию для создания er-диаграммы"""
return generator.generate()
@router.get( @router.get(
"/api/stats", "/api/stats",
summary="Статистика сервиса", summary="Статистика сервиса",
description="Возвращает статистическую информацию о системе", description="Возвращает статистическую информацию о системе",
) )
async def api_stats(session: Session = Depends(get_session)): async def api_stats(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)
+89 -35
View File
@@ -1,7 +1,8 @@
"""Модуль работы со связями""" """Модуль работы со связями"""
from typing import Dict, List from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireStaff from library_service.auth import RequireStaff
@@ -17,7 +18,9 @@ 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=status.HTTP_404_NOT_FOUND, detail=f"{entity_name} not found"
)
return entity return entity
@@ -30,7 +33,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
).first() ).first()
if existing_link: if existing_link:
raise HTTPException(status_code=400, detail=detail) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
link = link_model(**{field1: id1, field2: id2}) link = link_model(**{field1: id1, field2: id2})
session.add(link) session.add(link)
@@ -48,7 +51,9 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
).first() ).first()
if not link: if not link:
raise HTTPException(status_code=404, detail="Relationship not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Relationship not found"
)
session.delete(link) session.delete(link)
session.commit() session.commit()
@@ -56,21 +61,22 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
def get_related( def get_related(
session, session,
main_model, main_model,
main_id, main_id,
main_name, main_name,
related_model, related_model,
link_model, link_model,
link_main_field, link_main_field,
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(
select(related_model).join(link_model) select(related_model)
.join(link_model)
.where(getattr(link_model, link_main_field) == main_id) .where(getattr(link_model, link_main_field) == main_id)
).all() ).all()
@@ -93,8 +99,15 @@ def add_author_to_book(
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")
return add_relationship(session, AuthorBookLink, return add_relationship(
author_id, "author_id", book_id, "book_id", "Relationship already exists") session,
AuthorBookLink,
author_id,
"author_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete( @router.delete(
@@ -110,8 +123,9 @@ def remove_author_from_book(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Удаляет связь между автором и книгой""" """Удаляет связь между автором и книгой"""
return remove_relationship(session, AuthorBookLink, return remove_relationship(
author_id, "author_id", book_id, "book_id") session, AuthorBookLink, author_id, "author_id", book_id, "book_id"
)
@router.get( @router.get(
@@ -122,9 +136,17 @@ def remove_author_from_book(
) )
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(
Author, author_id, "Author", Book, session,
AuthorBookLink, "author_id", "book_id", BookRead) Author,
author_id,
"Author",
Book,
AuthorBookLink,
"author_id",
"book_id",
BookRead,
)
@router.get( @router.get(
@@ -135,9 +157,17 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
) )
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(
Book, book_id, "Book", Author, session,
AuthorBookLink, "book_id", "author_id", AuthorRead) Book,
book_id,
"Book",
Author,
AuthorBookLink,
"book_id",
"author_id",
AuthorRead,
)
@router.post( @router.post(
@@ -156,8 +186,15 @@ def add_genre_to_book(
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")
return add_relationship(session, GenreBookLink, return add_relationship(
genre_id, "genre_id", book_id, "book_id", "Relationship already exists") session,
GenreBookLink,
genre_id,
"genre_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete( @router.delete(
@@ -173,8 +210,9 @@ def remove_genre_from_book(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Удаляет связь между жанром и книгой""" """Удаляет связь между жанром и книгой"""
return remove_relationship(session, GenreBookLink, return remove_relationship(
genre_id, "genre_id", book_id, "book_id") session, GenreBookLink, genre_id, "genre_id", book_id, "book_id"
)
@router.get( @router.get(
@@ -185,9 +223,17 @@ def remove_genre_from_book(
) )
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(
Genre, genre_id, "Genre", Book, session,
GenreBookLink, "genre_id", "book_id", BookRead) Genre,
genre_id,
"Genre",
Book,
GenreBookLink,
"genre_id",
"book_id",
BookRead,
)
@router.get( @router.get(
@@ -198,6 +244,14 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
) )
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(
Book, book_id, "Book", Genre, session,
GenreBookLink, "book_id", "genre_id", GenreRead) Book,
book_id,
"Book",
Genre,
GenreBookLink,
"book_id",
"genre_id",
GenreRead,
)
+302
View File
@@ -0,0 +1,302 @@
"""Модуль управления пользователями (для администраторов)"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from library_service.models.db import Role, User
from library_service.models.dto import (
RoleRead,
RoleList,
UserRead,
UserList,
UserCreateByAdmin,
UserUpdateByAdmin,
)
from library_service.settings import get_session
from library_service.auth import (
RequireAuth,
RequireAdmin,
RequireStaff,
get_password_hash,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.get(
"/roles",
response_model=RoleList,
summary="Список ролей",
)
def get_roles(
auth: RequireAuth,
session: Session = Depends(get_session),
):
"""Возвращает список ролей в системе"""
user_roles = [role.name for role in auth.roles]
exclude = {"payroll"} if "admin" not in user_roles else set()
roles = session.exec(select(Role)).all()
return RoleList(
roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
total=len(roles),
)
@router.get(
"/",
response_model=UserList,
summary="Список пользователей",
)
def list_users(
current_user: RequireStaff,
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
):
"""Возвращает список всех пользователей"""
users = session.exec(select(User).offset(skip).limit(limit)).all()
total = session.exec(select(User)).all()
return UserList(
users=[
UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
for user in users
],
total=len(total),
)
@router.post(
"/",
response_model=UserRead,
status_code=status.HTTP_201_CREATED,
summary="Создать пользователя",
)
def create_user(
user_data: UserCreateByAdmin,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Создает пользователя (без резервных кодов)"""
if session.exec(select(User).where(User.username == user_data.username)).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
if session.exec(select(User).where(User.email == user_data.email)).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
db_user = User(
username=user_data.username,
email=user_data.email,
full_name=user_data.full_name,
hashed_password=get_password_hash(user_data.password),
is_active=user_data.is_active,
)
if user_data.roles:
for role_name in user_data.roles:
role = session.exec(select(Role).where(Role.name == role_name)).first()
if role:
db_user.roles.append(role)
else:
default_role = session.exec(select(Role).where(Role.name == "member")).first()
if default_role:
db_user.roles.append(default_role)
session.add(db_user)
session.commit()
session.refresh(db_user)
return UserRead(**db_user.model_dump(), roles=[r.name for r in db_user.roles])
@router.get(
"/{user_id}",
response_model=UserRead,
summary="Получить пользователя",
)
def get_user(
user_id: int,
current_user: RequireStaff,
session: Session = Depends(get_session),
):
"""Возвращает пользователя по ID"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.put(
"/{user_id}",
response_model=UserRead,
summary="Обновить пользователя",
)
def update_user(
user_id: int,
user_data: UserUpdateByAdmin,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Обновляет данные пользователя"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user_data.email and user_data.email != user.email:
existing = session.exec(
select(User).where(User.email == user_data.email)
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user.email = user_data.email
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.password:
user.hashed_password = get_password_hash(user_data.password)
if user_data.is_active is not None:
user.is_active = user_data.is_active
if user_data.roles is not None:
user.roles.clear()
for role_name in user_data.roles:
role = session.exec(select(Role).where(Role.name == role_name)).first()
if role:
user.roles.append(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.delete(
"/{user_id}",
response_model=UserRead,
summary="Удалить пользователя",
)
def delete_user(
user_id: int,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Деактивирует пользователя, при повторном вызове — удаляет физически"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user.is_active:
user.is_active = False
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
else:
user_read = UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
session.delete(user)
session.commit()
return user_read
@router.post(
"/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Назначить роль пользователю",
)
def add_role_to_user(
user_id: int,
role_name: str,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Добавляет роль пользователю"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already has this role",
)
user.roles.append(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.delete(
"/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Удалить роль у пользователя",
)
def remove_role_from_user(
user_id: int,
role_name: str,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Удаляет роль у пользователя"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role not in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User does not have this role",
)
user.roles.remove(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
+33
View File
@@ -0,0 +1,33 @@
from .captcha import (
limiter,
cleanup_task,
get_ip,
require_captcha,
active_challenges,
redeem_tokens,
challenges_by_ip,
MAX_CHALLENGES_PER_IP,
MAX_TOTAL_CHALLENGES,
CHALLENGE_TTL,
REDEEM_TTL,
prng,
)
from .describe_er import SchemaGenerator
from .image_processing import transcode_image
__all__ = [
"limiter",
"cleanup_task",
"get_ip",
"require_captcha",
"active_challenges",
"redeem_tokens",
"challenges_by_ip",
"MAX_CHALLENGES_PER_IP",
"MAX_TOTAL_CHALLENGES",
"CHALLENGE_TTL",
"REDEEM_TTL",
"prng",
"SchemaGenerator",
"transcode_image",
]
+77
View File
@@ -0,0 +1,77 @@
"""Модуль создания и проверки capjs"""
import os
import asyncio
import hashlib
import secrets
import time
from collections import defaultdict
from fastapi import Request, HTTPException, Depends, status
from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
CLEANUP_INTERVAL = int(os.getenv("CAP_CLEANUP_INTERVAL", "10"))
REDEEM_TTL = int(os.getenv("CAP_REDEEM_TTL_SECONDS", "180")) * 1000
CHALLENGE_TTL = int(os.getenv("CAP_CHALLENGE_TTL_SECONDS", "120")) * 1000
MAX_CHALLENGES_PER_IP = int(os.getenv("CAP_MAX_CHALLENGES_PER_IP", "12"))
MAX_TOTAL_CHALLENGES = int(os.getenv("CAP_MAX_TOTAL_CHALLENGES", "1000"))
active_challenges: dict[str, dict] = {}
redeem_tokens: dict[str, int] = {}
challenges_by_ip: defaultdict[str, int] = defaultdict(int)
limiter = Limiter(key_func=get_remote_address)
def now_ms() -> int:
return int(time.time() * 1000)
def fnv1a_utf16(seed: str) -> int:
h = 2166136261
data = seed.encode("utf-16le")
i = 0
while i < len(data):
unit = data[i] + (data[i + 1] << 8)
h ^= unit
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & 0xFFFFFFFF
i += 2
return h
def prng(seed: str, length: int) -> str:
state = fnv1a_utf16(seed)
out = ""
while len(out) < length:
state ^= (state << 13) & 0xFFFFFFFF
state ^= state >> 17
state ^= (state << 5) & 0xFFFFFFFF
out += f"{state & 0xFFFFFFFF:08x}"
return out[:length]
async def cleanup_task():
while True:
now = now_ms()
for token, data in list(active_challenges.items()):
if data["expires"] < now:
challenges_by_ip[data["ip"]] -= 1
del active_challenges[token]
for token, exp in list(redeem_tokens.items()):
if exp < now:
del redeem_tokens[token]
await asyncio.sleep(CLEANUP_INTERVAL)
def get_ip(request: Request) -> str:
return get_remote_address(request)
async def require_captcha(request: Request):
token = request.cookies.get("capjs_token")
if not token or token not in redeem_tokens or redeem_tokens[token] < now_ms():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail={"error": "captcha_required"}
)
del redeem_tokens[token]
+283
View File
@@ -0,0 +1,283 @@
"""Модуль генерации описания схемы БД"""
import enum
import inspect
from typing import (
List,
Dict,
Any,
Set,
Type,
Tuple,
Optional,
Union,
get_origin,
get_args,
)
from sqlalchemy import Enum as SAEnum
from sqlalchemy.inspection import inspect as sa_inspect
from sqlmodel import SQLModel
class SchemaGenerator:
"""Сервис генерации json описания схемы БД"""
def __init__(self, db_module, dto_module=None):
self.db_models = self._get_classes(db_module, is_table=True)
self.dto_models = (
self._get_classes(dto_module, is_table=False) if dto_module else []
)
self.link_table_names = self._identify_link_tables()
self.field_descriptions = self._collect_all_descriptions()
self._table_to_model = {m.__tablename__: m for m in self.db_models}
def _get_classes(
self, module, is_table: bool | None = None
) -> List[Type[SQLModel]]:
if module is None:
return []
classes = []
for name, obj in inspect.getmembers(module):
if (
inspect.isclass(obj)
and issubclass(obj, SQLModel)
and obj is not SQLModel
):
if is_table is True and hasattr(obj, "__table__"):
classes.append(obj)
elif is_table is False and not hasattr(obj, "__table__"):
classes.append(obj)
return classes
def _normalize_model_name(self, name: str) -> str:
suffixes = [
"Create",
"Read",
"Update",
"DTO",
"Base",
"List",
"Detail",
"Response",
"Request",
]
result = name
for suffix in suffixes:
if result.endswith(suffix) and len(result) > len(suffix):
result = result[: -len(suffix)]
return result
def _get_field_descriptions_from_class(self, cls: Type) -> Dict[str, str]:
descriptions = {}
for parent in cls.__mro__:
if parent is SQLModel or parent is object:
continue
fields = getattr(parent, "model_fields", {})
for field_name, field_info in fields.items():
if field_name in descriptions:
continue
desc = getattr(field_info, "description", None) or getattr(
field_info, "title", None
)
if desc:
descriptions[field_name] = desc
return descriptions
def _collect_all_descriptions(self) -> Dict[str, Dict[str, str]]:
result = {}
dto_map = {}
for dto in self.dto_models:
base_name = self._normalize_model_name(dto.__name__)
if base_name not in dto_map:
dto_map[base_name] = {}
for field, desc in self._get_field_descriptions_from_class(dto).items():
if field not in dto_map[base_name]:
dto_map[base_name][field] = desc
for model in self.db_models:
model_name = model.__name__
result[model_name] = {
**dto_map.get(model_name, {}),
**self._get_field_descriptions_from_class(model),
}
return result
def _identify_link_tables(self) -> Set[str]:
link_tables = set()
for model in self.db_models:
try:
for rel in sa_inspect(model).relationships:
if rel.secondary is not None:
link_tables.add(rel.secondary.name)
except Exception:
continue
return link_tables
def _collect_fk_relations(self) -> List[Dict[str, Any]]:
relations = []
processed: Set[Tuple[str, str, str, str]] = set()
for model in self.db_models:
if model.__tablename__ in self.link_table_names:
continue
for col in sa_inspect(model).columns:
for fk in col.foreign_keys:
target_table = fk.column.table.name
if target_table in self.link_table_names:
continue
target_model = self._table_to_model.get(target_table)
if not target_model:
continue
key = (
model.__name__,
col.name,
target_model.__name__,
fk.column.name,
)
if key not in processed:
relations.append(
{
"fromEntity": model.__name__,
"fromField": col.name,
"toEntity": target_model.__name__,
"toField": fk.column.name,
"fromMultiplicity": "N",
"toMultiplicity": "1",
}
)
processed.add(key)
return relations
def _collect_m2m_relations(self) -> List[Dict[str, Any]]:
relations = []
processed: Set[Tuple[str, str]] = set()
for model in self.db_models:
if model.__tablename__ in self.link_table_names:
continue
try:
for rel in sa_inspect(model).relationships:
if rel.direction.name != "MANYTOMANY":
continue
target_model = rel.mapper.class_
if target_model.__tablename__ in self.link_table_names:
continue
pair = tuple(sorted([model.__name__, target_model.__name__]))
if pair not in processed:
relations.append(
{
"fromEntity": pair[0],
"fromField": "id",
"toEntity": pair[1],
"toField": "id",
"fromMultiplicity": "N",
"toMultiplicity": "N",
}
)
processed.add(pair)
except Exception:
continue
return relations
def _extract_enum_from_annotation(self, annotation) -> Optional[Type[enum.Enum]]:
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
return annotation
origin = get_origin(annotation)
if origin is Union:
for arg in get_args(annotation):
if isinstance(arg, type) and issubclass(arg, enum.Enum):
return arg
return None
def _get_enum_values(self, model: Type[SQLModel], col) -> Optional[List[str]]:
if isinstance(col.type, SAEnum):
if col.type.enum_class is not None:
return [e.value for e in col.type.enum_class]
if col.type.enums:
return list(col.type.enums)
try:
annotations = {}
for cls in model.__mro__:
if hasattr(cls, "__annotations__"):
annotations.update(cls.__annotations__)
if col.name in annotations:
annotation = annotations[col.name]
enum_class = self._extract_enum_from_annotation(annotation)
if enum_class:
return [e.value for e in enum_class]
except Exception:
pass
return None
def generate(self) -> Dict[str, Any]:
entities = []
for model in self.db_models:
table_name = model.__tablename__
if table_name in self.link_table_names:
continue
columns = sorted(
sa_inspect(model).columns,
key=lambda c: (
0 if c.primary_key else (1 if c.foreign_keys else 2),
c.name,
),
)
entity_fields = []
descriptions = self.field_descriptions.get(model.__name__, {})
for col in columns:
label = col.name
if col.primary_key:
label += " (PK)"
if col.foreign_keys:
label += " (FK)"
field_obj = {"id": col.name, "label": label}
tooltip_parts = []
if col.name in descriptions:
tooltip_parts.append(descriptions[col.name])
enum_values = self._get_enum_values(model, col)
if enum_values:
tooltip_parts.append(
"Варианты:\n" + "\n".join(f"{v}" for v in enum_values)
)
if tooltip_parts:
field_obj["tooltip"] = "\n\n".join(tooltip_parts)
entity_fields.append(field_obj)
entities.append(
{"id": model.__name__, "title": table_name, "fields": entity_fields}
)
relations = self._collect_fk_relations() + self._collect_m2m_relations()
return {"entities": entities, "relations": relations}
@@ -0,0 +1,81 @@
from pathlib import Path
from PIL import Image
TARGET_RATIO = 5 / 7
def crop_image(img: Image.Image, target_ratio: float = TARGET_RATIO) -> Image.Image:
w, h = img.size
current_ratio = w / h
if current_ratio > target_ratio:
new_w = int(h * target_ratio)
left = (w - new_w) // 2
right = left + new_w
top = 0
bottom = h
else:
new_h = int(w / target_ratio)
top = (h - new_h) // 2
bottom = top + new_h
left = 0
right = w
return img.crop((left, top, right, bottom))
def transcode_image(
src_path: str | Path,
*,
jpeg_quality: int = 85,
webp_quality: int = 80,
webp_lossless: bool = False,
resize_to: tuple[int, int] | None = None,
):
src_path = Path(src_path)
if not src_path.exists():
raise FileNotFoundError(src_path)
stem = src_path.stem
folder = src_path.parent
img = Image.open(src_path).convert("RGBA")
img = crop_image(img)
if resize_to:
img = img.resize(resize_to, Image.LANCZOS)
png_path = folder / f"{stem}.png"
img.save(
png_path,
format="PNG",
optimize=True,
interlace=1,
)
jpg_path = folder / f"{stem}.jpg"
img.convert("RGB").save(
jpg_path,
format="JPEG",
quality=jpeg_quality,
progressive=True,
optimize=True,
subsampling="4:2:0",
)
webp_path = folder / f"{stem}.webp"
img.save(
webp_path,
format="WEBP",
quality=webp_quality,
lossless=webp_lossless,
method=6,
)
return {
"png": png_path,
"jpeg": jpg_path,
"webp": webp_path,
}
+8 -1
View File
@@ -10,6 +10,9 @@ from toml import load
load_dotenv() load_dotenv()
BOOKS_PREVIEW_DIR = Path(__file__).parent / "static" / "books"
BOOKS_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
with open("pyproject.toml", "r", encoding="utf-8") as f: with open("pyproject.toml", "r", encoding="utf-8") as f:
_pyproject = load(f) _pyproject = load(f)
@@ -60,6 +63,8 @@ OPENAPI_TAGS = [
{"name": "genres", "description": "Действия с жанрами."}, {"name": "genres", "description": "Действия с жанрами."},
{"name": "loans", "description": "Действия с выдачами."}, {"name": "loans", "description": "Действия с выдачами."},
{"name": "relations", "description": "Действия со связями."}, {"name": "relations", "description": "Действия со связями."},
{"name": "users", "description": "Действия с пользователями."},
{"name": "captcha", "description": "Создание и проверка cap.js каптчи."},
{"name": "misc", "description": "Прочие."}, {"name": "misc", "description": "Прочие."},
] ]
@@ -93,7 +98,9 @@ USER = os.getenv("POSTGRES_USER")
PASSWORD = os.getenv("POSTGRES_PASSWORD") PASSWORD = os.getenv("POSTGRES_PASSWORD")
DATABASE = os.getenv("POSTGRES_DB") DATABASE = os.getenv("POSTGRES_DB")
if not all([HOST, PORT, USER, PASSWORD, DATABASE]): OLLAMA_URL = os.getenv("OLLAMA_URL")
if not all([HOST, PORT, USER, PASSWORD, DATABASE, OLLAMA_URL]):
raise ValueError("Missing required POSTGRES environment variables") raise ValueError("Missing required POSTGRES environment variables")
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}" POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
-152
View File
@@ -1,152 +0,0 @@
$(() => {
$("#login-tab").on("click", function () {
$(this)
.removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
$("#register-tab")
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
.addClass("text-gray-400 hover:text-gray-600");
$("#login-form").removeClass("hidden");
$("#register-form").addClass("hidden");
});
$("#register-tab").on("click", function () {
$(this)
.removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
$("#login-tab")
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
.addClass("text-gray-400 hover:text-gray-600");
$("#register-form").removeClass("hidden");
$("#login-form").addClass("hidden");
});
$("body").on("click", ".toggle-password", function () {
const $btn = $(this);
const $input = $btn.siblings("input");
const isPassword = $input.attr("type") === "password";
$input.attr("type", isPassword ? "text" : "password");
$btn.find("svg").toggleClass("hidden");
});
$("#register-password").on("input", function () {
const password = $(this).val();
let strength = 0;
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
const levels = [
{ width: "0%", color: "", text: "" },
{ width: "20%", color: "bg-red-500", text: "Очень слабый" },
{ width: "40%", color: "bg-orange-500", text: "Слабый" },
{ width: "60%", color: "bg-yellow-500", text: "Средний" },
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
{ width: "100%", color: "bg-green-500", text: "Отличный" },
];
const level = levels[strength];
const $bar = $("#password-strength-bar");
$bar.css("width", level.width);
$bar.attr("class", "h-full transition-all duration-300 " + level.color);
$("#password-strength-text").text(level.text);
checkPasswordMatch();
});
function checkPasswordMatch() {
const password = $("#register-password").val();
const confirm = $("#register-password-confirm").val();
const $error = $("#password-match-error");
if (confirm && password !== confirm) {
$error.removeClass("hidden");
return false;
} else {
$error.addClass("hidden");
return true;
}
}
$("#register-password-confirm").on("input", checkPasswordMatch);
$("#login-form").on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $("#login-submit");
const username = $("#login-username").val();
const password = $("#login-password").val();
const rememberMe = $("#remember-me").prop("checked");
$submitBtn.prop("disabled", true).text("Вход...");
try {
const formData = new URLSearchParams();
formData.append("username", username);
formData.append("password", password);
const data = await Api.postForm("/api/auth/token", formData);
const storage = rememberMe ? localStorage : sessionStorage;
storage.setItem("access_token", data.access_token);
if (rememberMe && data.refresh_token) {
storage.setItem("refresh_token", data.refresh_token);
}
const otherStorage = rememberMe ? sessionStorage : localStorage;
otherStorage.removeItem("access_token");
otherStorage.removeItem("refresh_token");
window.location.href = "/";
} catch (error) {
Utils.showToast(error.message || "Ошибка входа", "error");
} finally {
$submitBtn.prop("disabled", false).text("Войти");
}
});
$("#register-form").on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $("#register-submit");
const pass = $("#register-password").val();
const confirm = $("#register-password-confirm").val();
if (pass !== confirm) {
Utils.showToast("Пароли не совпадают", "error");
return;
}
const userData = {
username: $("#register-username").val(),
email: $("#register-email").val(),
full_name: $("#register-fullname").val() || null,
password: pass,
};
$submitBtn.prop("disabled", true).text("Регистрация...");
try {
await Api.post("/api/auth/register", userData);
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
setTimeout(() => {
$("#login-tab").trigger("click");
$("#login-username").val(userData.username);
}, 1500);
} catch (error) {
let msg = error.message;
if (Array.isArray(error.detail)) {
msg = error.detail.map((e) => e.msg).join(". ");
}
Utils.showToast(msg || "Ошибка регистрации", "error");
} finally {
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
}
});
});
-413
View File
@@ -1,413 +0,0 @@
$(document).ready(() => {
const STATUS_CONFIG = {
active: {
label: "Доступна",
bgClass: "bg-green-100",
textClass: "text-green-800",
},
borrowed: {
label: "Выдана",
bgClass: "bg-yellow-100",
textClass: "text-yellow-800",
},
reserved: {
label: "Забронирована",
bgClass: "bg-blue-100",
textClass: "text-blue-800",
},
restoration: {
label: "На реставрации",
bgClass: "bg-orange-100",
textClass: "text-orange-800",
},
written_off: {
label: "Списана",
bgClass: "bg-red-100",
textClass: "text-red-800",
},
};
function getStatusConfig(status) {
return (
STATUS_CONFIG[status] || {
label: status || "Неизвестно",
bgClass: "bg-gray-100",
textClass: "text-gray-800",
}
);
}
let selectedAuthors = new Map();
let selectedGenres = new Map();
let currentPage = 1;
let pageSize = 12;
let totalBooks = 0;
const urlParams = new URLSearchParams(window.location.search);
const genreIdsFromUrl = urlParams.getAll("genre_id");
const authorIdsFromUrl = urlParams.getAll("author_id");
const searchFromUrl = urlParams.get("q");
if (searchFromUrl) $("#book-search-input").val(searchFromUrl);
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
.then(([authorsData, genresData]) => {
initAuthors(authorsData.authors);
initGenres(genresData.genres);
initializeAuthorDropdownListeners();
renderChips();
loadBooks();
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки данных", "error");
});
function initAuthors(authors) {
const $dropdown = $("#author-dropdown");
authors.forEach((author) => {
$("<div>")
.addClass(
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors",
)
.attr("data-id", author.id)
.attr("data-name", author.name)
.text(author.name)
.appendTo($dropdown);
if (authorIdsFromUrl.includes(String(author.id))) {
selectedAuthors.set(author.id, author.name);
}
});
}
function initGenres(genres) {
const $list = $("#genres-list");
genres.forEach((genre) => {
const isChecked = genreIdsFromUrl.includes(String(genre.id));
if (isChecked) selectedGenres.set(genre.id, genre.name);
const editButton = window.canManage()
? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
</a>`
: "";
$list.append(`
<li class="mb-1">
<div class="flex items-center">
<label class="custom-checkbox flex items-center flex-1">
<input type="checkbox" data-id="${genre.id}" data-name="${Utils.escapeHtml(genre.name)}" ${isChecked ? "checked" : ""} />
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)}
</label>
${editButton}
</div>
</li>
`);
});
$list.on("change", "input", function () {
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
});
$list.on("change", "input", function () {
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
});
}
function loadBooks() {
const searchQuery = $("#book-search-input").val().trim();
const params = new URLSearchParams();
params.append("q", searchQuery);
selectedAuthors.forEach((_, id) => params.append("author_ids", id));
selectedGenres.forEach((_, id) => params.append("genre_ids", id));
const browserParams = new URLSearchParams();
browserParams.append("q", searchQuery);
selectedAuthors.forEach((_, id) => browserParams.append("author_id", id));
selectedGenres.forEach((_, id) => browserParams.append("genre_id", id));
const newUrl =
window.location.pathname +
(browserParams.toString() ? `?${browserParams.toString()}` : "");
window.history.replaceState({}, "", newUrl);
params.append("page", currentPage);
params.append("size", pageSize);
showLoadingState();
Api.get(`/api/books/filter?${params.toString()}`)
.then((data) => {
totalBooks = data.total;
renderBooks(data.books);
renderPagination();
})
.catch((error) => {
console.error(error);
Utils.showToast("Не удалось загрузить книги", "error");
$("#books-container").html(
document.getElementById("empty-state-template").innerHTML,
);
});
}
function renderBooks(books) {
const $container = $("#books-container");
const tpl = document.getElementById("book-card-template");
const emptyTpl = document.getElementById("empty-state-template");
const badgeTpl = document.getElementById("genre-badge-template");
$container.empty();
if (books.length === 0) {
$container.append(emptyTpl.content.cloneNode(true));
return;
}
books.forEach((book) => {
const clone = tpl.content.cloneNode(true);
const card = clone.querySelector(".book-card");
card.dataset.id = book.id;
clone.querySelector(".book-title").textContent = book.title;
clone.querySelector(".book-authors").textContent =
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
clone.querySelector(".book-desc").textContent = book.description || "";
const statusConfig = getStatusConfig(book.status);
const statusEl = clone.querySelector(".book-status");
statusEl.textContent = statusConfig.label;
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
const genresContainer = clone.querySelector(".book-genres");
book.genres.forEach((g) => {
const badge = badgeTpl.content.cloneNode(true);
const span = badge.querySelector("span");
span.textContent = g.name;
genresContainer.appendChild(badge);
});
$container.append(clone);
});
}
function renderPagination() {
$("#pagination-container").empty();
const totalPages = Math.ceil(totalBooks / pageSize);
if (totalPages <= 1) return;
const $pagination = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => {
if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2 text-gray-500">...</span>`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
`);
}
});
$("#pagination-container").append($pagination);
$("#prev-page").on("click", function () {
if (currentPage > 1) {
currentPage--;
loadBooks();
scrollToTop();
}
});
$("#next-page").on("click", function () {
if (currentPage < totalPages) {
currentPage++;
loadBooks();
scrollToTop();
}
});
$(".page-btn").on("click", function () {
const page = parseInt($(this).data("page"));
if (page !== currentPage) {
currentPage = page;
loadBooks();
scrollToTop();
}
});
}
function generatePageNumbers(current, total) {
const pages = [];
const delta = 2;
for (let i = 1; i <= total; i++) {
if (
i === 1 ||
i === total ||
(i >= current - delta && i <= current + delta)
) {
pages.push(i);
} else if (pages[pages.length - 1] !== "...") {
pages.push("...");
}
}
return pages;
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
function showLoadingState() {
$("#books-container").html(`
<div class="space-y-4">
${Array(3)
.fill()
.map(
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
</div>
`,
)
.join("")}
</div>
`);
}
function renderChips() {
const $container = $("#selected-authors-container");
const $dropdown = $("#author-dropdown");
$container.empty();
selectedAuthors.forEach((name, id) => {
$(`<span class="author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).appendTo($container);
});
$dropdown.find(".author-item").each(function () {
const id = parseInt($(this).data("id"));
if (selectedAuthors.has(id)) {
$(this)
.addClass("bg-gray-200 text-gray-900 font-semibold")
.removeClass("hover:bg-gray-100");
} else {
$(this)
.removeClass("bg-gray-200 text-gray-900 font-semibold")
.addClass("hover:bg-gray-100");
}
});
}
function initializeAuthorDropdownListeners() {
const $input = $("#author-search-input");
const $dropdown = $("#author-dropdown");
const $container = $("#selected-authors-container");
$input.on("focus", function () {
$dropdown.removeClass("hidden");
});
$input.on("input", function () {
const val = $(this).val().toLowerCase();
$dropdown.removeClass("hidden");
$dropdown.find(".author-item").each(function () {
const text = $(this).text().toLowerCase();
$(this).toggle(text.includes(val));
});
});
$(document).on("click", function (e) {
if (
!$(e.target).closest(
"#author-search-input, #author-dropdown, #selected-authors-container",
).length
) {
$dropdown.addClass("hidden");
}
});
$dropdown.on("click", ".author-item", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
if (selectedAuthors.has(id)) {
selectedAuthors.delete(id);
} else {
selectedAuthors.set(id, name);
}
$input.val("");
$dropdown.find(".author-item").show();
renderChips();
$input[0].focus();
});
$container.on("click", ".remove-author", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
selectedAuthors.delete(id);
renderChips();
});
}
$("#books-container").on("click", ".book-card", function () {
window.location.href = `/book/${$(this).data("id")}`;
});
$("#apply-filters-btn").on("click", function () {
currentPage = 1;
loadBooks();
});
$("#reset-filters-btn").on("click", function () {
$("#book-search-input").val("");
selectedAuthors.clear();
selectedGenres.clear();
$("#genres-list input").prop("checked", false);
renderChips();
currentPage = 1;
loadBooks();
});
$("#book-search-input").on("keypress", function (e) {
if (e.which === 13) {
currentPage = 1;
loadBooks();
}
});
function showAdminControls() {
if (window.canManage()) {
$("#admin-actions").removeClass("hidden");
}
}
showAdminControls();
setTimeout(showAdminControls, 100);
});
@@ -177,8 +177,10 @@ $(async () => {
$msg.text("").attr("class", "mb-4 text-center text-sm min-h-[20px]"); $msg.text("").attr("class", "mb-4 text-center text-sm min-h-[20px]");
try { try {
await Api.post("/api/auth/2fa/verify", { await Api.post("/api/auth/2fa/enable", {
code: code, data: {
code: code,
},
secret: secretKey, secret: secretKey,
}); });
@@ -1,7 +1,7 @@
$(document).ready(() => { $(document).ready(() => {
if (!window.isAdmin()) { if (!window.isAdmin()) {
$(".container").html( $(".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>' '<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; return;
} }
@@ -45,21 +45,28 @@ $(document).ready(() => {
} }
function renderCharts(data) { function renderCharts(data) {
// Подготовка данных для графиков
const startDate = new Date(data.start_date); const startDate = new Date(data.start_date);
const endDate = new Date(data.end_date); const endDate = new Date(data.end_date);
const dates = []; const dates = [];
const loansData = []; const loansData = [];
const returnsData = []; const returnsData = [];
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { for (
let d = new Date(startDate);
d <= endDate;
d.setDate(d.getDate() + 1)
) {
const dateStr = d.toISOString().split("T")[0]; const dateStr = d.toISOString().split("T")[0];
dates.push(new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" })); dates.push(
new Date(d).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
}),
);
loansData.push(data.daily_loans[dateStr] || 0); loansData.push(data.daily_loans[dateStr] || 0);
returnsData.push(data.daily_returns[dateStr] || 0); returnsData.push(data.daily_returns[dateStr] || 0);
} }
// График выдач
const loansCtx = document.getElementById("loans-chart"); const loansCtx = document.getElementById("loans-chart");
if (loansChart) { if (loansChart) {
loansChart.destroy(); loansChart.destroy();
@@ -141,7 +148,6 @@ $(document).ready(() => {
}, },
}); });
// График возвратов
const returnsCtx = document.getElementById("returns-chart"); const returnsCtx = document.getElementById("returns-chart");
if (returnsChart) { if (returnsChart) {
returnsChart.destroy(); returnsChart.destroy();
@@ -230,7 +236,7 @@ $(document).ready(() => {
if (!topBooks || topBooks.length === 0) { if (!topBooks || topBooks.length === 0) {
$container.html( $container.html(
'<div class="text-center text-gray-500 py-8">Нет данных</div>' '<div class="text-center text-gray-500 py-8">Нет данных</div>',
); );
return; return;
} }
@@ -259,4 +265,3 @@ $(document).ready(() => {
}); });
} }
}); });
+598
View File
@@ -0,0 +1,598 @@
$(() => {
const SELECTORS = {
loginForm: "#login-form",
registerForm: "#register-form",
resetForm: "#reset-password-form",
authTabs: "#auth-tabs",
loginTab: "#login-tab",
registerTab: "#register-tab",
forgotBtn: "#forgot-password-btn",
backToLoginBtn: "#back-to-login-btn",
backToCredentialsBtn: "#back-to-credentials-btn",
submitLogin: "#login-submit",
submitRegister: "#register-submit",
submitReset: "#reset-submit",
usernameLogin: "#login-username",
passwordLogin: "#login-password",
totpInput: "#login-totp",
rememberMe: "#remember-me",
credentialsSection: "#credentials-section",
totpSection: "#totp-section",
registerUsername: "#register-username",
registerEmail: "#register-email",
registerFullname: "#register-fullname",
registerPassword: "#register-password",
registerConfirm: "#register-password-confirm",
passwordStrengthBar: "#password-strength-bar",
passwordStrengthText: "#password-strength-text",
passwordMatchError: "#password-match-error",
resetUsername: "#reset-username",
resetCode: "#reset-recovery-code",
resetNewPassword: "#reset-new-password",
resetConfirmPassword: "#reset-confirm-password",
resetMatchError: "#reset-password-match-error",
recoveryModal: "#recovery-codes-modal",
recoveryList: "#recovery-codes-list",
codesSavedCheckbox: "#codes-saved-checkbox",
closeRecoveryBtn: "#close-recovery-modal-btn",
copyCodesBtn: "#copy-codes-btn",
downloadCodesBtn: "#download-codes-btn",
gotoLoginAfterReset: "#goto-login-after-reset",
capWidget: "#cap",
lockProgressCircle: "#lock-progress-circle",
};
const STORAGE_KEYS = {
partialToken: "partial_token",
partialUsername: "partial_username",
};
const TEXTS = {
login: "Войти",
confirm: "Подтвердить",
checking: "Проверка...",
registering: "Регистрация...",
resetting: "Сброс...",
enterTotp: "Введите код из приложения аутентификатора",
sessionExpired: "Время сессии истекло. Пожалуйста, войдите заново.",
invalidCode: "Неверный код",
passwordsNotMatch: "Пароли не совпадают",
captchaRequired: "Пожалуйста, пройдите проверку Captcha",
registrationSuccess: "Регистрация успешна! Войдите в систему.",
codesCopied: "Коды скопированы в буфер обмена",
codesDownloaded: "Файл с кодами скачан",
passwordResetSuccess: "Пароль успешно изменён!",
invalidRecoveryCode: "Неверный формат резервного кода",
passwordTooShort: "Пароль должен содержать минимум 8 символов",
};
const TOTP_PERIOD = 30;
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38;
let loginState = {
step: "credentials",
partialToken: null,
username: "",
rememberMe: false,
};
let registeredRecoveryCodes = [];
let totpAnimationFrame = null;
const getTotpProgress = () => {
const now = Date.now() / 1000;
const elapsed = now % TOTP_PERIOD;
return elapsed / TOTP_PERIOD;
};
const updateTotpTimer = () => {
const circle = $(SELECTORS.lockProgressCircle).get(0);
if (!circle) return;
const progress = getTotpProgress();
const offset = CIRCLE_CIRCUMFERENCE * (1 - progress);
circle.style.strokeDashoffset = offset;
totpAnimationFrame = requestAnimationFrame(updateTotpTimer);
};
const startTotpTimer = () => {
stopTotpTimer();
updateTotpTimer();
};
const stopTotpTimer = () => {
if (totpAnimationFrame) {
cancelAnimationFrame(totpAnimationFrame);
totpAnimationFrame = null;
}
};
const resetCircle = () => {
const circle = $(SELECTORS.lockProgressCircle).get(0);
if (circle) circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE;
};
const savePartialToken = (token, username) => {
sessionStorage.setItem(STORAGE_KEYS.partialToken, token);
sessionStorage.setItem(STORAGE_KEYS.partialUsername, username);
};
const clearPartialToken = () => {
sessionStorage.removeItem(STORAGE_KEYS.partialToken);
sessionStorage.removeItem(STORAGE_KEYS.partialUsername);
};
const showForm = (formId) => {
let newHash = "";
if (formId === SELECTORS.loginForm) newHash = "login";
else if (formId === SELECTORS.registerForm) newHash = "register";
else if (formId === SELECTORS.resetForm) newHash = "reset";
if (newHash && window.location.hash !== "#" + newHash) {
window.history.pushState(null, null, "#" + newHash);
}
$(
`${SELECTORS.loginForm}, ${SELECTORS.registerForm}, ${SELECTORS.resetForm}`,
).addClass("hidden");
$(formId).removeClass("hidden");
$(`${SELECTORS.loginTab}, ${SELECTORS.registerTab}`)
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
.addClass("text-gray-400 hover:text-gray-600");
if (formId === SELECTORS.loginForm) {
$(SELECTORS.loginTab)
.removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
resetLoginState();
} else if (formId === SELECTORS.registerForm) {
$(SELECTORS.registerTab)
.removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
}
};
const handleHash = () => {
const hash = window.location.hash.toLowerCase();
if (hash === "#register" || hash === "#signup") {
showForm(SELECTORS.registerForm);
$(SELECTORS.registerTab).trigger("click");
} else if (hash === "#login" || hash === "#signin") {
showForm(SELECTORS.loginForm);
$(SELECTORS.loginTab).trigger("click");
}
};
const resetLoginState = () => {
clearPartialToken();
stopTotpTimer();
loginState = {
step: "credentials",
partialToken: null,
username: "",
rememberMe: false,
};
$(SELECTORS.authTabs).removeClass("hide-animated");
$(SELECTORS.totpSection).addClass("hidden");
$(SELECTORS.totpInput).val("");
$(SELECTORS.credentialsSection).removeClass("hidden");
$(SELECTORS.submitLogin).text(TEXTS.login);
resetCircle();
};
const checkPasswordMatch = (passwordId, confirmId, errorId) => {
const password = $(passwordId).val();
const confirm = $(confirmId).val();
const $error = $(errorId);
if (confirm && password !== confirm) {
$error.removeClass("hidden");
return false;
}
$error.addClass("hidden");
return true;
};
const saveTokensAndRedirect = (data, rememberMe) => {
const storage = rememberMe ? localStorage : sessionStorage;
const otherStorage = rememberMe ? sessionStorage : localStorage;
storage.setItem("access_token", data.access_token);
if (data.refresh_token)
storage.setItem("refresh_token", data.refresh_token);
otherStorage.removeItem("access_token");
otherStorage.removeItem("refresh_token");
window.location.href = "/";
};
const initLoginState = () => {
const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken);
const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername);
if (savedToken && savedUsername) {
$(SELECTORS.authTabs).addClass("hide-animated");
loginState.partialToken = savedToken;
loginState.username = savedUsername;
loginState.step = "2fa";
$(SELECTORS.usernameLogin).val(savedUsername);
$(SELECTORS.credentialsSection).addClass("hidden");
$(SELECTORS.totpSection).removeClass("hidden");
$(SELECTORS.submitLogin).text(TEXTS.confirm);
startTotpTimer();
setTimeout(() => $(SELECTORS.totpInput).get(0)?.focus(), 100);
}
};
$(SELECTORS.loginTab).on("click", () => showForm(SELECTORS.loginForm));
$(SELECTORS.registerTab).on("click", () => showForm(SELECTORS.registerForm));
$(SELECTORS.forgotBtn).on("click", () => showForm(SELECTORS.resetForm));
$(SELECTORS.backToLoginBtn).on("click", () => showForm(SELECTORS.loginForm));
$(SELECTORS.backToCredentialsBtn).on("click", resetLoginState);
$("body").on("click", ".toggle-password", function () {
const $input = $(this).siblings("input");
const isPassword = $input.attr("type") === "password";
$input.attr("type", isPassword ? "text" : "password");
$(this).find("svg").toggleClass("hidden");
});
$(SELECTORS.registerPassword).on("input", function () {
const password = $(this).val();
let strength = 0;
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
const levels = [
{ width: "0%", color: "", text: "" },
{ width: "20%", color: "bg-red-500", text: "Очень слабый" },
{ width: "40%", color: "bg-orange-500", text: "Слабый" },
{ width: "60%", color: "bg-yellow-500", text: "Средний" },
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
{ width: "100%", color: "bg-green-500", text: "Отличный" },
];
const level = levels[strength];
$(SELECTORS.passwordStrengthBar)
.css("width", level.width)
.attr("class", `h-full transition-all duration-300 ${level.color}`);
$(SELECTORS.passwordStrengthText).text(level.text);
checkPasswordMatch(
SELECTORS.registerPassword,
SELECTORS.registerConfirm,
SELECTORS.passwordMatchError,
);
});
$(SELECTORS.registerConfirm).on("input", () =>
checkPasswordMatch(
SELECTORS.registerPassword,
SELECTORS.registerConfirm,
SELECTORS.passwordMatchError,
),
);
$(SELECTORS.resetCode).on("input", function () {
let value = this.value.toUpperCase().replace(/[^0-9A-F]/g, "");
let formatted = "";
for (let i = 0; i < value.length && i < 16; i++) {
if (i > 0 && i % 4 === 0) formatted += "-";
formatted += value[i];
}
this.value = formatted;
});
$(SELECTORS.totpInput).on("input", function () {
this.value = this.value.replace(/\D/g, "").slice(0, 6);
if (this.value.length === 6) $(SELECTORS.loginForm).trigger("submit");
});
$(SELECTORS.loginForm).on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $(SELECTORS.submitLogin);
if (loginState.step === "credentials") {
const username = $(SELECTORS.usernameLogin).val();
const password = $(SELECTORS.passwordLogin).val();
const rememberMe = $(SELECTORS.rememberMe).prop("checked");
loginState.username = username;
loginState.rememberMe = rememberMe;
$submitBtn.prop("disabled", true).text("Вход...");
try {
const formData = new URLSearchParams({ username, password });
const data = await Api.postForm("/api/auth/token", formData);
if (data.requires_2fa && data.partial_token) {
loginState.partialToken = data.partial_token;
loginState.step = "2fa";
savePartialToken(data.partial_token, username);
$(SELECTORS.authTabs).addClass("hide-animated");
$(SELECTORS.credentialsSection).addClass("hidden");
$(SELECTORS.totpSection).removeClass("hidden");
startTotpTimer();
$(SELECTORS.totpInput).get(0)?.focus();
$submitBtn.text(TEXTS.confirm);
Utils.showToast(TEXTS.enterTotp, "info");
} else if (data.access_token) {
clearPartialToken();
saveTokensAndRedirect(data, rememberMe);
}
} catch (error) {
Utils.showToast(error.message || "Ошибка входа", "error");
} finally {
$submitBtn.prop("disabled", false);
if (loginState.step === "credentials") $submitBtn.text(TEXTS.login);
}
} else if (loginState.step === "2fa") {
const totpCode = $(SELECTORS.totpInput).val();
if (!totpCode || totpCode.length !== 6) {
Utils.showToast("Введите 6-значный код", "error");
return;
}
$submitBtn.prop("disabled", true).text(TEXTS.checking);
try {
const response = await fetch("/api/auth/2fa/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${loginState.partialToken}`,
},
body: JSON.stringify({ code: totpCode }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 401) {
resetLoginState();
throw new Error(TEXTS.sessionExpired);
}
throw new Error(errorData.detail || TEXTS.invalidCode);
}
const data = await response.json();
clearPartialToken();
stopTotpTimer();
saveTokensAndRedirect(data, loginState.rememberMe);
} catch (error) {
Utils.showToast(error.message || TEXTS.invalidCode, "error");
$(SELECTORS.totpInput).val("");
$(SELECTORS.totpInput).get(0)?.focus();
} finally {
$submitBtn.prop("disabled", false).text(TEXTS.confirm);
}
}
});
$(SELECTORS.registerForm).on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $(SELECTORS.submitRegister);
const pass = $(SELECTORS.registerPassword).val();
const confirm = $(SELECTORS.registerConfirm).val();
if (pass !== confirm) {
Utils.showToast(TEXTS.passwordsNotMatch, "error");
return;
}
const userData = {
username: $(SELECTORS.registerUsername).val(),
email: $(SELECTORS.registerEmail).val(),
full_name: $(SELECTORS.registerFullname).val() || null,
password: pass,
};
$submitBtn.prop("disabled", true).text(TEXTS.registering);
try {
const response = await Api.post("/api/auth/register", userData);
if (response.recovery_codes && response.recovery_codes.codes) {
registeredRecoveryCodes = response.recovery_codes.codes;
showRecoveryCodesModal(registeredRecoveryCodes, userData.username);
} else {
Utils.showToast(TEXTS.registrationSuccess, "success");
setTimeout(() => {
showForm(SELECTORS.loginForm);
$(SELECTORS.usernameLogin).val(userData.username);
}, 1500);
}
} catch (error) {
console.log("Debug error object:", error);
const cleanMsg = (text) => {
if (!text) return "";
if (text.includes("value is not a valid email address")) {
return "Некорректный адрес электронной почты";
}
text = text.replace(/^Value error,\s*/i, "");
return text.charAt(0).toUpperCase() + text.slice(1);
};
let msg = "Ошибка регистрации";
if (error.detail && error.detail.error === "captcha_required") {
Utils.showToast(TEXTS.captchaRequired, "error");
const $capElement = $(SELECTORS.capWidget);
const $parent = $capElement.parent();
$capElement.remove();
$parent.append(
`<cap-widget id="cap" data-cap-api-endpoint="/api/cap/" style="--cap-widget-width: 100%;"></cap-widget>`,
);
return;
}
if (error.detail && Array.isArray(error.detail)) {
msg = error.detail.map((e) => cleanMsg(e.msg)).join(". ");
} else if (Array.isArray(error)) {
msg = error.map((e) => cleanMsg(e.msg || e.message)).join(". ");
} else if (typeof error.detail === "string") {
msg = cleanMsg(error.detail);
} else if (error.message && !error.message.includes("[object Object]")) {
msg = cleanMsg(error.message);
}
console.log("Resulting msg:", msg);
Utils.showToast(msg, "error");
} finally {
$submitBtn
.prop("disabled", false)
.text(TEXTS.registering.replace("...", ""));
}
});
const showRecoveryCodesModal = (codes, username) => {
const $list = $(SELECTORS.recoveryList);
$list.empty();
codes.forEach((code, index) => {
$list.append(
`<div class="py-1 px-2 bg-white rounded border select-all font-mono">${index + 1}. ${Utils.escapeHtml(code)}</div>`,
);
});
$(SELECTORS.codesSavedCheckbox).prop("checked", false);
$(SELECTORS.closeRecoveryBtn).prop("disabled", true);
$(SELECTORS.recoveryModal).data("username", username).removeClass("hidden");
};
const renderRecoveryCodesStatus = (usedCodes) => {
return usedCodes
.map((used, index) => {
const codeDisplay = "████-████-████-████";
const statusClass = used
? "text-gray-300 line-through"
: "text-green-600";
const statusIcon = used ? "✗" : "✓";
return `<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}"><span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span><span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span></div>`;
})
.join("");
};
$(SELECTORS.codesSavedCheckbox).on("change", function () {
$(SELECTORS.closeRecoveryBtn).prop("disabled", !this.checked);
});
$(SELECTORS.copyCodesBtn).on("click", function () {
const codesText = registeredRecoveryCodes.join("\n");
navigator.clipboard
.writeText(codesText)
.then(() => Utils.showToast(TEXTS.codesCopied, "success"));
});
$(SELECTORS.downloadCodesBtn).on("click", function () {
const username = $(SELECTORS.recoveryModal).data("username") || "user";
const codesText = `Резервные коды для аккаунта: ${username}\nДата: ${new Date().toLocaleString()}\n\n${registeredRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nХраните эти коды в надёжном месте!`;
const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `recovery-codes-${username}.txt`;
a.click();
URL.revokeObjectURL(url);
Utils.showToast(TEXTS.codesDownloaded, "success");
});
$(SELECTORS.closeRecoveryBtn).on("click", function () {
const username = $(SELECTORS.recoveryModal).data("username");
$(SELECTORS.recoveryModal).addClass("hidden");
Utils.showToast(TEXTS.registrationSuccess, "success");
showForm(SELECTORS.loginForm);
$(SELECTORS.usernameLogin).val(username);
});
$(SELECTORS.resetConfirmPassword).on("input", () =>
checkPasswordMatch(
SELECTORS.resetNewPassword,
SELECTORS.resetConfirmPassword,
SELECTORS.resetMatchError,
),
);
$(SELECTORS.resetForm).on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $(SELECTORS.submitReset);
const newPassword = $(SELECTORS.resetNewPassword).val();
const confirmPassword = $(SELECTORS.resetConfirmPassword).val();
if (newPassword !== confirmPassword) {
Utils.showToast(TEXTS.passwordsNotMatch, "error");
return;
}
if (newPassword.length < 8) {
Utils.showToast(TEXTS.passwordTooShort, "error");
return;
}
const data = {
username: $(SELECTORS.resetUsername).val(),
recovery_code: $(SELECTORS.resetCode).val().toUpperCase(),
new_password: newPassword,
};
if (
!/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test(
data.recovery_code,
)
) {
Utils.showToast(TEXTS.invalidRecoveryCode, "error");
return;
}
$submitBtn.prop("disabled", true).text(TEXTS.resetting);
try {
const response = await Api.post("/api/auth/password/reset", data);
showPasswordResetResult(response, data.username);
} catch (error) {
Utils.showToast(error.message || "Ошибка сброса пароля", "error");
$submitBtn.prop("disabled", false).text("Сбросить пароль");
}
});
const showPasswordResetResult = (response, username) => {
const $form = $(SELECTORS.resetForm);
$form.html(`
<div class="text-center mb-4">
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
<svg class="w-8 h-8 text-green-600" 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>
</div>
<h3 class="text-lg font-semibold text-gray-800">${TEXTS.passwordResetSuccess}</h3>
</div>
<div class="mb-4">
<p class="text-sm text-gray-600 mb-2 text-center">
Осталось резервных кодов: <strong class="${response.remaining <= 2 ? "text-red-600" : "text-green-600"}">${response.remaining}</strong> из ${response.total}
</p>
${
response.should_regenerate
? `
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
<p class="text-sm text-yellow-800 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
Рекомендуем сгенерировать новые коды в профиле
</p>
</div>
`
: ""
}
<div class="bg-gray-50 rounded-lg p-3 space-y-1 max-h-48 overflow-y-auto">
<p class="text-xs text-gray-500 mb-2 text-center">Статус резервных кодов:</p>
${renderRecoveryCodesStatus(response.used_codes)}
</div>
${
response.generated_at
? `
<p class="text-xs text-gray-400 mt-2 text-center">
Сгенерированы: ${new Date(response.generated_at).toLocaleString()}
</p>
`
: ""
}
</div>
<button type="button" id="goto-login-after-reset" class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
Перейти к входу
</button>
`);
$form.off("submit");
$("#goto-login-after-reset").on("click", function () {
location.reload();
setTimeout(() => {
showForm(SELECTORS.loginForm);
$(SELECTORS.usernameLogin).val(username);
}, 100);
});
};
initLoginState();
handleHash();
const widget = $(SELECTORS.capWidget).get(0);
if (widget && widget.shadowRoot) {
const style = document.createElement("style");
style.textContent = `.credits { right: 20px !important; }`;
$(widget.shadowRoot).append(style);
}
});
@@ -6,6 +6,11 @@ $(document).ready(() => {
let currentSort = "name_asc"; let currentSort = "name_asc";
loadAuthors(); loadAuthors();
const USER_CAN_MANAGE =
typeof window.canManage === "function" && window.canManage();
if (USER_CAN_MANAGE) {
$("#add-author-btn").removeClass("hidden");
}
function loadAuthors() { function loadAuthors() {
showLoadingState(); showLoadingState();
@@ -34,6 +34,7 @@ $(document).ready(() => {
const pathParts = window.location.pathname.split("/"); const pathParts = window.location.pathname.split("/");
const bookId = parseInt(pathParts[pathParts.length - 1]); const bookId = parseInt(pathParts[pathParts.length - 1]);
let isDraggingOver = false;
let currentBook = null; let currentBook = null;
let cachedUsers = null; let cachedUsers = null;
let selectedLoanUserId = null; let selectedLoanUserId = null;
@@ -48,6 +49,28 @@ $(document).ready(() => {
} }
loadBookData(); loadBookData();
setupEventHandlers(); setupEventHandlers();
setupCoverUpload();
}
function getPreviewUrl(book) {
if (!book.preview_urls) {
return null;
}
const priorities = ["webp", "jpeg", "jpg", "png"];
for (const format of priorities) {
if (book.preview_urls[format]) {
return book.preview_urls[format];
}
}
const availableFormats = Object.keys(book.preview_urls);
if (availableFormats.length > 0) {
return book.preview_urls[availableFormats[0]];
}
return null;
} }
function setupEventHandlers() { function setupEventHandlers() {
@@ -75,6 +98,270 @@ $(document).ready(() => {
$("#loan-due-date").val(future.toISOString().split("T")[0]); $("#loan-due-date").val(future.toISOString().split("T")[0]);
} }
function setupCoverUpload() {
const $container = $("#book-cover-container");
const $fileInput = $("#cover-file-input");
$fileInput.on("change", function (e) {
const file = e.target.files[0];
if (file) {
uploadCover(file);
}
$(this).val("");
});
$container.on("dragenter", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
isDraggingOver = true;
showDropOverlay();
});
$container.on("dragover", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
isDraggingOver = true;
});
$container.on("dragleave", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
const rect = this.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
isDraggingOver = false;
hideDropOverlay();
}
});
$container.on("drop", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
isDraggingOver = false;
hideDropOverlay();
const files = e.dataTransfer?.files || [];
if (files.length > 0) {
const file = files[0];
if (!file.type.startsWith("image/")) {
Utils.showToast("Пожалуйста, загрузите изображение", "error");
return;
}
uploadCover(file);
}
});
}
function showDropOverlay() {
const $container = $("#book-cover-container");
$container.find(".drop-overlay").remove();
const $overlay = $(`
<div class="drop-overlay absolute inset-0 flex flex-col items-center justify-center z-20 pointer-events-none">
<div class="absolute inset-2 border-2 border-dashed border-gray-600 rounded-lg"></div>
<svg class="w-10 h-10 text-gray-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
<span class="text-gray-700 text-sm font-medium text-center px-4">Отпустите для загрузки</span>
</div>
`);
$container.append($overlay);
}
function hideDropOverlay() {
$("#book-cover-container .drop-overlay").remove();
}
async function uploadCover(file) {
const $container = $("#book-cover-container");
const maxSize = 32 * 1024 * 1024;
if (file.size > maxSize) {
Utils.showToast("Файл слишком большой. Максимум 32 MB", "error");
return;
}
if (!file.type.startsWith("image/")) {
Utils.showToast("Пожалуйста, загрузите изображение", "error");
return;
}
const $loader = $(`
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex flex-col items-center justify-center z-20">
<svg class="animate-spin w-8 h-8 text-white mb-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>
<span class="text-white text-sm">Загрузка...</span>
</div>
`);
$container.find(".upload-loader").remove();
$container.append($loader);
try {
const formData = new FormData();
formData.append("file", file);
const response = await Api.uploadFile(
`/api/books/${bookId}/preview`,
formData,
);
if (!response) {
return;
}
if (response.preview) {
currentBook.preview_urls = response.preview;
} else if (response.preview_urls) {
currentBook.preview_urls = response.preview_urls;
} else {
currentBook = response;
}
Utils.showToast("Обложка успешно загружена", "success");
renderBookCover(currentBook);
} catch (error) {
console.error("Upload error:", error);
Utils.showToast(error.message || "Ошибка загрузки обложки", "error");
} finally {
$container.find(".upload-loader").remove();
}
}
async function deleteCover() {
if (!confirm("Удалить обложку книги?")) {
return;
}
const $container = $("#book-cover-container");
const $loader = $(`
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-20">
<svg class="animate-spin w-8 h-8 text-white" 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>
</div>
`);
$container.find(".upload-loader").remove();
$container.append($loader);
try {
await Api.delete(`/api/books/${bookId}/preview`);
currentBook.preview_urls = null;
Utils.showToast("Обложка удалена", "success");
renderBookCover(currentBook);
} catch (error) {
console.error("Delete error:", error);
Utils.showToast(error.message || "Ошибка удаления обложки", "error");
} finally {
$container.find(".upload-loader").remove();
}
}
function renderBookCover(book) {
const $container = $("#book-cover-container");
const canManage = window.canManage();
const previewUrl = getPreviewUrl(book);
if (previewUrl) {
$container.html(`
<img
src="${Utils.escapeHtml(previewUrl)}"
alt="Обложка книги ${Utils.escapeHtml(book.title)}"
class="w-full h-full object-cover"
onerror="this.onerror=null; this.parentElement.querySelector('.cover-fallback').classList.remove('hidden'); this.classList.add('hidden');"
/>
<div class="cover-fallback hidden w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center absolute inset-0">
<svg class="w-20 h-20 text-white opacity-80" 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>
${
canManage
? `
<button
id="delete-cover-btn"
class="absolute top-2 right-2 w-7 h-7 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10"
title="Удалить обложку"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all flex flex-col items-center justify-center cursor-pointer z-0" id="cover-replace-overlay">
<svg class="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
<span class="text-white text-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-medium pointer-events-none px-2">
Заменить
</span>
</div>
`
: ""
}
`);
if (canManage) {
$("#delete-cover-btn").on("click", function (e) {
e.stopPropagation();
deleteCover();
});
$("#cover-replace-overlay").on("click", function () {
$("#cover-file-input").trigger("click");
});
}
} else {
if (canManage) {
$container.html(`
<div
id="cover-upload-zone"
class="w-full h-full bg-gray-100 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-200 transition-all text-center relative"
>
<div class="absolute inset-2 border-2 border-dashed border-gray-300 rounded-lg pointer-events-none"></div>
<svg class="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
<span class="text-gray-500 text-xs font-medium px-2">
Добавить обложку
</span>
<span class="text-gray-400 text-xs mt-1 px-2">
или перетащите
</span>
</div>
`);
$("#cover-upload-zone").on("click", function () {
$("#cover-file-input").trigger("click");
});
} else {
$container.html(`
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
<svg class="w-20 h-20 text-white opacity-80" 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>
`);
}
}
}
function loadBookData() { function loadBookData() {
Api.get(`/api/books/${bookId}`) Api.get(`/api/books/${bookId}`)
.then((book) => { .then((book) => {
@@ -103,7 +390,7 @@ $(document).ready(() => {
try { try {
const data = await Api.get( const data = await Api.get(
`/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10` `/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10`,
); );
activeLoan = data.loans.length > 0 ? data.loans[0] : null; activeLoan = data.loans.length > 0 ? data.loans[0] : null;
renderLoans(data.loans); renderLoans(data.loans);
@@ -128,7 +415,7 @@ $(document).ready(() => {
loans.forEach((loan) => { loans.forEach((loan) => {
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString( const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
"ru-RU" "ru-RU",
); );
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const isOverdue = const isOverdue =
@@ -234,6 +521,16 @@ $(document).ready(() => {
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}`);
renderBookCover(book);
if (book.page_count && book.page_count > 0) {
$("#book-page-count-value").text(book.page_count);
$("#book-page-count-text").removeClass("hidden");
} else {
$("#book-page-count-text").addClass("hidden");
}
$("#book-authors-text").text( $("#book-authors-text").text(
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен", book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
); );
@@ -253,9 +550,9 @@ $(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>
`); `);
}); });
} }
@@ -266,12 +563,12 @@ $(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>
`); `);
}); });
} }
@@ -435,7 +732,7 @@ $(document).ready(() => {
} }
try { try {
const data = await Api.get("/api/auth/users?skip=0&limit=500"); const data = await Api.get("/api/users?skip=0&limit=500");
cachedUsers = data.users; cachedUsers = data.users;
renderUsersList(cachedUsers); renderUsersList(cachedUsers);
} catch (error) { } catch (error) {
@@ -531,11 +828,9 @@ $(document).ready(() => {
due_date: new Date(dueDate).toISOString(), due_date: new Date(dueDate).toISOString(),
}; };
// Используем прямой эндпоинт выдачи для администраторов
if (window.isAdmin()) { if (window.isAdmin()) {
await Api.post("/api/loans/issue", payload); await Api.post("/api/loans/issue", payload);
} else { } else {
// Для библиотекарей создаем бронь, которую потом нужно подтвердить
await Api.post("/api/loans/", payload); await Api.post("/api/loans/", payload);
} }
+519
View File
@@ -0,0 +1,519 @@
$(() => {
const SELECTORS = {
booksContainer: "#books-container",
paginationContainer: "#pagination-container",
bookSearchInput: "#book-search-input",
authorSearchInput: "#author-search-input",
authorDropdown: "#author-dropdown",
selectedAuthorsContainer: "#selected-authors-container",
genresList: "#genres-list",
applyFiltersBtn: "#apply-filters-btn",
resetFiltersBtn: "#reset-filters-btn",
adminActions: "#admin-actions",
pagesMin: "#pages-min",
pagesMax: "#pages-max",
};
const TEMPLATES = {
bookCard: document.getElementById("book-card-template"),
genreBadge: document.getElementById("genre-badge-template"),
emptyState: document.getElementById("empty-state-template"),
};
const STATUS_CONFIG = {
active: {
label: "Доступна",
bgClass: "bg-green-100",
textClass: "text-green-800",
},
borrowed: {
label: "Выдана",
bgClass: "bg-yellow-100",
textClass: "text-yellow-800",
},
reserved: {
label: "Забронирована",
bgClass: "bg-blue-100",
textClass: "text-blue-800",
},
restoration: {
label: "На реставрации",
bgClass: "bg-orange-100",
textClass: "text-orange-800",
},
written_off: {
label: "Списана",
bgClass: "bg-red-100",
textClass: "text-red-800",
},
};
const PAGE_SIZE = 12;
const STATE = {
selectedAuthors: new Map(),
selectedGenres: new Map(),
currentPage: 1,
totalBooks: 0,
};
const urlParams = new URLSearchParams(window.location.search);
const INITIAL_FILTERS = {
search: urlParams.get("q") || "",
authorIds: new Set(urlParams.getAll("author_id")),
genreIds: new Set(urlParams.getAll("genre_id")),
};
if (INITIAL_FILTERS.search) {
$(SELECTORS.bookSearchInput).val(INITIAL_FILTERS.search);
}
const LOADING_SKELETON_HTML = `<div class="space-y-4">${Array.from(
{ length: 3 },
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
</div>
`,
).join("")}</div>`;
const USER_CAN_MANAGE =
typeof window.canManage === "function" && window.canManage();
function getStatusConfig(status) {
return (
STATUS_CONFIG[status] || {
label: status || "Неизвестно",
bgClass: "bg-gray-100",
textClass: "text-gray-800",
}
);
}
function initAuthors(authors) {
const $dropdown = $(SELECTORS.authorDropdown);
const fragment = document.createDocumentFragment();
authors.forEach((author) => {
const item = document.createElement("div");
item.className =
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors";
item.dataset.id = author.id;
item.dataset.name = author.name;
item.textContent = author.name;
fragment.appendChild(item);
if (INITIAL_FILTERS.authorIds.has(String(author.id))) {
STATE.selectedAuthors.set(author.id, author.name);
}
});
$dropdown.empty().append(fragment);
}
function initGenres(genres) {
const $list = $(SELECTORS.genresList);
const canManage = USER_CAN_MANAGE;
let html = "";
genres.forEach((genre) => {
const isChecked = INITIAL_FILTERS.genreIds.has(String(genre.id));
if (isChecked) {
STATE.selectedGenres.set(genre.id, genre.name);
}
const safeName = Utils.escapeHtml(genre.name);
const editButton = canManage
? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
</a>`
: "";
html += `
<li class="mb-1">
<div class="flex items-center">
<label class="custom-checkbox flex items-center flex-1">
<input type="checkbox" data-id="${genre.id}" data-name="${safeName}" ${
isChecked ? "checked" : ""
} />
<span class="checkmark"></span> ${safeName}
</label>
${editButton}
</div>
</li>
`;
});
$list.html(html);
$list.on("change", "input", function () {
const id = parseInt($(this).data("id"), 10);
const name = $(this).data("name");
if (this.checked) {
STATE.selectedGenres.set(id, name);
} else {
STATE.selectedGenres.delete(id);
}
});
}
function getTotalPages() {
return Math.max(1, Math.ceil(STATE.totalBooks / PAGE_SIZE));
}
function loadBooks() {
const searchQuery = $(SELECTORS.bookSearchInput).val().trim();
const $minPages = $(SELECTORS.pagesMin);
const $maxPages = $(SELECTORS.pagesMax);
const minPages = $minPages.length ? $minPages.val() : "";
const maxPages = $maxPages.length ? $maxPages.val() : "";
const apiParams = new URLSearchParams();
const browserParams = new URLSearchParams();
if (searchQuery) {
apiParams.append("q", searchQuery);
browserParams.append("q", searchQuery);
}
if (minPages && minPages > 0) {
apiParams.append("min_page_count", minPages);
browserParams.append("min_page_count", minPages);
}
if (maxPages && maxPages < 2000) {
apiParams.append("max_page_count", maxPages);
browserParams.append("max_page_count", maxPages);
}
STATE.selectedAuthors.forEach((_, id) => {
apiParams.append("author_ids", id);
browserParams.append("author_id", id);
});
STATE.selectedGenres.forEach((_, id) => {
apiParams.append("genre_ids", id);
browserParams.append("genre_id", id);
});
apiParams.append("page", STATE.currentPage);
apiParams.append("size", PAGE_SIZE);
const newUrl =
window.location.pathname +
(browserParams.toString() ? `?${browserParams.toString()}` : "");
window.history.replaceState({}, "", newUrl);
showLoadingState();
Api.get(`/api/books/filter?${apiParams.toString()}`)
.then((data) => {
STATE.totalBooks = data.total || 0;
renderBooks(data.books || []);
renderPagination();
})
.catch((error) => {
console.error(error);
Utils.showToast("Не удалось загрузить книги", "error");
$(SELECTORS.booksContainer).html(
TEMPLATES.emptyState.content.cloneNode(true),
);
});
}
function renderBooks(books) {
const $container = $(SELECTORS.booksContainer);
$container.empty();
if (!books.length) {
$container.append(TEMPLATES.emptyState.content.cloneNode(true));
return;
}
const fragment = document.createDocumentFragment();
books.forEach((book) => {
const clone = TEMPLATES.bookCard.content.cloneNode(true);
const card = clone.querySelector(".book-card");
card.dataset.id = book.id;
const titleEl = clone.querySelector(".book-title");
const authorsEl = clone.querySelector(".book-authors");
const pageCountWrapper = clone.querySelector(".book-page-count");
const pageCountValue =
pageCountWrapper.querySelector(".page-count-value");
const descEl = clone.querySelector(".book-desc");
const statusEl = clone.querySelector(".book-status");
const genresContainer = clone.querySelector(".book-genres");
titleEl.textContent = book.title;
authorsEl.textContent =
(book.authors && book.authors.map((a) => a.name).join(", ")) ||
"Автор неизвестен";
if (book.page_count && book.page_count > 0) {
pageCountValue.textContent = book.page_count;
pageCountWrapper.classList.remove("hidden");
}
descEl.textContent = book.description || "";
const statusConfig = getStatusConfig(book.status);
statusEl.textContent = statusConfig.label;
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
if (Array.isArray(book.genres)) {
book.genres.forEach((g) => {
const badge = TEMPLATES.genreBadge.content.cloneNode(true);
const span = badge.querySelector("span");
span.textContent = g.name;
genresContainer.appendChild(badge);
});
}
fragment.appendChild(clone);
});
$container.append(fragment);
}
function generatePageNumbers(current, total) {
const pages = [];
const delta = 2;
for (let i = 1; i <= total; i++) {
if (
i === 1 ||
i === total ||
(i >= current - delta && i <= current + delta)
) {
pages.push(i);
} else if (pages[pages.length - 1] !== "...") {
pages.push("...");
}
}
return pages;
}
function renderPagination() {
const totalPages = getTotalPages();
const $container = $(SELECTORS.paginationContainer);
$container.empty();
if (totalPages <= 1) {
return;
}
const pages = generatePageNumbers(STATE.currentPage, totalPages);
let pagesHtml = "";
pages.forEach((page) => {
if (page === "...") {
pagesHtml += `<span class="px-3 py-2 text-gray-500">...</span>`;
} else {
const isActive = page === STATE.currentPage;
pagesHtml += `<button class="page-btn px-3 py-2 rounded-lg transition-colors ${
isActive
? "bg-gray-600 text-white"
: "bg-white border border-gray-300 hover:bg-gray-50"
}" data-page="${page}">${page}</button>`;
}
});
const html = `
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
STATE.currentPage === 1 ? "disabled" : ""
}>&larr;</button>
<div id="page-numbers" class="flex gap-1">${pagesHtml}</div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
STATE.currentPage === totalPages ? "disabled" : ""
}>&rarr;</button>
</div>
`;
$container.html(html);
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
function showLoadingState() {
$(SELECTORS.booksContainer).html(LOADING_SKELETON_HTML);
}
function renderSelectedAuthors() {
const $container = $(SELECTORS.selectedAuthorsContainer);
const $dropdown = $(SELECTORS.authorDropdown);
$container.empty();
const fragment = document.createDocumentFragment();
STATE.selectedAuthors.forEach((name, id) => {
const wrapper = document.createElement("span");
wrapper.className =
"author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full";
wrapper.innerHTML = `
${Utils.escapeHtml(name)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
`;
fragment.appendChild(wrapper);
});
$container.append(fragment);
$dropdown.find(".author-item").each(function () {
const id = parseInt($(this).data("id"), 10);
if (STATE.selectedAuthors.has(id)) {
$(this)
.addClass("bg-gray-200 text-gray-900 font-semibold")
.removeClass("hover:bg-gray-100");
} else {
$(this)
.removeClass("bg-gray-200 text-gray-900 font-semibold")
.addClass("hover:bg-gray-100");
}
});
}
function initializeAuthorDropdownListeners() {
const $input = $(SELECTORS.authorSearchInput);
const $dropdown = $(SELECTORS.authorDropdown);
const $container = $(SELECTORS.selectedAuthorsContainer);
$input.on("focus", () => {
$dropdown.removeClass("hidden");
});
$input.on("input", function () {
const val = $(this).val().toLowerCase();
$dropdown.removeClass("hidden");
$dropdown.find(".author-item").each(function () {
const text = $(this).text().toLowerCase();
$(this).toggle(text.includes(val));
});
});
$(document).on("click", function (e) {
if (
!$(e.target).closest(
`${SELECTORS.authorSearchInput}, ${SELECTORS.authorDropdown}, ${SELECTORS.selectedAuthorsContainer}`,
).length
) {
$dropdown.addClass("hidden");
}
});
$dropdown.on("click", ".author-item", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"), 10);
const name = $(this).data("name");
if (STATE.selectedAuthors.has(id)) {
STATE.selectedAuthors.delete(id);
} else {
STATE.selectedAuthors.set(id, name);
}
$input.val("");
$dropdown.find(".author-item").show();
renderSelectedAuthors();
$input[0].focus();
});
$container.on("click", ".remove-author", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"), 10);
STATE.selectedAuthors.delete(id);
renderSelectedAuthors();
});
}
$(SELECTORS.booksContainer).on("click", ".book-card", function () {
const id = $(this).data("id");
if (id) {
window.location.href = `/book/${id}`;
}
});
$(SELECTORS.applyFiltersBtn).on("click", function () {
STATE.currentPage = 1;
loadBooks();
});
$(SELECTORS.resetFiltersBtn).on("click", function () {
$(SELECTORS.bookSearchInput).val("");
STATE.selectedAuthors.clear();
STATE.selectedGenres.clear();
$(`${SELECTORS.genresList} input`).prop("checked", false);
const $min = $(SELECTORS.pagesMin);
const $max = $(SELECTORS.pagesMax);
if ($min.length && $max.length) {
const minDefault = $min.attr("min");
const maxDefault = $max.attr("max");
if (minDefault !== undefined) $min.val(minDefault).trigger("input");
if (maxDefault !== undefined) $max.val(maxDefault).trigger("input");
}
renderSelectedAuthors();
STATE.currentPage = 1;
loadBooks();
});
$(SELECTORS.bookSearchInput).on("keypress", function (e) {
if (e.which === 13) {
STATE.currentPage = 1;
loadBooks();
}
});
$(SELECTORS.paginationContainer).on("click", "#prev-page", function () {
if (STATE.currentPage > 1) {
STATE.currentPage -= 1;
loadBooks();
scrollToTop();
}
});
$(SELECTORS.paginationContainer).on("click", "#next-page", function () {
const totalPages = getTotalPages();
if (STATE.currentPage < totalPages) {
STATE.currentPage += 1;
loadBooks();
scrollToTop();
}
});
$(SELECTORS.paginationContainer).on("click", ".page-btn", function () {
const page = parseInt($(this).data("page"), 10);
if (page && page !== STATE.currentPage) {
STATE.currentPage = page;
loadBooks();
scrollToTop();
}
});
if (USER_CAN_MANAGE) {
$(SELECTORS.adminActions).removeClass("hidden");
}
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
.then(([authorsData, genresData]) => {
initAuthors(authorsData.authors || []);
initGenres(genresData.genres || []);
initializeAuthorDropdownListeners();
renderSelectedAuthors();
loadBooks();
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки данных", "error");
});
});
@@ -235,18 +235,25 @@ $(document).ready(() => {
const title = $("#book-title").val().trim(); const title = $("#book-title").val().trim();
const description = $("#book-description").val().trim(); const description = $("#book-description").val().trim();
const pageCount = parseInt($("#book-page-count").val()) || null;
if (!title) { if (!title) {
Utils.showToast("Введите название книги", "error"); Utils.showToast("Введите название книги", "error");
return; return;
} }
if (!pageCount) {
Utils.showToast("Введите количество страниц", "error");
return;
}
setLoading(true); setLoading(true);
try { try {
const bookPayload = { const bookPayload = {
title: title, title: title,
description: description || null, description: description || null,
page_count: pageCount,
}; };
const createdBook = await Api.post("/api/books/", bookPayload); const createdBook = await Api.post("/api/books/", bookPayload);
@@ -23,6 +23,7 @@ $(document).ready(() => {
const $titleInput = $("#book-title"); const $titleInput = $("#book-title");
const $descInput = $("#book-description"); const $descInput = $("#book-description");
const $statusSelect = $("#book-status"); const $statusSelect = $("#book-status");
const $pagesInput = $("#book-page-count");
const $submitBtn = $("#submit-btn"); const $submitBtn = $("#submit-btn");
const $submitText = $("#submit-text"); const $submitText = $("#submit-text");
const $loadingSpinner = $("#loading-spinner"); const $loadingSpinner = $("#loading-spinner");
@@ -69,6 +70,7 @@ $(document).ready(() => {
function populateForm(book) { function populateForm(book) {
$titleInput.val(book.title); $titleInput.val(book.title);
$descInput.val(book.description || ""); $descInput.val(book.description || "");
$pagesInput.val(book.page_count);
$statusSelect.val(book.status); $statusSelect.val(book.status);
updateCounters(); updateCounters();
} }
@@ -329,6 +331,7 @@ $(document).ready(() => {
const title = $titleInput.val().trim(); const title = $titleInput.val().trim();
const description = $descInput.val().trim(); const description = $descInput.val().trim();
const pages = parseInt($("#book-page-count").val()) || null;
const status = $statusSelect.val(); const status = $statusSelect.val();
if (!title) { if (!title) {
@@ -340,6 +343,7 @@ $(document).ready(() => {
if (title !== originalBook.title) payload.title = title; if (title !== originalBook.title) payload.title = title;
if (description !== (originalBook.description || "")) if (description !== (originalBook.description || ""))
payload.description = description || null; payload.description = description || null;
if (pages !== originalBook.page_count) payload.page_count = pages;
if (status !== originalBook.status) payload.status = status; if (status !== originalBook.status) payload.status = status;
if (Object.keys(payload).length === 0) { if (Object.keys(payload).length === 0) {
@@ -19,8 +19,7 @@ $(document).ready(() => {
const data = await Api.get("/api/loans/?page=1&size=100"); const data = await Api.get("/api/loans/?page=1&size=100");
allLoans = data.loans; allLoans = data.loans;
// Загружаем информацию о книгах const bookIds = [...new Set(allLoans.map((loan) => loan.book_id))];
const bookIds = [...new Set(allLoans.map(loan => loan.book_id))];
await loadBooks(bookIds); await loadBooks(bookIds);
renderLoans(); renderLoans();
@@ -46,12 +45,12 @@ $(document).ready(() => {
function renderLoans() { function renderLoans() {
const reservations = allLoans.filter( const reservations = allLoans.filter(
loan => !loan.returned_at && getBookStatus(loan.book_id) === "reserved" (loan) => !loan.returned_at && getBookStatus(loan.book_id) === "reserved",
); );
const activeLoans = allLoans.filter( const activeLoans = allLoans.filter(
loan => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed" (loan) => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed",
); );
const returned = allLoans.filter(loan => loan.returned_at !== null); const returned = allLoans.filter((loan) => loan.returned_at !== null);
renderReservations(reservations); renderReservations(reservations);
renderActiveLoans(activeLoans); renderActiveLoans(activeLoans);
@@ -70,7 +69,7 @@ $(document).ready(() => {
if (reservations.length === 0) { if (reservations.length === 0) {
$container.html( $container.html(
'<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>' '<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>',
); );
return; return;
} }
@@ -79,7 +78,9 @@ $(document).ready(() => {
const book = booksCache.get(loan.book_id); const book = booksCache.get(loan.book_id);
if (!book) return; if (!book) return;
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU"); const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
"ru-RU",
);
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const $card = $(` const $card = $(`
@@ -90,7 +91,7 @@ $(document).ready(() => {
${Utils.escapeHtml(book.title)} ${Utils.escapeHtml(book.title)}
</a> </a>
<p class="text-sm text-gray-600 mt-1"> <p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"} Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}
</p> </p>
<div class="mt-3 space-y-1 text-sm text-gray-600"> <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> ${borrowedDate}</p>
@@ -130,7 +131,7 @@ $(document).ready(() => {
if (activeLoans.length === 0) { if (activeLoans.length === 0) {
$container.html( $container.html(
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>' '<div class="text-center text-gray-500 py-8">Нет активных выдач</div>',
); );
return; return;
} }
@@ -139,7 +140,9 @@ $(document).ready(() => {
const book = booksCache.get(loan.book_id); const book = booksCache.get(loan.book_id);
if (!book) return; if (!book) return;
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU"); const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
"ru-RU",
);
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const isOverdue = new Date(loan.due_date) < new Date(); const isOverdue = new Date(loan.due_date) < new Date();
@@ -151,7 +154,7 @@ $(document).ready(() => {
${Utils.escapeHtml(book.title)} ${Utils.escapeHtml(book.title)}
</a> </a>
<p class="text-sm text-gray-600 mt-1"> <p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"} Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}
</p> </p>
<div class="mt-3 space-y-1 text-sm text-gray-600"> <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> ${borrowedDate}</p>
@@ -179,7 +182,7 @@ $(document).ready(() => {
if (returned.length === 0) { if (returned.length === 0) {
$container.html( $container.html(
'<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>' '<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>',
); );
return; return;
} }
@@ -188,8 +191,12 @@ $(document).ready(() => {
const book = booksCache.get(loan.book_id); const book = booksCache.get(loan.book_id);
if (!book) return; if (!book) return;
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU"); const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
const returnedDate = new Date(loan.returned_at).toLocaleDateString("ru-RU"); "ru-RU",
);
const returnedDate = new Date(loan.returned_at).toLocaleDateString(
"ru-RU",
);
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const $card = $(` const $card = $(`
@@ -200,7 +207,7 @@ $(document).ready(() => {
${Utils.escapeHtml(book.title)} ${Utils.escapeHtml(book.title)}
</a> </a>
<p class="text-sm text-gray-600 mt-1"> <p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"} Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}
</p> </p>
<div class="mt-3 space-y-1 text-sm text-gray-600"> <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> ${borrowedDate}</p>
@@ -230,8 +237,7 @@ $(document).ready(() => {
await Api.delete(`/api/loans/${loanId}`); await Api.delete(`/api/loans/${loanId}`);
Utils.showToast("Бронирование отменено", "success"); Utils.showToast("Бронирование отменено", "success");
// Удаляем из кэша и перезагружаем allLoans = allLoans.filter((loan) => loan.id !== loanId);
allLoans = allLoans.filter(loan => loan.id !== loanId);
const book = booksCache.get(bookId); const book = booksCache.get(bookId);
if (book) { if (book) {
book.status = "active"; book.status = "active";
@@ -245,4 +251,3 @@ $(document).ready(() => {
} }
} }
}); });
+387
View File
@@ -0,0 +1,387 @@
$(document).ready(() => {
const token = StorageHelper.get("access_token");
if (!token) {
window.location.href = "/auth";
return;
}
let currentUsername = "";
let currentRecoveryCodes = [];
loadProfile();
function loadProfile() {
Promise.all([
Api.get("/api/auth/me"),
Api.get("/api/users/roles").catch(() => ({ roles: [] })),
Api.get("/api/auth/recovery-codes/status").catch(() => null),
])
.then(async ([user, rolesData, recoveryStatus]) => {
document.title = `LiB - ${user.full_name || user.username}`;
currentUsername = user.username;
await renderProfileHeader(user);
renderInfo(user);
renderRoles(user.roles || [], rolesData.roles || []);
window.dispatchEvent(
new CustomEvent("update-2fa", { detail: user.is_2fa_enabled }),
);
if (recoveryStatus) {
window.dispatchEvent(
new CustomEvent("update-recovery-codes", {
detail: recoveryStatus.remaining,
}),
);
}
$("#account-section, #roles-section").removeClass("hidden");
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки профиля", "error");
});
}
async function renderProfileHeader(user) {
const avatarUrl = await Utils.getGravatarUrl(user.email);
const displayName = Utils.escapeHtml(user.full_name || user.username);
$("#profile-card").html(`
<div class="flex flex-col sm:flex-row items-center sm:items-start">
<div class="relative mb-4 sm:mb-0 sm:mr-6">
<img src="${avatarUrl}" class="w-24 h-24 rounded-full object-cover border-4 border-gray-200">
${user.is_verified ? '<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1 border-2 border-white"><svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg></div>' : ""}
</div>
<div class="flex-1 text-center sm:text-left">
<h1 class="text-2xl font-bold text-gray-900 mb-1">${displayName}</h1>
<p class="text-gray-500 mb-3">@${Utils.escapeHtml(user.username)}</p>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm ${user.is_active ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}">
${user.is_active ? "Активен" : "Заблокирован"}
</span>
</div>
</div>
`);
}
function renderInfo(user) {
const fields = [
{ label: "ID пользователя", value: user.id },
{ label: "Email", value: user.email },
{ label: "Полное имя", value: user.full_name || "Не указано" },
];
const html = fields
.map(
(f) => `
<div class="flex justify-between py-2 border-b last:border-0">
<span class="text-gray-500">${f.label}</span>
<span class="font-medium text-gray-900">${Utils.escapeHtml(String(f.value))}</span>
</div>
`,
)
.join("");
$("#account-info").html(html);
}
function renderRoles(userRoles, allRoles) {
const $container = $("#roles-container");
if (userRoles.length === 0) {
$container.html('<p class="text-gray-500">Нет ролей</p>');
return;
}
const roleMap = {};
allRoles.forEach((r) => (roleMap[r.name] = r.description));
const html = userRoles
.map(
(role) => `
<div class="p-3 bg-blue-50 border border-blue-100 rounded text-blue-800">
<div class="font-bold capitalize">${Utils.escapeHtml(role)}</div>
<div class="text-xs opacity-75">${Utils.escapeHtml(roleMap[role] || "")}</div>
</div>
`,
)
.join("");
$container.html(html);
}
$("#recovery-codes-btn").on("click", function () {
resetRecoveryCodesModal();
window.dispatchEvent(new CustomEvent("open-recovery-codes-modal"));
loadRecoveryCodesStatus();
});
function resetRecoveryCodesModal() {
$("#recovery-codes-loading").removeClass("hidden");
$("#recovery-codes-status").addClass("hidden");
$("#recovery-codes-display").addClass("hidden");
$("#codes-saved-checkbox").prop("checked", false);
$("#close-recovery-modal-btn").prop("disabled", true);
$("#regenerate-codes-btn")
.prop("disabled", false)
.text("Сгенерировать новые коды");
currentRecoveryCodes = [];
}
async function loadRecoveryCodesStatus() {
try {
const status = await Api.get("/api/auth/recovery-codes/status");
renderRecoveryCodesStatus(status);
} catch (error) {
Utils.showToast(
error.message || "Ошибка загрузки статуса кодов",
"error",
);
window.dispatchEvent(new CustomEvent("close-recovery-codes-modal"));
}
}
function renderRecoveryCodesStatus(status) {
const { total, remaining, used_codes, generated_at, should_regenerate } =
status;
let iconBgClass, iconColorClass, iconSvg;
if (remaining <= 2) {
iconBgClass = "bg-red-100";
iconColorClass = "text-red-600";
iconSvg = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />`;
} else if (remaining <= 5) {
iconBgClass = "bg-yellow-100";
iconColorClass = "text-yellow-600";
iconSvg = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />`;
} else {
iconBgClass = "bg-green-100";
iconColorClass = "text-green-600";
iconSvg = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />`;
}
$("#status-icon-container")
.removeClass()
.addClass(
`flex items-center justify-center w-12 h-12 mx-auto rounded-full mb-4 ${iconBgClass}`,
)
.html(
`<svg class="w-6 h-6 ${iconColorClass}" fill="none" stroke="currentColor" viewBox="0 0 24 24">${iconSvg}</svg>`,
);
let statusColorClass;
if (remaining <= 2) {
statusColorClass = "text-red-600";
} else if (remaining <= 5) {
statusColorClass = "text-yellow-600";
} else {
statusColorClass = "text-green-600";
}
$("#codes-status-summary").html(`
<p class="text-sm text-gray-600">
Доступно кодов: <strong class="${statusColorClass}">${remaining}</strong> из <strong>${total}</strong>
</p>
`);
const $list = $("#codes-status-list");
$list.empty();
used_codes.forEach((used, index) => {
const codeDisplay = "████-████-████-████";
const statusClass = used
? "text-gray-300 line-through"
: "text-green-600";
const statusIcon = used ? "✗" : "✓";
const bgClass = used ? "bg-gray-50" : "bg-green-50";
$list.append(`
<div class="flex items-center justify-between py-1 px-2 rounded ${bgClass}">
<span class="font-mono text-sm ${statusClass}">${index + 1}. ${codeDisplay}</span>
<span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span>
</div>
`);
});
if (should_regenerate || remaining <= 2) {
let warningText;
if (remaining === 0) {
warningText =
"У вас не осталось резервных кодов! Срочно сгенерируйте новые.";
} else if (remaining <= 2) {
warningText = "Осталось мало кодов. Рекомендуем сгенерировать новые.";
} else {
warningText = "Рекомендуем сгенерировать новые коды для безопасности.";
}
$("#warning-text").text(warningText);
$("#codes-warning").removeClass("hidden");
} else {
$("#codes-warning").addClass("hidden");
}
if (generated_at) {
const date = new Date(generated_at);
$("#codes-generated-at").text(`Сгенерированы: ${date.toLocaleString()}`);
}
$("#recovery-codes-loading").addClass("hidden");
$("#recovery-codes-status").removeClass("hidden");
}
$("#regenerate-codes-btn").on("click", async function () {
const $btn = $(this);
$btn.prop("disabled", true).text("Генерация...");
try {
const response = await Api.post("/api/auth/recovery-codes/regenerate");
currentRecoveryCodes = response.codes;
displayNewRecoveryCodes(response.codes, response.generated_at);
window.dispatchEvent(
new CustomEvent("update-recovery-codes", {
detail: response.codes.length,
}),
);
Utils.showToast("Новые коды успешно сгенерированы", "success");
} catch (error) {
Utils.showToast(error.message || "Ошибка генерации кодов", "error");
$btn.prop("disabled", false).text("Сгенерировать новые коды");
}
});
function displayNewRecoveryCodes(codes, generatedAt) {
const $list = $("#recovery-codes-list");
$list.empty();
codes.forEach((code, index) => {
$list.append(`
<div class="py-1 px-2 bg-white rounded border select-all font-mono text-gray-800">
${index + 1}. ${Utils.escapeHtml(code)}
</div>
`);
});
if (generatedAt) {
const date = new Date(generatedAt);
$("#recovery-codes-generated-at").text(
`Сгенерированы: ${date.toLocaleString()}`,
);
}
$("#recovery-codes-status").addClass("hidden");
$("#recovery-codes-display").removeClass("hidden");
}
$("#codes-saved-checkbox").on("change", function () {
$("#close-recovery-modal-btn").prop("disabled", !this.checked);
});
$("#copy-codes-btn").on("click", function () {
if (currentRecoveryCodes.length === 0) return;
const codesText = currentRecoveryCodes.join("\n");
navigator.clipboard.writeText(codesText).then(() => {
const $btn = $(this);
const originalHtml = $btn.html();
$btn.html(`
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span>Скопировано!</span>
`);
setTimeout(() => $btn.html(originalHtml), 2000);
Utils.showToast("Коды скопированы в буфер обмена", "success");
});
});
$("#download-codes-btn").on("click", function () {
if (currentRecoveryCodes.length === 0) return;
const username = currentUsername || "user";
const codesText = `Резервные коды для аккаунта: ${username}
Дата: ${new Date().toLocaleString()}
${currentRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}
Храните эти коды в надёжном месте!
Каждый код можно использовать только один раз.`;
const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `recovery-codes-${username}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
Utils.showToast("Файл с кодами скачан", "success");
});
$("#close-recovery-modal-btn, #close-status-modal-btn").on(
"click",
function () {
window.dispatchEvent(new CustomEvent("close-recovery-codes-modal"));
},
);
$("#submit-disable-2fa-btn").on("click", async function () {
const $btn = $(this);
const password = $("#disable-2fa-password").val();
if (!password) {
Utils.showToast("Введите пароль", "error");
return;
}
$btn.prop("disabled", true).text("Отключение...");
try {
await Api.post("/api/auth/2fa/disable", { password });
Utils.showToast("2FA успешно отключена", "success");
window.dispatchEvent(new CustomEvent("update-2fa", { detail: false }));
window.dispatchEvent(new CustomEvent("close-disable-2fa-modal"));
$("#disable-2fa-form")[0].reset();
} catch (error) {
Utils.showToast(error.message || "Ошибка отключения 2FA", "error");
} finally {
$btn.prop("disabled", false).text("Отключить");
}
});
$("#submit-password-btn").on("click", async function () {
const $btn = $(this);
const newPass = $("#new-password").val();
const confirm = $("#confirm-password").val();
if (newPass !== confirm) {
Utils.showToast("Пароли не совпадают", "error");
return;
}
if (newPass.length < 8) {
Utils.showToast("Пароль должен быть минимум 8 символов", "error");
return;
}
$btn.prop("disabled", true).text("Сохранение...");
try {
await Api.put("/api/auth/me", { password: newPass });
Utils.showToast("Пароль успешно изменён", "success");
window.dispatchEvent(new CustomEvent("close-password-modal"));
$("#change-password-form")[0].reset();
} catch (error) {
Utils.showToast(error.message || "Ошибка смены пароля", "error");
} finally {
$btn.prop("disabled", false).text("Сменить");
}
});
});
+175
View File
@@ -0,0 +1,175 @@
const NS = "http://www.w3.org/2000/svg";
const $svg = $("#canvas");
const CONFIG = {
holeRadius: 60,
maxRadius: 220,
tilt: 0.4,
ringsCount: 7,
ringSpeed: 0.002,
ringStroke: 5,
particlesCount: 40,
particleSpeedBase: 0.02,
particleFallSpeed: 0.2,
};
function create(tag, attrs) {
const el = document.createElementNS(NS, tag);
for (let k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
const $layerBack = $(create("g", { id: "layer-back" }));
const $layerHole = $(create("g", { id: "layer-hole" }));
const $layerFront = $(create("g", { id: "layer-front" }));
$svg.append($layerBack, $layerHole, $layerFront);
const holeHalo = create("circle", {
cx: 0,
cy: 0,
r: CONFIG.holeRadius + 4,
fill: "#ffffff",
stroke: "none",
});
const holeBody = create("circle", {
cx: 0,
cy: 0,
r: CONFIG.holeRadius,
fill: "#000000",
});
$layerHole.append(holeHalo, holeBody);
class Ring {
constructor(offset) {
this.progress = offset;
const style = {
fill: "none",
stroke: "#000",
"stroke-linecap": "round",
"stroke-width": CONFIG.ringStroke,
};
this.elBack = create("path", style);
this.elFront = create("path", style);
$layerBack.append(this.elBack);
$layerFront.append(this.elFront);
}
update() {
this.progress += CONFIG.ringSpeed;
if (this.progress >= 1) this.progress -= 1;
const t = this.progress;
const currentR =
CONFIG.maxRadius - t * (CONFIG.maxRadius - CONFIG.holeRadius);
const currentRy = currentR * CONFIG.tilt;
const distFromHole = currentR - CONFIG.holeRadius;
const distFromEdge = CONFIG.maxRadius - currentR;
const fadeHole = Math.min(1, distFromHole / 40);
const fadeEdge = Math.min(1, distFromEdge / 40);
const opacity = fadeHole * fadeEdge;
if (opacity <= 0.01) {
this.elBack.setAttribute("opacity", 0);
this.elFront.setAttribute("opacity", 0);
} else {
const dBack = `M ${-currentR} 0 A ${currentR} ${currentRy} 0 0 1 ${currentR} 0`;
const dFront = `M ${-currentR} 0 A ${currentR} ${currentRy} 0 0 0 ${currentR} 0`;
this.elBack.setAttribute("d", dBack);
this.elFront.setAttribute("d", dFront);
this.elBack.setAttribute("opacity", opacity);
this.elFront.setAttribute("opacity", opacity);
const sw =
CONFIG.ringStroke *
(0.6 + 0.4 * (distFromHole / (CONFIG.maxRadius - CONFIG.holeRadius)));
this.elBack.setAttribute("stroke-width", sw);
this.elFront.setAttribute("stroke-width", sw);
}
}
}
class Particle {
constructor() {
this.el = create("circle", { fill: "#000" });
this.reset(true);
$layerFront.append(this.el);
this.inFront = true;
}
reset(randomStart = false) {
this.angle = Math.random() * Math.PI * 2;
this.r = randomStart
? CONFIG.holeRadius +
Math.random() * (CONFIG.maxRadius - CONFIG.holeRadius)
: CONFIG.maxRadius;
this.speed = CONFIG.particleSpeedBase + Math.random() * 0.02;
this.size = 1.5 + Math.random() * 2.5;
}
update() {
const acceleration = CONFIG.maxRadius / this.r;
this.angle += this.speed * acceleration;
this.r -= CONFIG.particleFallSpeed * (acceleration * 0.8);
const x = Math.cos(this.angle) * this.r;
const y = Math.sin(this.angle) * this.r * CONFIG.tilt;
const isNowFront = Math.sin(this.angle) > 0;
if (this.inFront !== isNowFront) {
this.inFront = isNowFront;
if (this.inFront) {
$layerFront.append(this.el);
} else {
$layerBack.append(this.el);
}
}
const distFromHole = this.r - CONFIG.holeRadius;
const distFromEdge = CONFIG.maxRadius - this.r;
const fadeHole = Math.min(1, distFromHole / 30);
const fadeEdge = Math.min(1, distFromEdge / 30);
const opacity = fadeHole * fadeEdge;
this.el.setAttribute("cx", x);
this.el.setAttribute("cy", y);
this.el.setAttribute("r", this.size * Math.min(1, this.r / 100));
this.el.setAttribute("opacity", opacity);
if (this.r <= CONFIG.holeRadius) {
this.reset(false);
}
}
}
const rings = [];
for (let i = 0; i < CONFIG.ringsCount; i++) {
rings.push(new Ring(i / CONFIG.ringsCount));
}
const particles = [];
for (let i = 0; i < CONFIG.particlesCount; i++) {
particles.push(new Particle());
}
function animate() {
rings.forEach((r) => r.update());
particles.forEach((p) => p.update());
requestAnimationFrame(animate);
}
animate();
@@ -5,18 +5,11 @@ $(document).ready(() => {
); );
return; return;
} }
setTimeout(() => {
if (!window.isAdmin()) {
$("#users-container").html(
document.getElementById("access-denied-template").innerHTML,
);
}
}, 100);
let allRoles = []; let allRoles = [];
let users = []; let users = [];
let currentPage = 1; let currentPage = 1;
let pageSize = 20; const pageSize = 20;
let totalUsers = 0; let totalUsers = 0;
let searchQuery = ""; let searchQuery = "";
let selectedFilterRoles = new Set(); let selectedFilterRoles = new Set();
@@ -28,8 +21,8 @@ $(document).ready(() => {
showLoadingState(); showLoadingState();
Promise.all([ Promise.all([
Api.get("/api/auth/users?skip=0&limit=100"), Api.get("/api/users?skip=0&limit=100"),
Api.get("/api/auth/roles"), Api.get("/api/users/roles"),
]) ])
.then(([usersData, rolesData]) => { .then(([usersData, rolesData]) => {
users = usersData.users; users = usersData.users;
@@ -57,12 +50,12 @@ $(document).ready(() => {
.attr("data-name", role.name) .attr("data-name", role.name)
.html( .html(
`<div> `<div>
<div class="font-medium text-sm">${Utils.escapeHtml(role.name)}</div> <div class="font-medium text-sm">${Utils.escapeHtml(role.name)}</div>
${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""} ${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""}
</div> </div>
<svg class="check-icon w-4 h-4 text-green-600 hidden" fill="currentColor" viewBox="0 0 20 20"> <svg class="check-icon w-4 h-4 text-green-600 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>`, </svg>`,
) )
.appendTo($dropdown); .appendTo($dropdown);
}); });
@@ -139,13 +132,11 @@ $(document).ready(() => {
} }
function loadUsers() { function loadUsers() {
const params = new URLSearchParams(); const skip = (currentPage - 1) * pageSize;
params.append("skip", (currentPage - 1) * pageSize);
params.append("limit", pageSize);
showLoadingState(); showLoadingState();
Api.get(`/api/auth/users?${params.toString()}`) Api.get(`/api/users?skip=${skip}&limit=${pageSize}`)
.then((data) => { .then((data) => {
users = data.users; users = data.users;
totalUsers = data.total; totalUsers = data.total;
@@ -220,6 +211,8 @@ $(document).ready(() => {
} }
const rolesContainer = clone.querySelector(".user-roles"); const rolesContainer = clone.querySelector(".user-roles");
let totalPayroll = 0;
if (user.roles && user.roles.length > 0) { if (user.roles && user.roles.length > 0) {
user.roles.forEach((roleName) => { user.roles.forEach((roleName) => {
const badge = roleBadgeTpl.content.cloneNode(true); const badge = roleBadgeTpl.content.cloneNode(true);
@@ -238,12 +231,25 @@ $(document).ready(() => {
removeBtn.dataset.userId = user.id; removeBtn.dataset.userId = user.id;
removeBtn.dataset.roleName = roleName; removeBtn.dataset.roleName = roleName;
rolesContainer.appendChild(badge); rolesContainer.appendChild(badge);
const fullRole = allRoles.find((r) => r.name === roleName);
if (fullRole && fullRole.payroll) {
totalPayroll += fullRole.payroll;
}
}); });
} else { } else {
rolesContainer.innerHTML = rolesContainer.innerHTML =
'<span class="text-gray-400 text-sm italic">Нет ролей</span>'; '<span class="text-gray-400 text-sm italic">Нет ролей</span>';
} }
if (totalPayroll > 0) {
const payrollBadge = clone.querySelector(".user-payroll");
const payrollAmount = clone.querySelector(".user-payroll-amount");
payrollBadge.classList.remove("hidden");
payrollAmount.textContent = totalPayroll.toLocaleString("ru-RU");
}
const addRoleBtn = clone.querySelector(".add-role-btn"); const addRoleBtn = clone.querySelector(".add-role-btn");
addRoleBtn.dataset.userId = user.id; addRoleBtn.dataset.userId = user.id;
@@ -265,30 +271,30 @@ $(document).ready(() => {
function showLoadingState() { function showLoadingState() {
$("#users-container").html(` $("#users-container").html(`
<div class="space-y-4"> <div class="space-y-4">
${Array(3) ${Array(3)
.fill() .fill()
.map( .map(
() => ` () => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse"> <div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="w-14 h-14 bg-gray-200 rounded-full"></div> <div class="w-14 h-14 bg-gray-200 rounded-full"></div>
<div class="flex-1"> <div class="flex-1">
<div class="h-5 bg-gray-200 rounded w-1/4 mb-2"></div> <div class="h-5 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/3 mb-2"></div> <div class="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2 mb-3"></div> <div class="h-4 bg-gray-200 rounded w-1/2 mb-3"></div>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="h-6 bg-gray-200 rounded-full w-16"></div> <div class="h-6 bg-gray-200 rounded-full w-16"></div>
<div class="h-6 bg-gray-200 rounded-full w-20"></div> <div class="h-6 bg-gray-200 rounded-full w-20"></div>
</div> </div>
</div> </div>
</div> </div>
</div>
`,
)
.join("")}
</div> </div>
`); `,
)
.join("")}
</div>
`);
} }
function renderPagination() { function renderPagination() {
@@ -297,12 +303,12 @@ $(document).ready(() => {
if (totalPages <= 1) return; if (totalPages <= 1) return;
const $pagination = $(` const $pagination = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4"> <div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === 1 ? "disabled" : ""}>&larr;</button> <button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<div id="page-numbers" class="flex gap-1"></div> <div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button> <button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</div> </div>
`); `);
const $pageNumbers = $pagination.find("#page-numbers"); const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages); const pages = generatePageNumbers(currentPage, totalPages);
@@ -313,8 +319,8 @@ $(document).ready(() => {
} else { } else {
const isActive = page === currentPage; const isActive = page === currentPage;
$pageNumbers.append(` $pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button> <button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
`); `);
} }
}); });
@@ -383,13 +389,13 @@ $(document).ready(() => {
} }
const $dropdown = $(` const $dropdown = $(`
<div class="role-add-dropdown absolute z-50 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden" style="min-width: 200px;"> <div class="role-add-dropdown absolute z-50 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden" style="min-width: 200px;">
<div class="p-2 border-b border-gray-100"> <div class="p-2 border-b border-gray-100">
<input type="text" class="role-search-input w-full border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" placeholder="Поиск роли..." autocomplete="off" /> <input type="text" class="role-search-input w-full border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" placeholder="Поиск роли..." autocomplete="off" />
</div> </div>
<div class="role-items max-h-48 overflow-y-auto"></div> <div class="role-items max-h-48 overflow-y-auto"></div>
</div> </div>
`); `);
const $roleItems = $dropdown.find(".role-items"); const $roleItems = $dropdown.find(".role-items");
@@ -402,12 +408,12 @@ $(document).ready(() => {
: "hover:bg-gray-50"; : "hover:bg-gray-50";
$roleItems.append(` $roleItems.append(`
<div class="role-item p-2 ${roleClass} cursor-pointer transition-colors border-b border-gray-50 last:border-0" data-role-name="${Utils.escapeHtml(role.name)}"> <div class="role-item p-2 ${roleClass} cursor-pointer transition-colors border-b border-gray-50 last:border-0" data-role-name="${Utils.escapeHtml(role.name)}">
<div class="font-medium text-sm text-gray-800">${Utils.escapeHtml(role.name)}</div> <div class="font-medium text-sm text-gray-800">${Utils.escapeHtml(role.name)}</div>
${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""} ${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""}
${role.payroll ? `<div class="text-xs text-green-600">Оклад: ${role.payroll}</div>` : ""} ${role.payroll ? `<div class="text-xs text-green-600">Оклад: ${role.payroll}</div>` : ""}
</div> </div>
`); `);
}); });
const $button = $(button); const $button = $(button);
@@ -457,12 +463,9 @@ $(document).ready(() => {
} }
function addRoleToUser(userId, roleName) { function addRoleToUser(userId, roleName) {
Api.request( Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`, method: "POST",
{ })
method: "POST",
},
)
.then((updatedUser) => { .then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId); const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) { if (userIndex !== -1) {
@@ -485,12 +488,9 @@ $(document).ready(() => {
return; return;
} }
Api.request( Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`, method: "DELETE",
{ })
method: "DELETE",
},
)
.then((updatedUser) => { .then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId); const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) { if (userIndex !== -1) {
@@ -514,7 +514,6 @@ $(document).ready(() => {
$("#edit-user-fullname").val(user.full_name || ""); $("#edit-user-fullname").val(user.full_name || "");
$("#edit-user-password").val(""); $("#edit-user-password").val("");
$("#edit-user-active").prop("checked", user.is_active); $("#edit-user-active").prop("checked", user.is_active);
$("#edit-user-verified").prop("checked", user.is_verified);
$("#edit-user-modal").removeClass("hidden"); $("#edit-user-modal").removeClass("hidden");
} }
@@ -529,6 +528,7 @@ $(document).ready(() => {
const email = $("#edit-user-email").val().trim(); const email = $("#edit-user-email").val().trim();
const fullName = $("#edit-user-fullname").val().trim(); const fullName = $("#edit-user-fullname").val().trim();
const password = $("#edit-user-password").val(); const password = $("#edit-user-password").val();
const isActive = $("#edit-user-active").prop("checked");
if (!email) { if (!email) {
Utils.showToast("Email обязателен", "error"); Utils.showToast("Email обязателен", "error");
@@ -538,40 +538,26 @@ $(document).ready(() => {
const updateData = { const updateData = {
email: email, email: email,
full_name: fullName || null, full_name: fullName || null,
is_active: isActive,
}; };
if (password) { if (password) {
updateData.password = password; updateData.password = password;
} }
// Note: This uses the /api/auth/me endpoint structure Api.put(`/api/users/${userId}`, updateData)
// For admin editing other users, you might need a different endpoint
// Here we'll simulate by updating local data
Api.put(`/api/auth/me`, updateData)
.then((updatedUser) => { .then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId); const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) { if (userIndex !== -1) {
users[userIndex] = { ...users[userIndex], ...updatedUser }; users[userIndex] = updatedUser;
} }
renderUsers(); renderUsers();
closeEditModal(); closeEditModal();
Utils.showToast("Пользователь обновлён", "success"); Utils.showToast("Пользователь обновлён", "success");
}) })
.catch((error) => { .catch((error) => {
console.warn("API update failed, updating locally:", error); console.error(error);
const userIndex = users.findIndex((u) => u.id === userId); Utils.showToast(error.message || "Ошибка обновления", "error");
if (userIndex !== -1) {
users[userIndex].email = email;
users[userIndex].full_name = fullName || null;
users[userIndex].is_active = $("#edit-user-active").prop("checked");
users[userIndex].is_verified = $("#edit-user-verified").prop(
"checked",
);
}
renderUsers();
closeEditModal();
Utils.showToast("Изменения сохранены локально", "info");
}); });
} }
@@ -586,7 +572,16 @@ $(document).ready(() => {
} }
userToDelete = user; userToDelete = user;
const actionText = user.is_active ? "деактивировать" : "удалить навсегда";
$("#delete-user-name").text(user.full_name || user.username); $("#delete-user-name").text(user.full_name || user.username);
$("#delete-user-modal .text-sm.text-gray-500").html(
`Вы уверены, что хотите <strong>${actionText}</strong> пользователя <strong>${Utils.escapeHtml(user.full_name || user.username)}</strong>?` +
(user.is_active
? ""
: " <span class='text-red-600'>Это действие необратимо!</span>"),
);
$("#delete-user-modal").removeClass("hidden"); $("#delete-user-modal").removeClass("hidden");
} }
@@ -598,23 +593,27 @@ $(document).ready(() => {
function confirmDeleteUser() { function confirmDeleteUser() {
if (!userToDelete) return; if (!userToDelete) return;
Utils.showToast("Удаление пользователей не поддерживается API", "error"); Api.delete(`/api/users/${userToDelete.id}`)
closeDeleteModal(); .then((deletedUser) => {
if (deletedUser.is_active === false) {
// When API supports deletion: const userIndex = users.findIndex((u) => u.id === userToDelete.id);
// Api.delete(`/api/auth/users/${userToDelete.id}`) if (userIndex !== -1) {
// .then(() => { users[userIndex] = deletedUser;
// users = users.filter(u => u.id !== userToDelete.id); }
// totalUsers--; Utils.showToast("Пользователь деактивирован", "success");
// $("#total-users-count").text(totalUsers); } else {
// renderUsers(); users = users.filter((u) => u.id !== userToDelete.id);
// closeDeleteModal(); totalUsers--;
// Utils.showToast("Пользователь удалён", "success"); $("#total-users-count").text(totalUsers);
// }) Utils.showToast("Пользователь удалён", "success");
// .catch((error) => { }
// console.error(error); renderUsers();
// Utils.showToast(error.message || "Ошибка удаления", "error"); closeDeleteModal();
// }); })
.catch((error) => {
console.error(error);
Utils.showToast(error.message || "Ошибка удаления", "error");
});
} }
$("#users-container").on("click", ".add-role-btn", function (e) { $("#users-container").on("click", ".add-role-btn", function (e) {
-128
View File
@@ -1,128 +0,0 @@
$(document).ready(() => {
const token = StorageHelper.get("access_token");
if (!token) {
window.location.href = "/auth";
return;
}
loadProfile();
function loadProfile() {
Promise.all([
Api.get("/api/auth/me"),
Api.get("/api/auth/roles").catch(() => ({ roles: [] })),
])
.then(async ([user, rolesData]) => {
document.title = `LiB - ${user.full_name || user.username}`;
await renderProfileHeader(user);
renderInfo(user);
renderRoles(user.roles || [], rolesData.roles || []);
$("#account-section, #roles-section").removeClass("hidden");
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки профиля", "error");
});
}
async function renderProfileHeader(user) {
const avatarUrl = await Utils.getGravatarUrl(user.email);
const displayName = Utils.escapeHtml(user.full_name || user.username);
$("#profile-card").html(`
<div class="flex flex-col sm:flex-row items-center sm:items-start">
<div class="relative mb-4 sm:mb-0 sm:mr-6">
<img src="${avatarUrl}" class="w-24 h-24 rounded-full object-cover border-4 border-gray-200">
${user.is_verified ? '<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1 border-2 border-white"><svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg></div>' : ""}
</div>
<div class="flex-1 text-center sm:text-left">
<h1 class="text-2xl font-bold text-gray-900 mb-1">${displayName}</h1>
<p class="text-gray-500 mb-3">@${Utils.escapeHtml(user.username)}</p>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm ${user.is_active ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}">
${user.is_active ? "Активен" : "Заблокирован"}
</span>
</div>
</div>
`);
}
function renderInfo(user) {
const fields = [
{ label: "ID пользователя", value: user.id },
{ label: "Email", value: user.email },
{ label: "Полное имя", value: user.full_name || "Не указано" },
];
const html = fields
.map(
(f) => `
<div class="flex justify-between py-2 border-b last:border-0">
<span class="text-gray-500">${f.label}</span>
<span class="font-medium text-gray-900">${Utils.escapeHtml(String(f.value))}</span>
</div>
`,
)
.join("");
$("#account-info").html(html);
}
function renderRoles(userRoles, allRoles) {
const $container = $("#roles-container");
if (userRoles.length === 0) {
$container.html('<p class="text-gray-500">Нет ролей</p>');
return;
}
const roleMap = {};
allRoles.forEach((r) => (roleMap[r.name] = r.description));
const html = userRoles
.map(
(role) => `
<div class="p-3 bg-blue-50 border border-blue-100 rounded text-blue-800">
<div class="font-bold capitalize">${Utils.escapeHtml(role)}</div>
<div class="text-xs opacity-75">${Utils.escapeHtml(roleMap[role] || "")}</div>
</div>
`,
)
.join("");
$container.html(html);
}
$("#submit-password-btn").on("click", async function () {
const $btn = $(this);
const newPass = $("#new-password").val();
const confirm = $("#confirm-password").val();
if (newPass !== confirm) {
Utils.showToast("Пароли не совпадают", "error");
return;
}
if (newPass.length < 4) {
Utils.showToast("Пароль слишком короткий", "error");
return;
}
$btn.prop("disabled", true).text("Меняем...");
try {
await Api.put("/api/auth/me", {
password: newPass,
});
Utils.showToast("Пароль успешно изменен", "success");
window.dispatchEvent(new CustomEvent("close-modal"));
$("#change-password-form")[0].reset();
} catch (error) {
console.error(error);
Utils.showToast(error.message || "Ошибка смены пароля", "error");
} finally {
$btn.prop("disabled", false).text("Сменить");
}
});
});
+26
View File
@@ -242,3 +242,29 @@ button:disabled {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
footer a {
color: #9ca3af;
text-decoration: none;
transition: all 0.25s ease;
position: relative;
}
footer a::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 1px;
background: #fff;
transition: width 0.25s ease;
}
footer a:hover {
color: #fff;
}
footer a:hover::after {
width: 100%;
}
+75 -7
View File
@@ -19,8 +19,8 @@ const StorageHelper = {
const Utils = { const Utils = {
escapeHtml: (text) => { escapeHtml: (text) => {
if (!text) return ""; if (text === null || text === undefined) return "";
return text.replace( return String(text).replace(
/[&<>"']/g, /[&<>"']/g,
(m) => (m) =>
({ ({
@@ -112,11 +112,18 @@ const Api = {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error( const error = new Error("API Error");
errorData.detail || Object.assign(error, errorData);
errorData.error_description ||
`Ошибка ${response.status}`, if (typeof errorData.detail === "string") {
); error.message = errorData.detail;
} else if (errorData.error_description) {
error.message = errorData.error_description;
} else if (!errorData.detail) {
error.message = `Ошибка ${response.status}`;
}
throw error;
} }
return response.json(); return response.json();
} catch (error) { } catch (error) {
@@ -153,6 +160,67 @@ const Api = {
body: formData.toString(), body: formData.toString(),
}); });
}, },
async uploadFile(endpoint, formData) {
const fullUrl = this.getBaseUrl() + endpoint;
const token = StorageHelper.get("access_token");
const headers = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
try {
const response = await fetch(fullUrl, {
method: "POST",
headers: headers,
body: formData,
credentials: "include",
});
if (response.status === 401) {
const refreshed = await Auth.tryRefresh();
if (refreshed) {
headers["Authorization"] =
`Bearer ${StorageHelper.get("access_token")}`;
const retryResponse = await fetch(fullUrl, {
method: "POST",
headers: headers,
body: formData,
credentials: "include",
});
if (retryResponse.ok) {
return retryResponse.json();
}
}
Auth.logout();
return null;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
let errorMessage = `Ошибка ${response.status}`;
if (typeof errorData.detail === "string") {
errorMessage = errorData.detail;
} else if (Array.isArray(errorData.detail)) {
errorMessage = errorData.detail.map((e) => e.msg || e).join(", ");
} else if (errorData.detail?.message) {
errorMessage = errorData.detail.message;
} else if (errorData.message) {
errorMessage = errorData.message;
}
const error = new Error(errorMessage);
error.status = response.status;
throw error;
}
return response.json();
} catch (error) {
throw error;
}
},
}; };
const Auth = { const Auth = {
+3 -4
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %} {% {% extends "base.html" %}{% block content %}
block content %}
<div class="flex flex-1 items-center justify-center p-4 bg-gray-100"> <div class="flex flex-1 items-center justify-center p-4 bg-gray-100">
<div <div
class="w-full max-w-4xl bg-white rounded-lg shadow-md overflow-hidden flex flex-col md:flex-row" class="w-full max-w-4xl bg-white rounded-lg shadow-md overflow-hidden flex flex-col md:flex-row"
@@ -11,7 +10,7 @@ block content %}
Настройка 2FA Настройка 2FA
</h2> </h2>
<p class="text-sm text-gray-500 text-center mb-6"> <p class="text-sm text-gray-500 text-center mb-6">
Отсканируйте код в Google Authenticator Отсканируйте код в приложении Аутентификатора
</p> </p>
<div <div
id="qr-container" id="qr-container"
@@ -155,5 +154,5 @@ block content %}
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/2fa.js"></script> <script src="/static/page/2fa.js"></script>
{% endblock %} {% endblock %}
+4 -9
View File
@@ -1,17 +1,16 @@
{% extends "base.html" %} {% block title %}Аналитика - LiB{% endblock %} {% block content %} {% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-7xl"> <div class="container mx-auto p-4 max-w-7xl">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1> <h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
<p class="text-sm text-gray-500">Статистика и графики по выдачам книг</p> <p class="text-sm text-gray-500">Статистика и графики по выдачам книг</p>
</div> </div>
<!-- Период анализа -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6 border border-gray-100"> <div class="bg-white rounded-xl shadow-sm p-4 mb-6 border border-gray-100">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-600">Период анализа:</label> <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"> <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="7" selected>7 дней</option>
<option value="30" selected>30 дней</option> <option value="30">30 дней</option>
<option value="90">90 дней</option> <option value="90">90 дней</option>
<option value="180">180 дней</option> <option value="180">180 дней</option>
<option value="365">365 дней</option> <option value="365">365 дней</option>
@@ -22,7 +21,6 @@
</div> </div>
</div> </div>
<!-- Общая статистика -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6"> <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="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -109,7 +107,6 @@
</div> </div>
</div> </div>
<!-- Графики -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <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"> <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> <h2 class="text-base font-medium text-gray-700 mb-6">Выдачи по дням</h2>
@@ -126,7 +123,6 @@
</div> </div>
</div> </div>
<!-- Топ книг -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100"> <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> <h2 class="text-base font-medium text-gray-700 mb-6">Топ книг по выдачам</h2>
<div id="top-books-container" class="space-y-2"> <div id="top-books-container" class="space-y-2">
@@ -137,6 +133,5 @@
{% endblock %} {% block extra_head %} {% endblock %} {% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/analytics.js"></script> <script src="/static/page/analytics.js"></script>
{% endblock %} {% endblock %}
+300 -20
View File
@@ -2,15 +2,16 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title id="pageTitle">Loading...</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_info.title }}</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 800px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
line-height: 1.6;
color: #333; color: #333;
} }
h1 { h1 {
@@ -20,11 +21,13 @@
} }
ul { ul {
list-style: none; list-style: none;
display: flex;
padding: 0; padding: 0;
margin: 0;
gap: 20px;
flex-wrap: wrap;
} }
li { li { margin: 10px 0; }
margin: 15px 0;
}
a { a {
display: inline-block; display: inline-block;
padding: 8px 15px; padding: 8px 15px;
@@ -33,29 +36,306 @@
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.3s; transition: background-color 0.3s;
font-size: 14px;
} }
a:hover { a:hover { background-color: #2980b9; }
background-color: #2980b9; p { margin: 5px 0; }
.status-ok { color: #27ae60; }
.status-error { color: #e74c3c; }
.server-time { color: #7f8c8d; font-size: 12px; }
#erDiagram {
position: relative;
width: 100%; height: 700px;
border: 1px solid #ddd;
border-radius: 8px;
margin-top: 30px;
background: #fcfcfc;
background-image:
linear-gradient(#eee 1px, transparent 1px),
linear-gradient(90deg, #eee 1px, transparent 1px);
background-size: 20px 20px;
background-position: -1px -1px;
overflow: hidden;
cursor: grab;
} }
p { #erDiagram:active { cursor: grabbing; }
margin: 5px 0; .er-table {
position: absolute;
width: 200px;
background: #fff;
border: 1px solid #bdc3c7;
border-radius: 5px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
font-size: 12px;
z-index: 10;
display: flex;
flex-direction: column;
} }
.er-table-header {
background: #3498db;
color: #ecf0f1;
padding: 8px;
font-weight: bold;
text-align: center;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.er-table-body {
background: #fff;
padding: 4px 0;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.er-field {
padding: 4px 10px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.er-field:hover {
background-color: #ecf0f1;
color: #2980b9;
}
.relation-label {
font-size: 10px;
font-weight: bold;
background: white;
padding: 2px 4px;
border: 1px solid #bdc3c7;
border-radius: 3px;
color: #7f8c8d;
z-index: 20;
}
.jtk-connector { z-index: 5; }
.jtk-endpoint { z-index: 5; }
</style> </style>
</head> </head>
<body> <body>
<img src="/favicon.ico" /> <img src="/favicon.ico" />
<h1>Welcome to {{ app_info.title }}!</h1> <h1 id="mainTitle">Загрузка...</h1>
<p>Description: {{ app_info.description }}</p> <p>Версия: <span id="appVersion">-</span></p>
<p>Version: {{ app_info.version }}</p> <p>Описание: <span id="appDescription">-</span></p>
<p>Current Time: {{ server_time }}</p> <p>Статус: <span id="appStatus">-</span></p>
<p>Status: {{ status }}</p> <p class="server-time">Время сервера: <span id="serverTime">-</span></p>
<ul> <ul>
<li><a href="/">Home page</a></li> <li><a href="/">Главная</a></li>
<li>
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
</li>
<li><a href="/docs">Swagger UI</a></li> <li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li> <li><a href="/redoc">ReDoc</a></li>
<li><a href="https://github.com/wowlikon/LiB">Исходный код</a></li>
</ul> </ul>
<h2>Интерактивная ER диаграмма</h2>
<div id="erDiagram"></div>
<script>
async function fetchInfo() {
try {
const response = await fetch('/api/info');
const data = await response.json();
document.getElementById('pageTitle').textContent = data.app_info.title;
document.getElementById('mainTitle').textContent = `Добро пожаловать в ${data.app_info.title} API!`;
document.getElementById('appVersion').textContent = data.app_info.version;
document.getElementById('appDescription').textContent = data.app_info.description;
const statusEl = document.getElementById('appStatus');
statusEl.textContent = data.status;
statusEl.className = data.status === 'ok' ? 'status-ok' : 'status-error';
document.getElementById('serverTime').textContent = new Date(data.server_time).toLocaleString();
} catch (error) {
console.error('Ошибка загрузки info:', error);
document.getElementById('appStatus').textContent = 'Ошибка соединения';
document.getElementById('appStatus').className = 'status-error';
}
}
async function fetchSchemaAndRender() {
try {
const response = await fetch('/api/schema');
const diagramData = await response.json();
renderDiagram(diagramData);
} catch (error) {
console.error('Ошибка загрузки схемы:', error);
document.getElementById('erDiagram').innerHTML = '<p style="padding:20px;color:#e74c3c;">Ошибка загрузки схемы</p>';
}
}
function renderDiagram(diagramData) {
jsPlumb.ready(function () {
const instance = jsPlumb.getInstance({
Container: "erDiagram",
Endpoint: "Blank",
Connector: ["Flowchart", { stub: 30, gap: 0, cornerRadius: 5, alwaysRespectStubs: true }]
});
const container = document.getElementById("erDiagram");
const tableWidth = 200;
const g = new dagre.graphlib.Graph();
g.setGraph({
nodesep: 60, ranksep: 80,
marginx: 20, marginy: 20,
rankdir: 'LR',
});
g.setDefaultEdgeLabel(() => ({}));
const fieldIndexByEntity = {};
diagramData.entities.forEach(entity => {
const idxMap = {};
entity.fields.forEach((field, idx) => { idxMap[field.id] = idx; });
fieldIndexByEntity[entity.id] = idxMap;
});
diagramData.entities.forEach(entity => {
const table = document.createElement("div");
table.className = "er-table";
table.id = "table-" + entity.id;
const header = document.createElement("div");
header.className = "er-table-header";
header.textContent = entity.title || entity.id;
const body = document.createElement("div");
body.className = "er-table-body";
entity.fields.forEach(field => {
const row = document.createElement("div");
row.className = "er-field";
row.id = `field-${entity.id}-${field.id}`;
row.style.display = "flex";
row.style.alignItems = "center";
const labelSpan = document.createElement("span");
labelSpan.textContent = field.label || field.id;
row.appendChild(labelSpan);
if (field.tooltip) {
row.title = field.tooltip;
const tip = document.createElement("span");
tip.textContent = "ⓘ";
tip.title = field.tooltip;
tip.style.marginLeft = "4px";
tip.style.marginRight = "0";
tip.style.fontSize = "10px";
tip.style.cursor = "help";
tip.style.marginLeft = "auto";
row.appendChild(tip);
}
body.appendChild(row);
});
table.appendChild(header);
table.appendChild(body);
container.appendChild(table);
const estimatedHeight = 20 + (entity.fields.length * 26);
g.setNode(entity.id, { width: tableWidth, height: estimatedHeight });
});
const layoutEdges = [];
const m2oGroups = {};
diagramData.relations.forEach(rel => {
const isManyToOne = (rel.fromMultiplicity === 'N' || rel.fromMultiplicity === '*') && (rel.toMultiplicity === '1');
if (isManyToOne) {
const fi = (fieldIndexByEntity[rel.fromEntity] || {})[rel.fromField] ?? 0;
if (!m2oGroups[rel.fromEntity]) m2oGroups[rel.fromEntity] = [];
m2oGroups[rel.fromEntity].push({ rel, fieldIndex: fi });
} else {
layoutEdges.push({ source: rel.fromEntity, target: rel.toEntity });
}
});
Object.keys(m2oGroups).forEach(fromEntity => {
const arr = m2oGroups[fromEntity];
arr.sort((a, b) => a.fieldIndex - b.fieldIndex);
arr.forEach((item, idx) => {
const rel = item.rel;
if (idx % 2 === 0) { layoutEdges.push({ source: rel.toEntity, target: rel.fromEntity });
} else { layoutEdges.push({ source: rel.fromEntity, target: rel.toEntity }); }
});
});
layoutEdges.forEach(e => g.setEdge(e.source, e.target));
dagre.layout(g);
g.nodes().forEach(function(v) {
const node = g.node(v);
const el = document.getElementById("table-" + v);
el.style.left = (node.x - (tableWidth / 2)) + "px";
el.style.top = (node.y - (node.height / 2)) + "px";
});
diagramData.relations.forEach(rel => {
const overlays = [];
if (rel.fromMultiplicity === '1') {
overlays.push(["Arrow", {
location: 8, width: 14, length: 1, foldback: 1, direction: 1,
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
}]);
overlays.push(["Arrow", {
location: 14, width: 14, length: 1, foldback: 1, direction: 1,
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
}]);
} else if (rel.fromMultiplicity === 'N' || rel.fromMultiplicity === '*') {
overlays.push(["Arrow", {
location: 8, width: 14, length: 1, foldback: 1, direction: 1,
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
}]);
overlays.push(["Arrow", {
location: 10, width: 14, length: 10, foldback: 0.1, direction: 1,
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
}]);
}
if (rel.toMultiplicity === '1') {
overlays.push(["Arrow", {
location: -8, width: 14, length: 1, foldback: 1, direction: -1,
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
}]);
overlays.push(["Arrow", {
location: -14, width: 14, length: 1, foldback: 1, direction: -1,
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
}]);
} else if (rel.toMultiplicity === 'N' || rel.toMultiplicity === '*') {
overlays.push(["Arrow", {
location: -8, width: 14, length: 1, foldback: 1, direction: -1,
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
}]);
overlays.push(["Arrow", {
location: -10, width: 14, length: 10, foldback: 0.1, direction: -1,
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
}]);
}
instance.connect({
source: `field-${rel.fromEntity}-${rel.fromField}`,
target: `field-${rel.toEntity}-${rel.toField}`,
anchor: ["Continuous", { faces: ["left", "right"] }],
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2 },
hoverPaintStyle: { stroke: "#3498db", strokeWidth: 3 },
overlays: overlays
});
});
const tableIds = diagramData.entities.map(e => "table-" + e.id);
instance.draggable(tableIds, {containment: "parent", stop: instance.repaintEverything});
});
}
fetchInfo();
setInterval(fetchInfo, 60000);
fetchSchemaAndRender();
</script>
</body> </body>
</html> </html>
+301 -268
View File
@@ -1,323 +1,356 @@
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %} {% {% extends "base.html" %}{% block content %}
block content %}
<div class="flex flex-1 items-center justify-center p-4"> <div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<div class="bg-white rounded-lg shadow-md overflow-hidden"> <div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="flex border-b border-gray-200"> <div id="auth-tabs" class="flex border-b border-gray-200">
<button <button type="button" id="login-tab"
type="button" class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500">
id="login-tab"
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500"
>
Вход Вход
</button> </button>
<button <button type="button" id="register-tab"
type="button" class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600">
id="register-tab"
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600"
>
Регистрация Регистрация
</button> </button>
</div> </div>
<form id="login-form" class="p-6"> <form id="login-form" class="p-6">
<div class="mb-4"> <div id="credentials-section">
<label <div class="mb-4">
for="login-username" <label for="login-username" class="block text-sm font-medium text-gray-700 mb-2">
class="block text-sm font-medium text-gray-700 mb-2" Имя пользователя
>Имя пользователя</label </label>
> <input type="text" id="login-username" name="username"
<input
type="text"
id="login-username"
name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Введите имя пользователя"
required
/>
</div>
<div class="mb-4">
<label
for="login-password"
class="block text-sm font-medium text-gray-700 mb-2"
>Пароль</label
>
<div class="relative">
<input
type="password"
id="login-password"
name="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Введите пароль" placeholder="Введите имя пользователя" required />
required </div>
/> <div class="mb-4">
<button <label for="login-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
type="button" <div class="relative">
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600" <input type="password" id="login-password" name="password"
> class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
<svg placeholder="Введите пароль" required />
class="eye-open w-5 h-5" <button type="button"
fill="none" class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
stroke="currentColor" <svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
viewBox="0 0 24 24" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
> d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
stroke-linecap="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
stroke-linejoin="round" </path>
stroke-width="2" </svg>
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" <svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<path d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
stroke-linecap="round" </path>
stroke-linejoin="round" </svg>
stroke-width="2" </button>
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" </div>
></path> </div>
</svg> <div class="flex items-center justify-between mb-6">
<svg <label class="custom-checkbox flex items-center text-sm text-gray-600">
class="eye-closed w-5 h-5 hidden" <input type="checkbox" id="remember-me" />
fill="none" <span class="checkmark"></span>Запомнить меня
stroke="currentColor" </label>
viewBox="0 0 24 24" <button type="button" id="forgot-password-btn"
> class="text-sm text-gray-500 hover:text-gray-700 transition">
<path Забыли пароль?
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
</svg>
</button> </button>
</div> </div>
</div> </div>
<div class="flex items-center justify-between mb-6">
<label <div id="totp-section" class="hidden">
class="custom-checkbox flex items-center text-sm text-gray-600" <div class="text-center mb-4">
> <div class="w-20 h-20 mx-auto relative flex items-center justify-center mb-3">
<input type="checkbox" id="remember-me" /> <svg class="absolute inset-0 w-full h-full -rotate-90" viewBox="0 0 80 80">
<span class="checkmark"></span>Запомнить меня <circle cx="40" cy="40" r="38" fill="none" stroke="#e5e7eb" stroke-width="2" />
</label> <circle id="lock-progress-circle" cx="40" cy="40" r="38" fill="none" stroke="#000000"
<a stroke-width="2" stroke-linecap="round"
href="#" style="stroke-dasharray: 238.761; stroke-dashoffset: 238.761;" />
class="text-sm text-gray-500 hover:text-gray-700 transition" </svg>
>Забыли пароль?</a <div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center z-10">
> <svg class="w-8 h-8 text-gray-600" 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>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-800">Двухфакторная аутентификация</h3>
<p class="text-sm text-gray-500 mt-1">
Введите код из приложения аутентификатора
</p>
</div>
<div class="mb-6">
<input type="text" id="login-totp" name="totp_code"
class="w-full p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200 text-center text-3xl tracking-[0.5em] font-mono"
placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
</div>
<button type="button" id="back-to-credentials-btn"
class="w-full mb-4 text-gray-500 hover:text-gray-700 text-sm flex items-center justify-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Назад
</button>
</div> </div>
<div
id="login-error" <button type="submit" id="login-submit"
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm" class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
></div>
<button
type="submit"
id="login-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium"
>
Войти Войти
</button> </button>
</form> </form>
<form
id="register-form" <form id="register-form" class="p-6 hidden">
class="p-6 hidden"
onsubmit="return handleRegister(event);"
>
<div class="mb-4"> <div class="mb-4">
<label <label for="register-username" class="block text-sm font-medium text-gray-700 mb-2">
for="register-username" Имя пользователя
class="block text-sm font-medium text-gray-700 mb-2" </label>
>Имя пользователя</label <input type="text" id="register-username" name="username"
>
<input
type="text"
id="register-username"
name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Придумайте имя пользователя (мин. 3 символа)" placeholder="Придумайте имя пользователя" required minlength="3" maxlength="50" />
required
minlength="3"
maxlength="50"
/>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label <label for="register-email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
for="register-email" <input type="email" id="register-email" name="email"
class="block text-sm font-medium text-gray-700 mb-2"
>Email</label
>
<input
type="email"
id="register-email"
name="email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="example@mail.com" placeholder="example@mail.com" required />
required
/>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label <label for="register-fullname" class="block text-sm font-medium text-gray-700 mb-2">
for="register-fullname" Полное имя <span class="text-gray-400">(необязательно)</span>
class="block text-sm font-medium text-gray-700 mb-2" </label>
>Полное имя <input type="text" id="register-fullname" name="full_name"
<span class="text-gray-400"
>(необязательно)</span
></label
>
<input
type="text"
id="register-fullname"
name="full_name"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Иван Иванов" placeholder="Иван Иванов" maxlength="100" />
maxlength="100"
/>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label <label for="register-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
for="register-password"
class="block text-sm font-medium text-gray-700 mb-2"
>Пароль</label
>
<div class="relative"> <div class="relative">
<input <input type="password" id="register-password" name="password"
type="password"
id="register-password"
name="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Минимум 8 символов, A-Z, a-z, 0-9" placeholder="Минимум 8 символов" required minlength="8" maxlength="100" />
required <button type="button"
minlength="8" class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
maxlength="100" <svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<button d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
type="button" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
> </path>
<svg
class="eye-open w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
</svg> </svg>
<svg <svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="eye-closed w-5 h-5 hidden" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
fill="none" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
stroke="currentColor" </path>
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
</svg> </svg>
</button> </button>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<div <div class="h-1 w-full bg-gray-200 rounded-full overflow-hidden">
class="h-1 w-full bg-gray-200 rounded-full overflow-hidden" <div id="password-strength-bar" class="h-full w-0 transition-all duration-300"></div>
>
<div
id="password-strength-bar"
class="h-full w-0 transition-all duration-300"
></div>
</div> </div>
<p <p id="password-strength-text" class="text-xs mt-1 text-gray-500"></p>
id="password-strength-text"
class="text-xs mt-1 text-gray-500"
></p>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label <label for="register-password-confirm" class="block text-sm font-medium text-gray-700 mb-2">
for="register-password-confirm" Подтвердите пароль
class="block text-sm font-medium text-gray-700 mb-2" </label>
>Подтвердите пароль</label
>
<div class="relative"> <div class="relative">
<input <input type="password" id="register-password-confirm" name="password_confirm"
type="password"
id="register-password-confirm"
name="password_confirm"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Повторите пароль" placeholder="Повторите пароль" required />
required <button type="button"
/> class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
<button <svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
type="button" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<svg d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
class="eye-open w-5 h-5" </path>
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
</svg> </svg>
<svg <svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="eye-closed w-5 h-5 hidden" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
fill="none" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
stroke="currentColor" </path>
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
</svg> </svg>
</button> </button>
</div> </div>
<p <p id="password-match-error" class="text-xs mt-1 text-red-500 hidden">
id="password-match-error"
class="text-xs mt-1 text-red-500 hidden"
>
Пароли не совпадают Пароли не совпадают
</p> </p>
</div> </div>
<div
id="register-error" <div class="mb-4">
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm" <cap-widget id="cap"
></div> data-cap-api-endpoint="/api/cap/"
<div style="
id="register-success" --cap-widget-width: 100%;
class="hidden mb-4 p-3 bg-green-100 border border-green-300 text-green-700 rounded-lg text-sm" --cap-background: #fdfdfd;
></div> --cap-border-color: #d1d5db;
<button --cap-border-radius: 8px;
type="submit" --cap-widget-height: auto;
id="register-submit" --cap-color: #212121;
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed" --cap-checkbox-size: 32px;
> --cap-checkbox-border: 1.5px dashed #d1d5db;
--cap-checkbox-border-radius: 6px;
--cap-checkbox-background: #fafafa;
--cap-checkbox-margin: 2px;
--cap-spinner-color: #4b5563;
--cap-spinner-background-color: #eee;
--cap-spinner-thickness: 5px;"
></cap-widget>
</div>
<button type="submit" id="register-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
Зарегистрироваться Зарегистрироваться
</button> </button>
</form> </form>
<form id="reset-password-form" class="p-6 hidden">
<div class="mb-4 text-center">
<h3 class="text-lg font-semibold text-gray-800">Сброс пароля</h3>
<p class="text-sm text-gray-500 mt-1">
Используйте один из резервных кодов, полученных при регистрации
</p>
</div>
<div class="mb-4">
<label for="reset-username" class="block text-sm font-medium text-gray-700 mb-2">
Имя пользователя
</label>
<input type="text" id="reset-username" name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Введите имя пользователя" required />
</div>
<div class="mb-4">
<label for="reset-recovery-code" class="block text-sm font-medium text-gray-700 mb-2">
Резервный код
</label>
<input type="text" id="reset-recovery-code" name="recovery_code"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200 text-center font-mono uppercase"
placeholder="XXXX-XXXX-XXXX-XXXX" maxlength="19" required
pattern="[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}" />
</div>
<div class="mb-4">
<label for="reset-new-password" class="block text-sm font-medium text-gray-700 mb-2">
Новый пароль
</label>
<div class="relative">
<input type="password" id="reset-new-password" name="new_password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Минимум 8 символов" required minlength="8" maxlength="100" />
<button type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
</path>
</svg>
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
</path>
</svg>
</button>
</div>
</div>
<div class="mb-6">
<label for="reset-confirm-password" class="block text-sm font-medium text-gray-700 mb-2">
Подтвердите новый пароль
</label>
<input type="password" id="reset-confirm-password" name="confirm_password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Повторите новый пароль" required />
<p id="reset-password-match-error" class="text-xs mt-1 text-red-500 hidden">
Пароли не совпадают
</p>
</div>
<button type="submit" id="reset-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
Сбросить пароль
</button>
<button type="button" id="back-to-login-btn"
class="w-full mt-3 text-gray-500 hover:text-gray-700 text-sm">
← Вернуться к входу
</button>
</form>
</div> </div>
</div> </div>
</div> </div>
<div id="recovery-codes-modal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-yellow-100 rounded-full mb-4">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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>
<h3 class="text-lg font-semibold text-center text-gray-800 mb-2">
Сохраните резервные коды!
</h3>
<p class="text-sm text-gray-500 text-center mb-4">
Эти коды понадобятся для восстановления доступа к аккаунту.
<strong class="text-red-600">Сохраните их в надёжном месте!</strong>
</p>
<div id="recovery-codes-list"
class="bg-gray-50 rounded-lg p-4 font-mono text-sm text-center space-y-2 mb-4">
</div>
<div class="flex gap-2 mb-4">
<button type="button" id="copy-codes-btn"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z">
</path>
</svg>
Копировать
</button>
<button type="button" id="download-codes-btn"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Скачать
</button>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 mb-4">
<input type="checkbox" id="codes-saved-checkbox" class="rounded" />
Я сохранил(а) коды в надёжном месте
</label>
<button type="button" id="close-recovery-modal-btn" disabled
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
Продолжить
</button>
</div>
</div>
</div>
<style>
#auth-tabs {
transition: transform 0.3s ease, opacity 0.2s ease, height 0.1s ease;
transform: translateY(0);
}
#auth-tabs.hide-animated {
transform: translateY(-12px);
pointer-events: none;
height: 0; opacity: 0;
}
</style>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/auth.js"></script> <script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
<script src="/static/page/auth.js"></script>
{% endblock %} {% endblock %}
+2 -2
View File
@@ -1,4 +1,4 @@
{% 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-4xl">
<div id="author-card" class="bg-white rounded-lg shadow-md p-6 mb-6"> <div id="author-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">
@@ -115,5 +115,5 @@
</div> </div>
</template> </template>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/author.js"></script> <script src="/static/page/author.js"></script>
{% endblock %} {% endblock %}
+8 -2
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %} {% block content %} {% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
<div <div
class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4" class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"
@@ -6,6 +6,12 @@
<h2 class="text-2xl font-bold text-gray-800">Авторы</h2> <h2 class="text-2xl font-bold text-gray-800">Авторы</h2>
<div class="flex flex-col sm:flex-row gap-4 w-full md:w-auto"> <div class="flex flex-col sm:flex-row gap-4 w-full md:w-auto">
<a href="/author/create" id="add-author-btn" class="hidden flex justify-center items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition whitespace-nowrap">
<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 4v16m8-8H4"></path>
</svg>
Добавить автора
</a>
<div class="relative"> <div class="relative">
<input <input
type="text" type="text"
@@ -119,5 +125,5 @@
</div> </div>
</template> </template>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/authors.js"></script> <script src="/static/page/authors.js"></script>
{% endblock %} {% endblock %}
+100 -27
View File
@@ -1,9 +1,13 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="ru">
<head> <head>
<title>{% block title %}LiB{% endblock %}</title> <title>{{ title }}</title>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="og:title" content="{{ title }}" />
<meta property="og:type" content="website" />
<meta property="og:description" content="Ваша персональная библиотека книг" />
<meta property="og:url" content="{{ request.url.scheme }}://{{ domain }}/" />
<script <script
defer defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js" src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
@@ -17,42 +21,55 @@
<body <body
class="flex flex-col min-h-screen bg-gray-100" class="flex flex-col min-h-screen bg-gray-100"
x-data="{ x-data="{
user: null, user: null,
async init() { menuOpen: false,
document.addEventListener('auth:login', async (e) => { async init() {
this.user = e.detail; document.addEventListener('auth:login', async (e) => {
this.user.avatar = await Utils.getGravatarUrl(this.user.email); this.user = e.detail;
}); this.user.avatar = await Utils.getGravatarUrl(this.user.email);
await Auth.init(); });
} await Auth.init();
}" }
}"
> >
<header class="bg-gray-600 text-white p-4 shadow-md"> <header class="bg-gray-600 text-white p-4 shadow-md">
<div class="mx-auto pl-5 pr-3 flex justify-between items-center"> <div class="mx-auto px-3 md:pl-5 md:pr-3 flex justify-between items-center">
<a class="flex gap-4 items-center max-w-10 h-auto" href="/"> <div class="flex items-center">
<img class="invert" src="/static/logo.svg" /> <button
<h1 class="text-2xl font-bold">LiB</h1> @click="menuOpen = !menuOpen"
</a> class="md:hidden flex gap-2 items-center hover:opacity-80 transition focus:outline-none"
<nav> :aria-expanded="menuOpen"
aria-label="Меню навигации"
>
<img class="invert max-w-10 h-auto" src="/static/logo.svg" />
<h1 class="text-xl font-bold">
<span class="text-gray-300 mr-1"></span>LiB
</h1>
</button>
<a class="hidden md:flex gap-4 items-center max-w-10 h-auto" href="/">
<img class="invert" src="/static/logo.svg" />
<h1 class="text-2xl font-bold">LiB</h1>
</a>
</div>
<nav class="hidden md:block">
<ul class="flex space-x-4"> <ul class="flex space-x-4">
<li> <li>
<a href="/" class="hover:text-gray-200">Главная</a> <a href="/" class="hover:text-gray-200">Главная</a>
</li> </li>
<li> <li>
<a href="/books" class="hover:text-gray-200" <a href="/books" class="hover:text-gray-200">Книги</a>
>Книги</a
>
</li> </li>
<li> <li>
<a href="/authors" class="hover:text-gray-200" <a href="/authors" class="hover:text-gray-200">Авторы</a>
>Авторы</a
>
</li> </li>
<li> <li>
<a href="/api" class="hover:text-gray-200">API</a> <a href="/api" class="hover:text-gray-200">API</a>
</li> </li>
</ul> </ul>
</nav> </nav>
<div class="relative" x-data="{ open: false }"> <div class="relative" x-data="{ open: false }">
<template x-if="!user"> <template x-if="!user">
<a <a
@@ -104,7 +121,7 @@
<div <div
x-show="open" x-show="open"
x-transition x-transition
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900" class="absolute right-0 mt-2 w-56 max-w-[calc(100vw-2rem)] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
style="display: none" style="display: none"
> >
<div class="px-4 py-3 border-b border-gray-200"> <div class="px-4 py-3 border-b border-gray-200">
@@ -229,16 +246,72 @@
</template> </template>
</div> </div>
</div> </div>
<nav
x-show="menuOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
@click.outside="menuOpen = false"
class="md:hidden mt-4 pb-2 border-t border-gray-500"
style="display: none"
>
<ul class="flex flex-col space-y-1 pt-3">
<li>
<a
href="/"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
Главная
</a>
</li>
<li>
<a
href="/books"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
Книги
</a>
</li>
<li>
<a
href="/authors"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
Авторы
</a>
</li>
<li>
<a
href="/api"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
API
</a>
</li>
</ul>
</nav>
</header> </header>
<main class="flex-grow">{% block content %}{% endblock %}</main> <main class="flex-grow">{% block content %}{% endblock %}</main>
<div <div
id="toast-container" id="toast-container"
class="fixed bottom-5 right-5 flex flex-col gap-2 z-50" class="fixed bottom-5 left-4 right-4 md:left-auto md:right-5 flex flex-col gap-2 z-50 items-center md:items-end"
></div> ></div>
<footer class="bg-gray-800 text-white p-4 mt-8"> <footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center"> <div class="container mx-auto text-center text-sm md:text-base">
<p>&copy; 2025 LiB Library. All rights reserved.</p> <p>
&copy; 2026 LiB Library. Разработано в рамках дипломного проекта.
<br class="sm:hidden" />
Код открыт под лицензией
<a href="https://github.com/wowlikon/LiB/blob/main/LICENSE" class="underline hover:text-gray-300">MIT</a>.
</p>
</div> </div>
</footer> </footer>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
+29 -19
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %} {% block content %} {% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-6xl"> <div class="container mx-auto p-4 max-w-6xl">
<div id="book-card" class="bg-white rounded-lg shadow-md p-6 mb-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">
@@ -66,29 +66,32 @@
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto" class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
> >
<div <div
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4" id="book-cover-container"
class="relative w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100 group"
> >
<svg <div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
class="w-20 h-20 text-white opacity-80" <svg
fill="none" class="w-20 h-20 text-white opacity-80"
stroke="currentColor" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
> viewBox="0 0 24 24"
<path >
stroke-linecap="round" <path
stroke-linejoin="round" stroke-linecap="round"
stroke-width="1.5" stroke-linejoin="round"
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" stroke-width="1.5"
></path> 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"
</svg> ></path>
</svg>
</div>
</div> </div>
<input type="file" id="cover-file-input" class="hidden" accept="image/*" />
<div <div
id="book-status-container" id="book-status-container"
class="relative w-full flex justify-center z-10 mb-4" class="relative w-full flex justify-center z-10 mb-4"
></div> ></div>
<div id="book-actions-container" class="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"
@@ -106,6 +109,10 @@
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>
<p id="book-page-count-text" class="text-sm text-gray-500 mb-6 hidden">
<span class="font-medium">Количество страниц:</span>
<span id="book-page-count-value"></span>
</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"
@@ -143,7 +150,6 @@
</div> </div>
</div> </div>
<!-- Секция выдачи для библиотекарей и администраторов -->
<div id="loans-section" class="hidden bg-white rounded-lg shadow-md p-6"> <div id="loans-section" class="hidden bg-white rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Выдачи книги</h2> <h2 class="text-xl font-bold text-gray-900">Выдачи книги</h2>
@@ -175,7 +181,6 @@
</div> </div>
</div> </div>
<!-- Модальное окно для выдачи книги -->
<div <div
id="loan-modal" id="loan-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto" class="hidden fixed inset-0 z-50 overflow-y-auto"
@@ -296,5 +301,10 @@
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/book.js"></script> <script src="/static/page/book.js"></script>
{% endblock %}
{% block extra_head %}
{% if img %}
<meta property="og:image" content="{{ request.url.scheme }}://{{ domain }}/static/books/{{ img }}.jpg" />
{% endif %}
{% endblock %} {% endblock %}
+112 -2
View File
@@ -1,4 +1,37 @@
{% extends "base.html" %} {% block content %} {% extends "base.html" %}{% block content %}
<style>
.range-double {
height: 0;
pointer-events: none;
appearance: none;
-webkit-appearance: none;
}
.range-double::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 8px;
height: 8px;
border-radius: 100%;
background: #4b5563;
border: 1px solid #ffffff;
box-shadow: 0 0 0 1px #4b5563;
cursor: pointer;
pointer-events: auto;
}
.range-double::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 100%;
background: #4b5563;
border: 1px solid #ffffff;
box-shadow: 0 0 0 1px #4b5563;
cursor: pointer;
pointer-events: auto;
}
.range-double::-moz-range-track {
background: transparent;
}
</style>
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6"> <div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
<aside class="w-full md:w-1/4"> <aside class="w-full md:w-1/4">
<div <div
@@ -87,6 +120,49 @@
</svg> </svg>
</div> </div>
</div> </div>
<div
x-data="pagesSlider(0, 2000, 10)"
class="bg-white p-4 rounded-lg shadow-md mb-6"
>
<h2 class="text-xl font-bold mb-4">Страниц</h2>
<div class="flex justify-between text-xs text-gray-500 mb-2">
<span>От: <span id="pages-min-value" x-text="minValue"></span></span>
<span>До: <span id="pages-max-value" x-text="maxValue"></span></span>
</div>
<div class="relative mt-4 mb-6">
<div class="absolute top-1/2 -translate-y-1/2 w-full h-1 bg-gray-200 rounded-full"></div>
<div
id="pages-range-progress"
class="absolute top-1/2 -translate-y-1/2 h-1 bg-gray-600 rounded-full"
:style="{ left: leftPercent + '%', right: rightPercent + '%' }"
></div>
<input
id="pages-min"
type="range"
:min="min"
:max="max"
x-model.number="minValue"
@input="onMinInput()"
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
/>
<input
id="pages-max"
type="range"
:min="min"
:max="max"
x-model.number="maxValue"
@input="onMaxInput()"
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
/>
</div>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-6"> <div class="bg-white p-4 rounded-lg shadow-md mb-6">
<h2 class="text-xl font-bold mb-4">Авторы</h2> <h2 class="text-xl font-bold mb-4">Авторы</h2>
<div <div
@@ -151,6 +227,10 @@
<span class="font-medium">Авторы:</span> <span class="font-medium">Авторы:</span>
<span class="book-authors"></span> <span class="book-authors"></span>
</p> </p>
<p class="book-page-count text-sm text-gray-600 mb-2 hidden">
<span class="font-medium">Страниц:</span>
<span class="page-count-value"></span>
</p>
<p <p
class="book-desc text-gray-700 text-sm mb-2 line-clamp-3" class="book-desc text-gray-700 text-sm mb-2 line-clamp-3"
></p> ></p>
@@ -186,5 +266,35 @@
</div> </div>
</template> </template>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/books.js"></script> <script src="/static/page/books.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('pagesSlider', (min, max, gap) => ({
min,
max,
gap,
minValue: min,
maxValue: max,
// проценты для заливки
get leftPercent() {
return (this.minValue - this.min) * 100 / (this.max - this.min);
},
get rightPercent() {
return 100 - (this.maxValue - this.min) * 100 / (this.max - this.min);
},
onMinInput() {
if (this.maxValue - this.minValue < this.gap) {
this.minValue = this.maxValue - this.gap;
}
},
onMaxInput() {
if (this.maxValue - this.minValue < this.gap) {
this.maxValue = this.minValue + this.gap;
}
}
}));
});
</script>
{% endblock %} {% endblock %}
+2 -3
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}Создание автора | LiB{% endblock %} {% extends "base.html" %}{% block content %}
{% block content %}
<div class="container mx-auto p-4 max-w-xl"> <div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8"> <div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4"> <div class="mb-8 border-b border-gray-100 pb-4">
@@ -158,5 +157,5 @@
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/create_author.js"></script> <script src="/static/page/create_author.js"></script>
{% endblock %} {% endblock %}
+18 -3
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}Создание книги | LiB{% endblock %} {% {% extends "base.html" %}{% block content %}
block content %}
<div class="container mx-auto p-4 max-w-3xl"> <div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8"> <div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4"> <div class="mb-8 border-b border-gray-100 pb-4">
@@ -69,6 +68,22 @@ block content %}
> >
</div> </div>
</div> </div>
<div>
<label
for="book-page-count"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Количество страниц
</label>
<input
type="number"
id="book-page-count"
name="page_count"
min="1"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Укажите количество страниц"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-4 rounded-lg border border-gray-200"> <div class="bg-white p-4 rounded-lg border border-gray-200">
<h2 class="text-sm font-semibold text-gray-700 mb-3"> <h2 class="text-sm font-semibold text-gray-700 mb-3">
@@ -225,5 +240,5 @@ block content %}
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/create_book.js"></script> <script src="/static/page/create_book.js"></script>
{% endblock %} {% endblock %}
+2 -3
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}Создание жанра | LiB{% endblock %} {% {% extends "base.html" %}{% block content %}
block content %}
<div class="container mx-auto p-4 max-w-xl"> <div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8"> <div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4"> <div class="mb-8 border-b border-gray-100 pb-4">
@@ -158,5 +157,5 @@ block content %}
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/create_genre.js"></script> <script src="/static/page/create_genre.js"></script>
{% endblock %} {% endblock %}
+2 -3
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}Редактирование автора | LiB{% {% extends "base.html" %}{% block content %}
endblock %} {% block content %}
<div class="container mx-auto p-4 max-w-2xl"> <div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8"> <div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4"> <div class="mb-8 border-b border-gray-100 pb-4">
@@ -312,5 +311,5 @@ endblock %} {% block content %}
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/edit_author.js"></script> <script src="/static/page/edit_author.js"></script>
{% endblock %} {% endblock %}
+19 -3
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}Редактирование книги | LiB{% endblock {% extends "base.html" %}{% block content %}
%} {% block content %}
<div class="container mx-auto p-4 max-w-3xl"> <div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8"> <div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4"> <div class="mb-8 border-b border-gray-100 pb-4">
@@ -78,6 +77,23 @@
</div> </div>
</div> </div>
<div>
<label
for="book-page-count"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Количество страниц
</label>
<input
type="number"
id="book-page-count"
name="page_count"
min="1"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Укажите количество страниц"
/>
</div>
<div> <div>
<label <label
for="book-status" for="book-status"
@@ -390,5 +406,5 @@
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/edit_book.js"></script> <script src="/static/page/edit_book.js"></script>
{% endblock %} {% endblock %}
+3 -4
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}Редактирование жанра | LiB{% endblock {% extends "base.html" %}{% block content %}
%} {% block content %}
<div class="container mx-auto p-4 max-w-2xl"> <div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8"> <div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4"> <div class="mb-8 border-b border-gray-100 pb-4">
@@ -120,7 +119,7 @@
</button> </button>
<a <a
id="cancel-btn" id="cancel-btn"
href="/" href="/books"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center" class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
> >
Отмена Отмена
@@ -313,5 +312,5 @@
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/edit_genre.js"></script> <script src="/static/page/edit_genre.js"></script>
{% endblock %} {% endblock %}
+4 -4
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %} {% {% extends "base.html" %}
block content %} {% block content %}
<div class="flex flex-1 items-center justify-center p-4"> <div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-4xl"> <div class="w-full max-w-4xl">
<div class="bg-white rounded-lg shadow-md overflow-hidden"> <div class="bg-white rounded-lg shadow-md overflow-hidden">
@@ -186,10 +186,10 @@ block content %}
</div> </div>
</div> </div>
<div class="mt-6 text-center text-gray-400 text-sm"> <div class="mt-6 text-center text-gray-400 text-sm">
<p>LiB — Библиотека. Создано с ❤️</p> <p>LiB — Библиотека. Красиво, функционально, безопасно.</p>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/index.js"></script> <script src="/static/page/index.js"></script>
{% endblock %} {% endblock %}
+2 -6
View File
@@ -1,11 +1,10 @@
{% extends "base.html" %} {% block content %} {% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-6xl"> <div class="container mx-auto p-4 max-w-6xl">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1> <h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
<p class="text-gray-600">Управление вашими бронированиями и выдачами</p> <p class="text-gray-600">Управление вашими бронированиями и выдачами</p>
</div> </div>
<!-- Бронирования -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6"> <div 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">
<h2 class="text-xl font-bold text-gray-900">Мои бронирования</h2> <h2 class="text-xl font-bold text-gray-900">Мои бронирования</h2>
@@ -18,7 +17,6 @@
</div> </div>
</div> </div>
<!-- Активные выдачи -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6"> <div 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">
<h2 class="text-xl font-bold text-gray-900">Активные выдачи</h2> <h2 class="text-xl font-bold text-gray-900">Активные выдачи</h2>
@@ -31,7 +29,6 @@
</div> </div>
</div> </div>
<!-- Возвращенные книги -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6"> <div 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">
<h2 class="text-xl font-bold text-gray-900">История возвратов</h2> <h2 class="text-xl font-bold text-gray-900">История возвратов</h2>
@@ -45,6 +42,5 @@
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/my_books.js"></script> <script src="/static/page/my_books.js"></script>
{% endblock %} {% endblock %}
+259 -125
View File
@@ -1,159 +1,293 @@
{% extends "base.html" %} {% block content %} {% extends "base.html" %}{% block content %}
<div <div class="container mx-auto p-4 max-w-2xl"
class="container mx-auto p-4 max-w-2xl" x-data="{ showPasswordModal: false, showDisable2FAModal: false, showRecoveryCodesModal: false, is2FAEnabled: false, recoveryCodesRemaining: null }"
x-data="{ showPasswordModal: false }" @update-2fa.window="is2FAEnabled = $event.detail"
@close-modal.window="showPasswordModal = false" @update-recovery-codes.window="recoveryCodesRemaining = $event.detail"
> @close-password-modal.window="showPasswordModal = false"
@close-disable-2fa-modal.window="showDisable2FAModal = false"
@close-recovery-codes-modal.window="showRecoveryCodesModal = false"
@open-recovery-codes-modal.window="showRecoveryCodesModal = true">
<div id="profile-card" class="bg-white rounded-lg shadow-md p-6 mb-6"> <div id="profile-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="animate-pulse flex items-center"> <div class="animate-pulse flex items-center">
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div> <div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
<div class="h-6 bg-gray-200 w-48 rounded"></div> <div class="h-6 bg-gray-200 w-48 rounded"></div>
</div> </div>
</div> </div>
<div
id="account-section" <div id="account-section" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
class="bg-white rounded-lg shadow-md p-6 mb-6 hidden"
>
<h2 class="text-xl font-bold mb-4 border-b pb-2">Информация</h2> <h2 class="text-xl font-bold mb-4 border-b pb-2">Информация</h2>
<div id="account-info" class="space-y-4"></div> <div id="account-info" class="space-y-4"></div>
</div> </div>
<div
id="roles-section" <div id="roles-section" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
class="bg-white rounded-lg shadow-md p-6 mb-6 hidden"
>
<h2 class="text-xl font-bold mb-4 border-b pb-2">Роли</h2> <h2 class="text-xl font-bold mb-4 border-b pb-2">Роли</h2>
<div id="roles-container" class="space-y-3"></div> <div id="roles-container" class="space-y-3"></div>
</div> </div>
<div class="bg-white rounded-lg shadow-md p-6 mb-6"> <div class="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 class="text-xl font-bold mb-4 border-b pb-2">Безопасность</h2>
<div class="space-y-3"> <div class="space-y-3">
<button <button type="button"
@click="showPasswordModal = true" @click="is2FAEnabled ? showDisable2FAModal = true : window.location.href = '/2fa'"
class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors" class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
> <div class="flex items-center gap-3">
<span class="text-gray-700 font-medium">Сменить пароль</span> <svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
class="w-5 h-5 text-gray-400" 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" />
fill="none" </svg>
stroke="currentColor" <span class="text-gray-700 font-medium">Двухфакторная аутентификация</span>
viewBox="0 0 24 24" </div>
> <span x-show="is2FAEnabled" class="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
<path Включена
stroke-linecap="round" </span>
stroke-linejoin="round" <span x-show="!is2FAEnabled" class="px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-600">
stroke-width="2" Выключена
d="M9 5l7 7-7 7" </span>
/> </button>
<button type="button" id="recovery-codes-btn"
class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span class="text-gray-700 font-medium">Резервные коды</span>
</div>
<div class="flex items-center gap-2">
<template x-if="recoveryCodesRemaining !== null">
<span :class="{
'bg-green-100 text-green-800': recoveryCodesRemaining > 5,
'bg-yellow-100 text-yellow-800': recoveryCodesRemaining > 2 && recoveryCodesRemaining <= 5,
'bg-red-100 text-red-800': recoveryCodesRemaining <= 2
}" class="px-2 py-1 text-xs font-medium rounded-full">
<span x-text="recoveryCodesRemaining"></span> / 10
</span>
</template>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
<button @click="showPasswordModal = true"
class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
<span class="text-gray-700 font-medium">Сменить пароль</span>
</div>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<button
onclick="Auth.logout()" <button onclick="Auth.logout()"
class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors" class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors">
> <div class="flex items-center gap-3">
<span class="text-red-700 font-medium">Выйти из аккаунта</span> <svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
class="w-5 h-5 text-red-400" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
fill="none" </svg>
stroke="currentColor" <span class="text-red-700 font-medium">Выйти из аккаунта</span>
viewBox="0 0 24 24" </div>
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
<div
x-show="showPasswordModal" <div x-show="showPasswordModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto">
class="fixed inset-0 z-50 overflow-y-auto" <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
style="display: none" <div x-show="showPasswordModal" x-transition.opacity class="fixed inset-0 transition-opacity">
> <div class="absolute inset-0 bg-gray-500 opacity-75" @click="showPasswordModal = false"></div>
<div
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"
>
<div
x-show="showPasswordModal"
x-transition.opacity
class="fixed inset-0 transition-opacity"
aria-hidden="true"
>
<div
class="absolute inset-0 bg-gray-500 opacity-75"
@click="showPasswordModal = false"
></div>
</div> </div>
<div <span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
x-show="showPasswordModal" <div x-show="showPasswordModal" x-transition
x-transition.scale class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
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-6 pt-6 pb-4">
> <h3 class="text-lg leading-6 font-semibold text-gray-900 mb-4">Смена пароля</h3>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <form id="change-password-form" class="space-y-4">
<h3 <div>
class="text-lg leading-6 font-medium text-gray-900 mb-4" <label class="block text-gray-700 text-sm font-medium mb-2">Новый пароль</label>
> <input type="password" id="new-password"
Смена пароля class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition"
</h3> placeholder="Минимум 8 символов" />
<form id="change-password-form">
<div class="mb-4">
<label
class="block text-gray-700 text-sm font-bold mb-2"
>Текущий пароль</label
>
<input
type="password"
id="current-password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
</div> </div>
<div class="mb-4"> <div>
<label <label class="block text-gray-700 text-sm font-medium mb-2">Подтвердите пароль</label>
class="block text-gray-700 text-sm font-bold mb-2" <input type="password" id="confirm-password"
>Новый пароль</label class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition"
> placeholder="Повторите пароль" />
<input
type="password"
id="new-password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
</div>
<div class="mb-4">
<label
class="block text-gray-700 text-sm font-bold mb-2"
>Подтвердите пароль</label
>
<input
type="password"
id="confirm-password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
</div> </div>
</form> </form>
</div> </div>
<div <div class="bg-gray-50 px-6 py-4 flex flex-row-reverse gap-3">
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse" <button type="button" id="submit-password-btn"
> class="px-5 py-2.5 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition">
<button
type="button"
id="submit-password-btn"
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 sm:ml-3 sm:w-auto sm:text-sm"
>
Сменить Сменить
</button> </button>
<button <button type="button" @click="showPasswordModal = false"
type="button" class="px-5 py-2.5 bg-white text-gray-700 font-medium rounded-lg border border-gray-300 hover:bg-gray-50 transition">
@click="showPasswordModal = false"
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 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Отмена Отмена
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div x-show="showDisable2FAModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showDisable2FAModal" x-transition.opacity class="fixed inset-0 transition-opacity">
<div class="absolute inset-0 bg-gray-500 opacity-75" @click="showDisable2FAModal = false"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showDisable2FAModal" x-transition
class="inline-block align-bottom bg-white rounded-xl 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-6 pt-6 pb-4">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-red-100 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="2"
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" />
</svg>
</div>
<h3 class="text-lg leading-6 font-semibold text-gray-900">Отключить 2FA</h3>
</div>
<p class="text-sm text-gray-500 mb-4">
Это снизит безопасность вашего аккаунта. Для подтверждения введите пароль.
</p>
<form id="disable-2fa-form">
<div>
<label class="block text-gray-700 text-sm font-medium mb-2">Пароль</label>
<input type="password" id="disable-2fa-password"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition"
placeholder="Введите ваш пароль" />
</div>
</form>
</div>
<div class="bg-gray-50 px-6 py-4 flex flex-row-reverse gap-3">
<button type="button" id="submit-disable-2fa-btn"
class="px-5 py-2.5 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition">
Отключить
</button>
<button type="button" @click="showDisable2FAModal = false"
class="px-5 py-2.5 bg-white text-gray-700 font-medium rounded-lg border border-gray-300 hover:bg-gray-50 transition">
Отмена
</button>
</div>
</div>
</div>
</div>
<div x-show="showRecoveryCodesModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showRecoveryCodesModal" x-transition.opacity class="fixed inset-0 transition-opacity">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showRecoveryCodesModal" x-transition
class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md w-full">
<div class="bg-white px-6 pt-6 pb-4">
<div id="recovery-codes-loading" class="text-center py-8">
<div class="animate-spin w-8 h-8 border-2 border-gray-300 border-t-gray-600 rounded-full mx-auto mb-4"></div>
<p class="text-gray-500">Загрузка...</p>
</div>
<div id="recovery-codes-status" class="hidden">
<div class="flex items-center justify-center w-12 h-12 mx-auto rounded-full mb-4" id="status-icon-container">
</div>
<h3 class="text-lg font-semibold text-center text-gray-800 mb-2">
Резервные коды
</h3>
<div id="codes-status-summary" class="text-center mb-4"></div>
<div class="bg-gray-50 rounded-lg p-3 mb-4">
<p class="text-xs text-gray-500 mb-2 text-center">Статус кодов:</p>
<div id="codes-status-list" class="space-y-1 max-h-48 overflow-y-auto"></div>
</div>
<div id="codes-warning" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<p class="text-sm text-yellow-800 flex items-start gap-2">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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" />
</svg>
<span id="warning-text"></span>
</p>
</div>
<p id="codes-generated-at" class="text-xs text-gray-400 text-center mb-4"></p>
<button type="button" id="regenerate-codes-btn"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium mb-3">
Сгенерировать новые коды
</button>
<button type="button" id="close-status-modal-btn"
class="w-full text-gray-500 hover:text-gray-700 text-sm py-2">
Закрыть
</button>
</div>
<div id="recovery-codes-display" class="hidden">
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-green-100 rounded-full mb-4">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 class="text-lg font-semibold text-center text-gray-800 mb-2">
Новые резервные коды
</h3>
<p class="text-sm text-gray-500 text-center mb-4">
<strong class="text-red-600">Сохраните эти коды!</strong>
Они понадобятся для восстановления доступа.
</p>
<div id="recovery-codes-list"
class="bg-gray-50 rounded-lg p-4 font-mono text-sm text-center space-y-2 mb-4 max-h-64 overflow-y-auto">
</div>
<p id="recovery-codes-generated-at" class="text-xs text-gray-400 text-center mb-4"></p>
<div class="flex gap-2 mb-4">
<button type="button" id="copy-codes-btn"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Копировать</span>
</button>
<button type="button" id="download-codes-btn"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Скачать</span>
</button>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 mb-4 cursor-pointer">
<input type="checkbox" id="codes-saved-checkbox" class="rounded border-gray-300 text-gray-600 focus:ring-gray-500" />
<span>Я сохранил(а) коды в надёжном месте</span>
</label>
<button type="button" id="close-recovery-modal-btn" disabled
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
Закрыть
</button>
</div>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %}
<script src="/static/profile.js"></script>
{% block scripts %}
<script src="/static/page/profile.js"></script>
{% endblock %} {% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}{% block content %}
<div class="flex flex-1 items-center justify-center p-4 min-h-[70vh]">
<div class="w-full max-w-2xl">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="p-8 text-center">
<div class="mb-6 relative">
<svg id="canvas" viewBox="-250 -50 500 100" style="width: 70vmin; height: 25vmin; max-width: 600px; max-height: 600px"></svg>
</div>
<h1 class="text-3xl font-bold text-gray-800 mb-3">
Страница не найдена
</h1>
<p class="text-gray-500 mb-2">
К сожалению, запрашиваемая страница не существует.
</p>
<p class="text-gray-400 text-sm mb-8">
Возможно, она была удалена или вы ввели неверный адрес.
</p>
<div class="bg-gray-100 rounded-lg px-4 py-3 mb-8 inline-block">
<code id="pathh" class="text-gray-600 text-sm">
<span class="text-gray-400">Путь:</span>
{{ request.url.path }}
</code>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<button
onclick="history.back()"
class="inline-flex items-center justify-center px-6 py-3 bg-white text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition duration-200 font-medium shadow-sm hover:shadow-md transform hover:-translate-y-0.5"
>
<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="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Назад
</button>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition duration-200 font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
На главную
</a>
</div>
</div>
<div class="bg-gray-50 px-8 py-6 border-t border-gray-200">
<p class="text-gray-500 text-sm text-center mb-4">Возможно, вы искали:</p>
<div class="flex flex-wrap justify-center gap-3">
<a href="/books" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
Книги
</a>
<a href="/authors" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
Авторы
</a>
<a href="/api" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
API
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/page/unknown.js"></script>
{% endblock %}
+14 -18
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}Пользователи - LiB{% endblock %} {% {% extends "base.html" %}{% block content %}
block content %}
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800"> <h1 class="text-2xl font-bold text-gray-800">
@@ -38,7 +37,7 @@ block content %}
type="text" type="text"
id="role-filter-input" id="role-filter-input"
placeholder="Фильтр по роли..." placeholder="Фильтр по роли..."
class="w-full md:w-56 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" class="w-full md:w-56 border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off" autocomplete="off"
/> />
<div <div
@@ -150,7 +149,7 @@ block content %}
</button> </button>
</div> </div>
</div> </div>
<div class="mt-3"> <div class="mt-3 flex flex-col sm:flex-row sm:items-end justify-between gap-3">
<div class="flex items-center flex-wrap gap-2"> <div class="flex items-center flex-wrap gap-2">
<span class="text-sm font-medium text-gray-700" <span class="text-sm font-medium text-gray-700"
>Роли:</span >Роли:</span
@@ -177,6 +176,15 @@ block content %}
</button> </button>
</div> </div>
</div> </div>
<div class="user-payroll hidden shrink-0 flex items-center gap-1.5 bg-emerald-50 text-emerald-700 px-3 py-1 rounded-lg border border-emerald-100 shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xs font-semibold uppercase tracking-wider text-emerald-600">Оклад:</div>
<div class="font-bold font-mono text-lg leading-none user-payroll-amount"></div>
<div class="text-xs font-medium"></div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -312,7 +320,7 @@ block content %}
placeholder="••••••••" placeholder="••••••••"
/> />
</div> </div>
<div class="flex items-center gap-4"> <div>
<label <label
class="flex items-center gap-2 cursor-pointer" class="flex items-center gap-2 cursor-pointer"
> >
@@ -325,18 +333,6 @@ block content %}
>Активен</span >Активен</span
> >
</label> </label>
<label
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
id="edit-user-verified"
class="w-4 h-4 text-green-600 rounded focus:ring-green-500"
/>
<span class="text-sm text-gray-700"
>Подтверждён</span
>
</label>
</div> </div>
</div> </div>
</div> </div>
@@ -429,5 +425,5 @@ block content %}
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/users.js"></script> <script src="/static/page/users.js"></script>
{% endblock %} {% endblock %}
-6
View File
@@ -1,6 +0,0 @@
def main():
print("Hello from libraryapi!")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel import sqlmodel, pgvector
${imports if imports else ""} ${imports if imports else ""}
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
+47 -22
View File
@@ -5,6 +5,7 @@ Revises: b838606ad8d1
Create Date: 2025-12-20 10:36:30.853896 Create Date: 2025-12-20 10:36:30.853896
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -13,39 +14,63 @@ import sqlmodel
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '02ed6e775351' revision: str = "02ed6e775351"
down_revision: Union[str, None] = 'b838606ad8d1' down_revision: Union[str, None] = "b838606ad8d1"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus') book_status_enum = sa.Enum(
book_status_enum.create(op.get_bind()) "active",
op.create_table('book_loans', "borrowed",
sa.Column('id', sa.Integer(), nullable=False), "reserved",
sa.Column('book_id', sa.Integer(), nullable=False), "restoration",
sa.Column('user_id', sa.Integer(), nullable=False), "written_off",
sa.Column('borrowed_at', sa.DateTime(), nullable=False), name="bookstatus",
sa.Column('due_date', sa.DateTime(), nullable=False),
sa.Column('returned_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_book_loans_id'), 'book_loans', ['id'], unique=False) book_status_enum.create(op.get_bind())
op.add_column('book', sa.Column('status', book_status_enum, nullable=False, server_default='active')) op.create_table(
op.drop_index(op.f('ix_roles_name'), table_name='roles') "loans",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("borrowed_at", sa.DateTime(), nullable=False),
sa.Column("due_date", sa.DateTime(), nullable=False),
sa.Column("returned_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["book_id"],
["book.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_loans_id"), "loans", ["id"], unique=False)
op.add_column(
"book",
sa.Column("status", book_status_enum, nullable=False, server_default="active"),
)
op.drop_index(op.f("ix_roles_name"), table_name="roles")
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True) op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True)
op.drop_column('book', 'status') op.drop_column("book", "status")
op.drop_index(op.f('ix_book_loans_id'), table_name='book_loans') op.drop_index(op.f("ix_loans_id"), table_name="loans")
op.drop_table('book_loans') op.drop_table("loans")
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus') book_status_enum = sa.Enum(
"active",
"borrowed",
"reserved",
"restoration",
"written_off",
name="bookstatus",
)
book_status_enum.drop(op.get_bind()) book_status_enum.drop(op.get_bind())
# ### end Alembic commands ### # ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""Book vector search
Revision ID: 6c616cc9d1f0
Revises: c5dfc16bdc66
Create Date: 2026-01-27 22:37:48.077761
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel, pgvector
# revision identifiers, used by Alembic.
revision: str = "6c616cc9d1f0"
down_revision: Union[str, None] = "c5dfc16bdc66"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"book",
sa.Column(
"embedding", pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=True
),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("book", "embedding")
# ### end Alembic commands ###
@@ -0,0 +1,53 @@
"""Recovery codes and totp
Revision ID: a585fd97b88c
Revises: a8e40ab24138
Create Date: 2026-01-18 15:09:58.721180
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "a585fd97b88c"
down_revision: Union[str, None] = "a8e40ab24138"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("is_2fa_enabled", sa.Boolean(), nullable=False))
op.add_column(
"users",
sa.Column(
"totp_secret", sqlmodel.sql.sqltypes.AutoString(length=80), nullable=True
),
)
op.add_column(
"users",
sa.Column(
"recovery_code_hashes",
sqlmodel.sql.sqltypes.AutoString(length=1500),
nullable=True,
),
)
op.add_column(
"users", sa.Column("recovery_codes_generated_at", sa.DateTime(), nullable=True)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "recovery_codes_generated_at")
op.drop_column("users", "recovery_code_hashes")
op.drop_column("users", "totp_secret")
op.drop_column("users", "is_2fa_enabled")
# ### end Alembic commands ###
@@ -1,10 +1,11 @@
"""role payroll """Role payroll
Revision ID: a8e40ab24138 Revision ID: a8e40ab24138
Revises: 02ed6e775351 Revises: 02ed6e775351
Create Date: 2025-12-20 13:44:13.807704 Create Date: 2025-12-20 13:44:13.807704
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -13,29 +14,49 @@ import sqlmodel
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'a8e40ab24138' revision: str = "a8e40ab24138"
down_revision: Union[str, None] = '02ed6e775351' down_revision: Union[str, None] = "02ed6e775351"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.alter_column('book', 'status', op.alter_column(
existing_type=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'), "book",
type_=sa.String(), "status",
existing_nullable=False, existing_type=postgresql.ENUM(
existing_server_default=sa.text("'active'::bookstatus")) "active",
op.add_column('roles', sa.Column('payroll', sa.Integer(), nullable=False)) "borrowed",
"reserved",
"restoration",
"written_off",
name="bookstatus",
),
type_=sa.String(),
existing_nullable=False,
existing_server_default=sa.text("'active'::bookstatus"),
)
op.add_column("roles", sa.Column("payroll", sa.Integer(), nullable=False))
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_column('roles', 'payroll') op.drop_column("roles", "payroll")
op.alter_column('book', 'status', op.alter_column(
existing_type=sa.String(), "book",
type_=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'), "status",
existing_nullable=False, existing_type=sa.String(),
existing_server_default=sa.text("'active'::bookstatus")) type_=postgresql.ENUM(
"active",
"borrowed",
"reserved",
"restoration",
"written_off",
name="bookstatus",
),
existing_nullable=False,
existing_server_default=sa.text("'active'::bookstatus"),
)
# ### end Alembic commands ### # ### end Alembic commands ###
@@ -0,0 +1,33 @@
"""Book preview
Revision ID: abbc38275032
Revises: 6c616cc9d1f0
Create Date: 2026-02-01 14:41:14.611420
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel, pgvector
# revision identifiers, used by Alembic.
revision: str = 'abbc38275032'
down_revision: Union[str, None] = '6c616cc9d1f0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('book', sa.Column('preview_id', sa.Uuid(), nullable=True))
op.create_index(op.f('ix_book_preview_id'), 'book', ['preview_id'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_book_preview_id'), table_name='book')
op.drop_column('book', 'preview_id')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Book page_count
Revision ID: c5dfc16bdc66
Revises: a585fd97b88c
Create Date: 2026-01-23 00:09:14.192263
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = "c5dfc16bdc66"
down_revision: Union[str, None] = "a585fd97b88c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("book", sa.Column("page_count", sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("book", "page_count")
# ### end Alembic commands ###
+6 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "LibraryAPI" name = "LiB"
version = "0.4.0" version = "0.9.0"
description = "Это простое API для управления авторами, книгами и их жанрами." description = "Это простое API для управления авторами, книгами и их жанрами."
authors = [{ name = "wowlikon" }] authors = [{ name = "wowlikon" }]
readme = "README.md" readme = "README.md"
@@ -21,6 +21,10 @@ dependencies = [
"aiofiles>=25.1.0", "aiofiles>=25.1.0",
"qrcode[pil]>=8.2", "qrcode[pil]>=8.2",
"pyotp>=2.9.0", "pyotp>=2.9.0",
"slowapi>=0.1.9",
"limits>=5.6.0",
"ollama>=0.6.1",
"pgvector>=0.4.2",
] ]
[dependency-groups] [dependency-groups]

Some files were not shown because too many files have changed in this diff Show More