mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 12:31:09 +00:00
Compare commits
21 Commits
758e0fc9e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a336d50ad0 | |||
| 38642a6910 | |||
| d442a37820 | |||
| 80acdceba6 | |||
| 4368ee0d3c | |||
| 4f9c472a54 | |||
| a6811a3e86 | |||
| 19d322c9d9 | |||
| dfa4d14afc | |||
| 6014db3c81 | |||
| 0e159df16e | |||
| 2f3d6f0e1e | |||
| 657f1b96f2 | |||
| 9f814e7271 | |||
| 09d5739256 | |||
| ec1c32a5bd | |||
| c1ac0ca246 | |||
| 7c3074e8fe | |||
| 1e0c3478a1 | |||
| e507896b7a | |||
| d6ecd4066f |
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
|
library_service/static/books/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
|
|||||||
@@ -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 && \
|
||||||
|
|||||||
@@ -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**: Просмотр каталога и управление своими выдачами
|
||||||
|
|
||||||
@@ -68,88 +59,116 @@
|
|||||||
#### **Аутентификация** (`/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` | Публичный | Схема базы данных |
|
||||||
|
|
||||||
### **Веб-страницы**
|
### **Веб-страницы**
|
||||||
|
|
||||||
| Путь | Доступ | Описание |
|
| Путь | Доступ | Описание |
|
||||||
|---------------------|----------------|-----------------------------------------|
|
|---------------------|----------------|-----------------------------|
|
||||||
| `/` | Публичный | Главная страница |
|
| `/` | Публичный | Главная страница |
|
||||||
|
| `/api` | Публичный | Ссылки на документацию |
|
||||||
| `/auth` | Публичный | Страница авторизации |
|
| `/auth` | Публичный | Страница авторизации |
|
||||||
| `/profile` | Авторизованный | Профиль пользователя |
|
| `/profile` | Авторизованный | Профиль пользователя |
|
||||||
| `/books` | Публичный | Каталог книг с фильтрацией |
|
| `/books` | Публичный | Каталог книг с фильтрацией |
|
||||||
@@ -163,9 +182,9 @@
|
|||||||
| `/genre/create` | Сотрудник | Создание жанра |
|
| `/genre/create` | Сотрудник | Создание жанра |
|
||||||
| `/genre/{id}/edit` | Сотрудник | Редактирование жанра |
|
| `/genre/{id}/edit` | Сотрудник | Редактирование жанра |
|
||||||
| `/my-books` | Авторизованный | Мои выдачи |
|
| `/my-books` | Авторизованный | Мои выдачи |
|
||||||
| `/users` | Сотрудник | Управление пользователями |
|
| `/users` | Админ | Управление пользователями |
|
||||||
| `/analytics` | Админ | Аналитика выдач и возвратов |
|
| `/analytics` | Админ | Аналитика выдач и возвратов |
|
||||||
| `/api` | Публичный | Страница с ссылками на документацию API |
|
|
||||||
|
|
||||||
### **Схема базы данных**
|
### **Схема базы данных**
|
||||||
|
|
||||||
@@ -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-фреймворк для стилизации интерфейса
|
||||||
|
|||||||
@@ -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()
|
|
||||||
+60
-22
@@ -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,24 +94,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
llm:
|
||||||
tests:
|
|
||||||
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
|
condition: service_healthy
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
|
||||||
|
|
||||||
|
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
|
||||||
|
SELECT pg_reload_conf();
|
||||||
@@ -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,36 +1,46 @@
|
|||||||
"""Модуль авторизации и аутентификации"""
|
"""Модуль основного функционала авторизации и аутентификации"""
|
||||||
|
|
||||||
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()
|
||||||
@@ -38,10 +48,68 @@ 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}
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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="Дата и время фактического возврата"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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="Количество авторов")
|
||||||
|
|||||||
@@ -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="Количество книг")
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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="Количество жанров")
|
||||||
|
|||||||
@@ -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="Количество выдач")
|
||||||
|
|||||||
@@ -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="Пароль")
|
||||||
@@ -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
|
||||||
@@ -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="Количество ролей")
|
||||||
|
|||||||
@@ -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="Является ли токен частичным")
|
||||||
|
|||||||
@@ -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="Количество пользователей")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+254
-147
@@ -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},
|
if user.is_2fa_enabled:
|
||||||
expires_delta=access_token_expires,
|
return LoginResponse(
|
||||||
)
|
partial_token=create_partial_token(token_data),
|
||||||
refresh_token = create_refresh_token(
|
token_type="partial",
|
||||||
data={"sub": user.username, "user_id": user.id}
|
requires_2fa=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Token(
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
|
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}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
|
||||||
statement = statement.where(
|
|
||||||
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if min_page_count:
|
||||||
|
statement = statement.where(Book.page_count >= min_page_count) # ty: ignore
|
||||||
|
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(
|
||||||
|
AuthorBookLink.book_id == Book.id, # ty: ignore
|
||||||
|
AuthorBookLink.author_id.in_(author_ids), # ty: ignore
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if genre_ids:
|
if genre_ids:
|
||||||
statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
total_statement = select(func.count()).select_from(statement.subquery())
|
count_statement = select(func.count()).select_from(statement.subquery())
|
||||||
total = session.exec(total_statement).one()
|
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
|
offset = (page - 1) * size
|
||||||
statement = statement.offset(offset).limit(size)
|
statement = statement.offset(offset).limit(size)
|
||||||
results = session.exec(statement).all()
|
results = session.scalars(statement).unique().all()
|
||||||
|
|
||||||
books_with_data = []
|
return BookFilteredList(books=results, total=total)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@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": []}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,23 +205,20 @@ 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(
|
||||||
|
content={
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_loans": total_loans,
|
"total_loans": total_loans,
|
||||||
"active_loans": active_loans,
|
"active_loans": active_loans,
|
||||||
@@ -232,7 +233,8 @@ def get_loans_analytics(
|
|||||||
"period_days": days,
|
"period_days": days,
|
||||||
"start_date": start_date.isoformat(),
|
"start_date": start_date.isoformat(),
|
||||||
"end_date": end_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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -64,13 +69,14 @@ def get_related(
|
|||||||
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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("Зарегистрироваться");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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" : ""}>←</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" : ""}>→</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", {
|
||||||
|
data: {
|
||||||
code: code,
|
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(() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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(", ") || "Автор неизвестен",
|
||||||
);
|
);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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" : ""
|
||||||
|
}>←</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" : ""
|
||||||
|
}>→</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(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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("Сменить");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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}`)
|
||||||
|
.then((deletedUser) => {
|
||||||
|
if (deletedUser.is_active === false) {
|
||||||
|
const userIndex = users.findIndex((u) => u.id === userToDelete.id);
|
||||||
|
if (userIndex !== -1) {
|
||||||
|
users[userIndex] = deletedUser;
|
||||||
|
}
|
||||||
|
Utils.showToast("Пользователь деактивирован", "success");
|
||||||
|
} else {
|
||||||
|
users = users.filter((u) => u.id !== userToDelete.id);
|
||||||
|
totalUsers--;
|
||||||
|
$("#total-users-count").text(totalUsers);
|
||||||
|
Utils.showToast("Пользователь удалён", "success");
|
||||||
|
}
|
||||||
|
renderUsers();
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
|
})
|
||||||
// When API supports deletion:
|
.catch((error) => {
|
||||||
// Api.delete(`/api/auth/users/${userToDelete.id}`)
|
console.error(error);
|
||||||
// .then(() => {
|
Utils.showToast(error.message || "Ошибка удаления", "error");
|
||||||
// users = users.filter(u => u.id !== userToDelete.id);
|
});
|
||||||
// totalUsers--;
|
|
||||||
// $("#total-users-count").text(totalUsers);
|
|
||||||
// renderUsers();
|
|
||||||
// closeDeleteModal();
|
|
||||||
// Utils.showToast("Пользователь удалён", "success");
|
|
||||||
// })
|
|
||||||
// .catch((error) => {
|
|
||||||
// console.error(error);
|
|
||||||
// Utils.showToast(error.message || "Ошибка удаления", "error");
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#users-container").on("click", ".add-role-btn", function (e) {
|
$("#users-container").on("click", ".add-role-btn", function (e) {
|
||||||
@@ -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("Сменить");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+290
-257
@@ -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 id="credentials-section">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label for="login-username" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
for="login-username"
|
Имя пользователя
|
||||||
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"
|
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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label for="login-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
|
||||||
for="login-password"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>Пароль</label
|
|
||||||
>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input type="password" id="login-password" name="password"
|
||||||
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
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<label
|
<label class="custom-checkbox flex items-center text-sm text-gray-600">
|
||||||
class="custom-checkbox flex items-center text-sm text-gray-600"
|
|
||||||
>
|
|
||||||
<input type="checkbox" id="remember-me" />
|
<input type="checkbox" id="remember-me" />
|
||||||
<span class="checkmark"></span>Запомнить меня
|
<span class="checkmark"></span>Запомнить меня
|
||||||
</label>
|
</label>
|
||||||
<a
|
<button type="button" id="forgot-password-btn"
|
||||||
href="#"
|
class="text-sm text-gray-500 hover:text-gray-700 transition">
|
||||||
class="text-sm text-gray-500 hover:text-gray-700 transition"
|
Забыли пароль?
|
||||||
>Забыли пароль?</a
|
</button>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
</div>
|
||||||
id="login-error"
|
|
||||||
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
|
<div id="totp-section" class="hidden">
|
||||||
></div>
|
<div class="text-center mb-4">
|
||||||
<button
|
<div class="w-20 h-20 mx-auto relative flex items-center justify-center mb-3">
|
||||||
type="submit"
|
<svg class="absolute inset-0 w-full h-full -rotate-90" viewBox="0 0 80 80">
|
||||||
id="login-submit"
|
<circle cx="40" cy="40" r="38" fill="none" stroke="#e5e7eb" stroke-width="2" />
|
||||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium"
|
<circle id="lock-progress-circle" cx="40" cy="40" r="38" fill="none" stroke="#000000"
|
||||||
>
|
stroke-width="2" stroke-linecap="round"
|
||||||
|
style="stroke-dasharray: 238.761; stroke-dashoffset: 238.761;" />
|
||||||
|
</svg>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<button type="submit" id="login-submit"
|
||||||
|
class="w-full bg-gray-500 text-white py-2 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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -18,6 +22,7 @@
|
|||||||
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,
|
||||||
|
menuOpen: false,
|
||||||
async init() {
|
async init() {
|
||||||
document.addEventListener('auth:login', async (e) => {
|
document.addEventListener('auth:login', async (e) => {
|
||||||
this.user = e.detail;
|
this.user = e.detail;
|
||||||
@@ -28,31 +33,43 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<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">
|
||||||
|
<button
|
||||||
|
@click="menuOpen = !menuOpen"
|
||||||
|
class="md:hidden flex gap-2 items-center hover:opacity-80 transition focus:outline-none"
|
||||||
|
: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" />
|
<img class="invert" src="/static/logo.svg" />
|
||||||
<h1 class="text-2xl font-bold">LiB</h1>
|
<h1 class="text-2xl font-bold">LiB</h1>
|
||||||
</a>
|
</a>
|
||||||
<nav>
|
</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>© 2025 LiB Library. All rights reserved.</p>
|
<p>
|
||||||
|
© 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 %}
|
||||||
|
|||||||
@@ -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,8 +66,10 @@
|
|||||||
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"
|
||||||
>
|
>
|
||||||
|
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
class="w-20 h-20 text-white opacity-80"
|
class="w-20 h-20 text-white opacity-80"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -82,13 +84,14 @@
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<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="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" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-700 font-medium">Двухфакторная аутентификация</span>
|
||||||
|
</div>
|
||||||
|
<span x-show="is2FAEnabled" class="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
|
||||||
|
Включена
|
||||||
|
</span>
|
||||||
|
<span x-show="!is2FAEnabled" class="px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-600">
|
||||||
|
Выключена
|
||||||
|
</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>
|
<span class="text-gray-700 font-medium">Сменить пароль</span>
|
||||||
<svg
|
</div>
|
||||||
class="w-5 h-5 text-gray-400"
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
fill="none"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
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">
|
||||||
|
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<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>
|
||||||
<span class="text-red-700 font-medium">Выйти из аккаунта</span>
|
<span class="text-red-700 font-medium">Выйти из аккаунта</span>
|
||||||
<svg
|
</div>
|
||||||
class="w-5 h-5 text-red-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<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">​</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">​</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">​</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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
def main():
|
|
||||||
print("Hello from libraryapi!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
"status",
|
||||||
|
existing_type=postgresql.ENUM(
|
||||||
|
"active",
|
||||||
|
"borrowed",
|
||||||
|
"reserved",
|
||||||
|
"restoration",
|
||||||
|
"written_off",
|
||||||
|
name="bookstatus",
|
||||||
|
),
|
||||||
type_=sa.String(),
|
type_=sa.String(),
|
||||||
existing_nullable=False,
|
existing_nullable=False,
|
||||||
existing_server_default=sa.text("'active'::bookstatus"))
|
existing_server_default=sa.text("'active'::bookstatus"),
|
||||||
op.add_column('roles', sa.Column('payroll', sa.Integer(), nullable=False))
|
)
|
||||||
|
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(
|
||||||
|
"book",
|
||||||
|
"status",
|
||||||
existing_type=sa.String(),
|
existing_type=sa.String(),
|
||||||
type_=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
|
type_=postgresql.ENUM(
|
||||||
|
"active",
|
||||||
|
"borrowed",
|
||||||
|
"reserved",
|
||||||
|
"restoration",
|
||||||
|
"written_off",
|
||||||
|
name="bookstatus",
|
||||||
|
),
|
||||||
existing_nullable=False,
|
existing_nullable=False,
|
||||||
existing_server_default=sa.text("'active'::bookstatus"))
|
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
@@ -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
Reference in New Issue
Block a user