mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Compare commits
18 Commits
1e0c3478a1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a336d50ad0 | |||
| 38642a6910 | |||
| d442a37820 | |||
| 80acdceba6 | |||
| 4368ee0d3c | |||
| 4f9c472a54 | |||
| a6811a3e86 | |||
| 19d322c9d9 | |||
| dfa4d14afc | |||
| 6014db3c81 | |||
| 0e159df16e | |||
| 2f3d6f0e1e | |||
| 657f1b96f2 | |||
| 9f814e7271 | |||
| 09d5739256 | |||
| ec1c32a5bd | |||
| c1ac0ca246 | |||
| 7c3074e8fe |
@@ -1,37 +0,0 @@
|
|||||||
# Postgres
|
|
||||||
POSTGRES_HOST="db"
|
|
||||||
POSTGRES_PORT="5432"
|
|
||||||
POSTGRES_USER="postgres"
|
|
||||||
POSTGRES_PASSWORD="postgres"
|
|
||||||
POSTGRES_DB="lib"
|
|
||||||
|
|
||||||
# Default admin account
|
|
||||||
# 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"
|
|
||||||
PARTIAL_TOKEN_EXPIRE_MINUTES="5"
|
|
||||||
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"
|
|
||||||
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"
|
|
||||||
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**: Просмотр каталога и управление своими выдачами
|
||||||
|
|
||||||
@@ -144,10 +135,10 @@
|
|||||||
|
|
||||||
#### **Пользователи** (`/api/users`)
|
#### **Пользователи** (`/api/users`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|--------|-------------------------------|----------------|------------------------------|
|
|--------|--------------------------------|----------------|------------------------------|
|
||||||
| POST | `/` | Админ | Создать нового пользователя |
|
| POST | `/` | Админ | Создать нового пользователя |
|
||||||
| GET | `/` | Админ | Список всех пользователей |
|
| GET | `/` | Админ | Список всех пользователей |
|
||||||
| GET | `/{id}` | Админ | Получить пользователя по ID |
|
| GET | `/{id}` | Админ | Получить пользователя по ID |
|
||||||
| PUT | `/{id}` | Админ | Обновить пользователя по ID |
|
| PUT | `/{id}` | Админ | Обновить пользователя по ID |
|
||||||
| DELETE | `/{id}` | Админ | Удалить пользователя по ID |
|
| DELETE | `/{id}` | Админ | Удалить пользователя по ID |
|
||||||
@@ -156,12 +147,21 @@
|
|||||||
| GET | `/roles` | Авторизованный | Список ролей в системе |
|
| GET | `/roles` | Авторизованный | Список ролей в системе |
|
||||||
|
|
||||||
|
|
||||||
|
#### **CAPTCHA** (`/api/cap`)
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|
|--------|---------------|-----------|-----------------|
|
||||||
|
| POST | `/challenge` | Публичный | Создание задачи |
|
||||||
|
| POST | `/redeem` | Публичный | Проверка задачи |
|
||||||
|
|
||||||
|
|
||||||
#### **Прочее** (`/api`)
|
#### **Прочее** (`/api`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|-------|----------|-----------|----------------------|
|
|-------|-----------|-----------|----------------------|
|
||||||
| GET | `/info` | Публичный | Информация о сервисе |
|
| GET | `/info` | Публичный | Информация о сервисе |
|
||||||
| GET | `/stats` | Публичный | Статистика системы |
|
| GET | `/stats` | Публичный | Статистика системы |
|
||||||
|
| GET | `/schema` | Публичный | Схема базы данных |
|
||||||
|
|
||||||
### **Веб-страницы**
|
### **Веб-страницы**
|
||||||
|
|
||||||
@@ -265,6 +265,8 @@ erDiagram
|
|||||||
- **ACTIVE**: Книга доступна для выдачи
|
- **ACTIVE**: Книга доступна для выдачи
|
||||||
- **RESERVED**: Книга забронирована (ожидает подтверждения)
|
- **RESERVED**: Книга забронирована (ожидает подтверждения)
|
||||||
- **BORROWED**: Книга выдана пользователю
|
- **BORROWED**: Книга выдана пользователю
|
||||||
|
- **RESTORATION**: Книга на реставрации
|
||||||
|
- **WRITTEN_OFF**: Книга списана
|
||||||
|
|
||||||
### **Используемые технологии**
|
### **Используемые технологии**
|
||||||
|
|
||||||
@@ -273,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()
|
|
||||||
+61
-23
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:17
|
image: pgvector/pgvector:pg17
|
||||||
container_name: db
|
container_name: db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging:
|
logging:
|
||||||
@@ -11,26 +11,81 @@ services:
|
|||||||
- ./data/db:/var/lib/postgresql/data
|
- ./data/db:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
ports: # !сменить внешний порт перед использованием!
|
||||||
|
- 5432:5432
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
|
command:
|
||||||
|
- "postgres"
|
||||||
|
- "-c"
|
||||||
|
- "wal_level=logical"
|
||||||
|
- "-c"
|
||||||
|
- "max_replication_slots=10"
|
||||||
|
- "-c"
|
||||||
|
- "max_wal_senders=10"
|
||||||
|
- "-c"
|
||||||
|
- "listen_addresses=*"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
replication-setup:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: replication-setup
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
volumes:
|
||||||
|
- ./setup-replication.sh:/setup-replication.sh
|
||||||
|
entrypoint: ["/bin/sh", "/setup-replication.sh"]
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
llm:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
container_name: llm
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
volumes:
|
||||||
|
- ./data/llm:/root/.ollama
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
ports: # !только локальный тест!
|
||||||
|
- 11434:11434
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "ollama", "list"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 5g
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build: .
|
build: .
|
||||||
container_name: api
|
container_name: api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: bash -c "uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips=*"
|
command: python library_service/main.py
|
||||||
logging:
|
logging:
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
ports:
|
ports: # !только локальный тест!
|
||||||
- 8000:8000
|
- 8000:8000
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
@@ -39,25 +94,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
llm:
|
||||||
tests:
|
condition: service_healthy
|
||||||
container_name: tests
|
|
||||||
build: .
|
|
||||||
command: bash -c "pytest tests"
|
|
||||||
restart: no
|
|
||||||
logging:
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
networks:
|
|
||||||
- proxy
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
volumes:
|
|
||||||
- .:/code
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy: # Рекомендуется использовать через реверс-прокси
|
proxy: # Рекомендуется использовать через реверс-прокси
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -16,6 +16,10 @@ from .core import (
|
|||||||
RECOVERY_CODE_SEGMENT_BYTES,
|
RECOVERY_CODE_SEGMENT_BYTES,
|
||||||
RECOVERY_MIN_REMAINING_WARNING,
|
RECOVERY_MIN_REMAINING_WARNING,
|
||||||
RECOVERY_MAX_AGE_DAYS,
|
RECOVERY_MAX_AGE_DAYS,
|
||||||
|
KeyDeriver,
|
||||||
|
deriver,
|
||||||
|
AES256Cipher,
|
||||||
|
cipher,
|
||||||
verify_password,
|
verify_password,
|
||||||
get_password_hash,
|
get_password_hash,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
@@ -75,6 +79,10 @@ __all__ = [
|
|||||||
"RECOVERY_CODE_SEGMENT_BYTES",
|
"RECOVERY_CODE_SEGMENT_BYTES",
|
||||||
"RECOVERY_MIN_REMAINING_WARNING",
|
"RECOVERY_MIN_REMAINING_WARNING",
|
||||||
"RECOVERY_MAX_AGE_DAYS",
|
"RECOVERY_MAX_AGE_DAYS",
|
||||||
|
"KeyDeriver",
|
||||||
|
"deriver",
|
||||||
|
"AES256Cipher",
|
||||||
|
"cipher",
|
||||||
"verify_password",
|
"verify_password",
|
||||||
"get_password_hash",
|
"get_password_hash",
|
||||||
"create_access_token",
|
"create_access_token",
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Модуль основного функционала авторизации и аутентификации"""
|
"""Модуль основного функционала авторизации и аутентификации"""
|
||||||
|
|
||||||
import os
|
|
||||||
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
|
||||||
@@ -17,7 +20,6 @@ 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"))
|
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"))
|
||||||
@@ -38,16 +40,76 @@ 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_MIN_REMAINING_WARNING = int(os.getenv("RECOVERY_MIN_REMAINING_WARNING", "3"))
|
||||||
RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365"))
|
RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365"))
|
||||||
|
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||||
|
|
||||||
# Получение логгера
|
# Получение логгера
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
# OAuth2 схема
|
# OAuth2 схема
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||||
|
|
||||||
|
|
||||||
|
class KeyDeriver:
|
||||||
|
def __init__(self, master_key: bytes):
|
||||||
|
self.master_key = master_key
|
||||||
|
|
||||||
|
def derive(
|
||||||
|
self,
|
||||||
|
context: str,
|
||||||
|
key_len: int = 32,
|
||||||
|
time_cost: int = 12,
|
||||||
|
memory_cost: int = 512 * 1024,
|
||||||
|
parallelism: int = 4,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
Формирование разных ключей из одного.
|
||||||
|
context: любая строка, например "aes", "hmac", "totp"
|
||||||
|
"""
|
||||||
|
salt = hashlib.sha256(context.encode("utf-8")).digest()
|
||||||
|
key = hash_secret_raw(
|
||||||
|
secret=self.master_key,
|
||||||
|
salt=salt,
|
||||||
|
time_cost=time_cost,
|
||||||
|
memory_cost=memory_cost,
|
||||||
|
parallelism=parallelism,
|
||||||
|
hash_len=key_len,
|
||||||
|
type=Type.ID,
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
class AES256Cipher:
|
||||||
|
def __init__(self, key: bytes):
|
||||||
|
if len(key) != 32:
|
||||||
|
raise ValueError("AES-256 требует ключ длиной 32 байта")
|
||||||
|
self.key = key
|
||||||
|
self.aesgcm = AESGCM(key)
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: bytes, nonce_len: int = 12) -> bytes:
|
||||||
|
"""Зашифровывает данные с помощью AES256-GCM"""
|
||||||
|
nonce = os.urandom(nonce_len)
|
||||||
|
ct = self.aesgcm.encrypt(nonce, plaintext, associated_data=None)
|
||||||
|
return nonce + ct
|
||||||
|
|
||||||
|
def decrypt(self, data: bytes, nonce_len: int = 12) -> bytes:
|
||||||
|
"""Расшифровывает данные с помощью AES256-GCM"""
|
||||||
|
nonce = data[:nonce_len]
|
||||||
|
ct = data[nonce_len:]
|
||||||
|
return self.aesgcm.decrypt(nonce, ct, associated_data=None)
|
||||||
|
|
||||||
|
|
||||||
# Проверка секретного ключа
|
# Проверка секретного ключа
|
||||||
if not SECRET_KEY:
|
if not SECRET_KEY:
|
||||||
raise RuntimeError("SECRET_KEY environment variable is required")
|
raise RuntimeError("SECRET_KEY environment variable is required")
|
||||||
|
|
||||||
|
deriver = KeyDeriver(SECRET_KEY.encode())
|
||||||
|
|
||||||
|
jwt_key = deriver.derive("jwt", key_len=32)
|
||||||
|
|
||||||
|
aes_key = deriver.derive("totp", key_len=32)
|
||||||
|
cipher = AES256Cipher(aes_key)
|
||||||
|
|
||||||
|
|
||||||
# Хэширование паролей
|
# Хэширование паролей
|
||||||
pwd_context = CryptContext(
|
pwd_context = CryptContext(
|
||||||
schemes=["argon2"],
|
schemes=["argon2"],
|
||||||
@@ -88,7 +150,7 @@ def _create_token(
|
|||||||
}
|
}
|
||||||
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:
|
def create_partial_token(data: dict) -> str:
|
||||||
@@ -119,7 +181,7 @@ def decode_token(
|
|||||||
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")
|
||||||
|
|||||||
+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,7 +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
|
||||||
|
|
||||||
@@ -17,11 +19,16 @@ 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,14 +10,29 @@ 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)
|
genre_id: int | None = Field(
|
||||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
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):
|
||||||
@@ -25,8 +40,18 @@ 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):
|
||||||
@@ -35,13 +60,22 @@ class BookUserLink(SQLModel, table=True):
|
|||||||
Связывает книгу и пользователя с фиксацией времени.
|
Связывает книгу и пользователя с фиксацией времени.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "book_loans"
|
__tablename__ = "loans"
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
id: int | None = Field(
|
||||||
|
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||||
|
)
|
||||||
|
|
||||||
book_id: int = Field(foreign_key="book.id")
|
book_id: int = Field(foreign_key="book.id", description="Идентификатор")
|
||||||
user_id: int = Field(foreign_key="users.id")
|
user_id: int = Field(
|
||||||
|
foreign_key="users.id", description="Идентификатор пользователя"
|
||||||
|
)
|
||||||
|
|
||||||
borrowed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
borrowed_at: datetime = Field(
|
||||||
due_date: datetime
|
default_factory=lambda: datetime.now(timezone.utc),
|
||||||
returned_at: datetime | None = Field(default=None)
|
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)
|
||||||
|
|||||||
@@ -17,17 +17,32 @@ 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_2fa_enabled: bool = Field(default=False)
|
)
|
||||||
totp_secret: str | None = Field(default=None, max_length=64)
|
hashed_password: str = Field(nullable=False, description="Argon2id хэш пароля")
|
||||||
recovery_code_hashes: str | None = Field(default=None, max_length=1500)
|
is_2fa_enabled: bool = Field(default=False, description="Включен TOTP 2FA")
|
||||||
recovery_codes_generated_at: datetime | None = Field(default=None)
|
totp_secret: str | None = Field(
|
||||||
is_active: bool = Field(default=True)
|
default=None, max_length=80, description="Зашифрованный секрет TOTP"
|
||||||
is_verified: bool = Field(default=False)
|
)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
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": lambda: datetime.now(timezone.utc)}
|
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)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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 .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
|
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
|
||||||
from .token import Token, TokenData, PartialToken
|
from .token import TokenData
|
||||||
from .misc import (
|
from .misc import (
|
||||||
AuthorWithBooks,
|
AuthorWithBooks,
|
||||||
GenreWithBooks,
|
GenreWithBooks,
|
||||||
@@ -62,9 +62,7 @@ __all__ = [
|
|||||||
"RoleUpdate",
|
"RoleUpdate",
|
||||||
"RoleRead",
|
"RoleRead",
|
||||||
"RoleList",
|
"RoleList",
|
||||||
"Token",
|
|
||||||
"TokenData",
|
"TokenData",
|
||||||
"PartialToken",
|
|
||||||
"TOTPSetupResponse",
|
"TOTPSetupResponse",
|
||||||
"TOTPVerifyRequest",
|
"TOTPVerifyRequest",
|
||||||
"TOTPDisableRequest",
|
"TOTPDisableRequest",
|
||||||
|
|||||||
@@ -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,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="Количество выдач")
|
||||||
|
|||||||
@@ -18,127 +18,145 @@ from .recovery import RecoveryCodesResponse
|
|||||||
class AuthorWithBooks(SQLModel):
|
class AuthorWithBooks(SQLModel):
|
||||||
"""Модель автора с книгами"""
|
"""Модель автора с книгами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
name: str
|
name: str = Field(description="Псевдоним")
|
||||||
books: List[BookRead] = Field(default_factory=list)
|
books: List[BookRead] = Field(default_factory=list, description="Список книг")
|
||||||
|
|
||||||
|
|
||||||
class GenreWithBooks(SQLModel):
|
class GenreWithBooks(SQLModel):
|
||||||
"""Модель жанра с книгами"""
|
"""Модель жанра с книгами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
name: str
|
name: str = Field(description="Название")
|
||||||
books: List[BookRead] = Field(default_factory=list)
|
books: List[BookRead] = Field(default_factory=list, description="Список книг")
|
||||||
|
|
||||||
|
|
||||||
class BookWithAuthors(SQLModel):
|
class BookWithAuthors(SQLModel):
|
||||||
"""Модель книги с авторами"""
|
"""Модель книги с авторами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
title: str
|
title: str = Field(description="Название")
|
||||||
description: str
|
description: str = Field(description="Описание")
|
||||||
authors: List[AuthorRead] = Field(default_factory=list)
|
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):
|
class BookWithGenres(SQLModel):
|
||||||
"""Модель книги с жанрами"""
|
"""Модель книги с жанрами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
title: str
|
title: str = Field(description="Название")
|
||||||
description: str
|
description: str = Field(description="Описание")
|
||||||
status: BookStatus | None = None
|
page_count: int = Field(description="Количество страниц")
|
||||||
genres: List[GenreRead] = Field(default_factory=list)
|
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):
|
class BookWithAuthorsAndGenres(SQLModel):
|
||||||
"""Модель с авторами и жанрами"""
|
"""Модель с авторами и жанрами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
title: str
|
title: str = Field(description="Название")
|
||||||
description: str
|
description: str = Field(description="Описание")
|
||||||
status: BookStatus | None = None
|
page_count: int = Field(description="Количество страниц")
|
||||||
authors: List[AuthorRead] = Field(default_factory=list)
|
status: BookStatus | None = Field(None, description="Статус")
|
||||||
genres: List[GenreRead] = Field(default_factory=list)
|
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):
|
class BookFilteredList(SQLModel):
|
||||||
"""Список книг с фильтрацией"""
|
"""Список книг с фильтрацией"""
|
||||||
|
|
||||||
books: List[BookWithAuthorsAndGenres]
|
books: List[BookWithAuthorsAndGenres] = Field(
|
||||||
total: int
|
description="Список отфильтрованных книг"
|
||||||
|
)
|
||||||
|
total: int = Field(description="Количество книг")
|
||||||
|
|
||||||
|
|
||||||
class LoanWithBook(LoanRead):
|
class LoanWithBook(LoanRead):
|
||||||
"""Модель выдачи, включающая данные о книге"""
|
"""Модель выдачи, включающая данные о книге"""
|
||||||
|
|
||||||
book: BookRead
|
book: BookRead = Field(description="Книга")
|
||||||
|
|
||||||
|
|
||||||
class BookStatusUpdate(SQLModel):
|
class BookStatusUpdate(SQLModel):
|
||||||
"""Модель для ручного изменения статуса библиотекарем"""
|
"""Модель для ручного изменения статуса библиотекарем"""
|
||||||
|
|
||||||
status: str
|
status: str = Field(description="Статус книги")
|
||||||
|
|
||||||
|
|
||||||
class UserCreateByAdmin(UserCreate):
|
class UserCreateByAdmin(UserCreate):
|
||||||
"""Создание пользователя администратором"""
|
"""Создание пользователя администратором"""
|
||||||
|
|
||||||
is_active: bool = True
|
is_active: bool = Field(True, description="Не является ли заблокированным")
|
||||||
roles: list[str] | None = None
|
roles: list[str] | None = Field(None, description="Роли")
|
||||||
|
|
||||||
|
|
||||||
class UserUpdateByAdmin(UserUpdate):
|
class UserUpdateByAdmin(UserUpdate):
|
||||||
"""Обновление пользователя администратором"""
|
"""Обновление пользователя администратором"""
|
||||||
|
|
||||||
is_active: bool | None = None
|
is_active: bool = Field(True, description="Не является ли заблокированным")
|
||||||
roles: list[str] | None = None
|
roles: list[str] | None = Field(None, description="Роли")
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(SQLModel):
|
class LoginResponse(SQLModel):
|
||||||
"""Модель для авторизации пользователя"""
|
"""Модель для авторизации пользователя"""
|
||||||
|
|
||||||
access_token: str | None = None
|
access_token: str | None = Field(None, description="Токен доступа")
|
||||||
partial_token: str | None = None
|
partial_token: str | None = Field(None, description="Частичный токен")
|
||||||
refresh_token: str | None = None
|
refresh_token: str | None = Field(None, description="Токен обновления")
|
||||||
token_type: str = "bearer"
|
token_type: str = Field("bearer", description="Тип токена")
|
||||||
requires_2fa: bool = False
|
requires_2fa: bool = Field(False, description="Требуется ли TOTP=код")
|
||||||
|
|
||||||
|
|
||||||
class RegisterResponse(SQLModel):
|
class RegisterResponse(SQLModel):
|
||||||
"""Модель для регистрации пользователя"""
|
"""Модель для регистрации пользователя"""
|
||||||
|
|
||||||
user: UserRead
|
user: UserRead = Field(description="Пользователь")
|
||||||
recovery_codes: RecoveryCodesResponse
|
recovery_codes: RecoveryCodesResponse = Field(description="Коды восстановления")
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetResponse(SQLModel):
|
class PasswordResetResponse(SQLModel):
|
||||||
"""Модель для сброса пароля"""
|
"""Модель для сброса пароля"""
|
||||||
|
|
||||||
total: int
|
total: int = Field(description="Общее количество кодов")
|
||||||
remaining: int
|
remaining: int = Field(description="Количество оставшихся кодов")
|
||||||
used_codes: list[bool]
|
used_codes: list[bool] = Field(description="Количество использованых кодов")
|
||||||
generated_at: datetime | None
|
generated_at: datetime | None = Field(description="Дата и время генерации")
|
||||||
should_regenerate: bool
|
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
|
||||||
|
|
||||||
|
|
||||||
class TOTPSetupResponse(SQLModel):
|
class TOTPSetupResponse(SQLModel):
|
||||||
"""Модель для генерации данных для настройки TOTP"""
|
"""Модель для генерации данных для настройки TOTP"""
|
||||||
|
|
||||||
secret: str
|
secret: str = Field(description="Секрет TOTP")
|
||||||
username: str
|
username: str = Field(description="Имя пользователя")
|
||||||
issuer: str
|
issuer: str = Field(description="Запрашивающий сервис")
|
||||||
size: int
|
size: int = Field(description="Размер кода")
|
||||||
padding: int
|
padding: int = Field(description="Отступ")
|
||||||
bitmap_b64: str
|
bitmap_b64: str = Field(description="QR-код")
|
||||||
|
|
||||||
|
|
||||||
class TOTPVerifyRequest(SQLModel):
|
class TOTPVerifyRequest(SQLModel):
|
||||||
"""Модель для проверки TOTP кода"""
|
"""Модель для проверки TOTP кода"""
|
||||||
|
|
||||||
code: str = Field(min_length=6, max_length=6, regex=r"^\d{6}$")
|
code: str = Field(
|
||||||
|
min_length=6,
|
||||||
|
max_length=6,
|
||||||
|
regex=r"^\d{6}$",
|
||||||
|
description="Шестизначный TOTP-код",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TOTPDisableRequest(SQLModel):
|
class TOTPDisableRequest(SQLModel):
|
||||||
"""Модель для отключения TOTP 2FA"""
|
"""Модель для отключения TOTP 2FA"""
|
||||||
|
|
||||||
password: str
|
password: str = Field(description="Пароль")
|
||||||
|
|||||||
@@ -10,26 +10,28 @@ from sqlmodel import SQLModel, Field
|
|||||||
class RecoveryCodesResponse(SQLModel):
|
class RecoveryCodesResponse(SQLModel):
|
||||||
"""Ответ при генерации резервных кодов"""
|
"""Ответ при генерации резервных кодов"""
|
||||||
|
|
||||||
codes: list[str]
|
codes: list[str] = Field(description="Список кодов восстановления")
|
||||||
generated_at: datetime
|
generated_at: datetime = Field(description="Дата и время генерации")
|
||||||
|
|
||||||
|
|
||||||
class RecoveryCodesStatus(SQLModel):
|
class RecoveryCodesStatus(SQLModel):
|
||||||
"""Статус резервных кодов пользователя"""
|
"""Статус резервных кодов пользователя"""
|
||||||
|
|
||||||
total: int
|
total: int = Field(description="Общее количество кодов")
|
||||||
remaining: int
|
remaining: int = Field(description="Количество оставшихся кодов")
|
||||||
used_codes: list[bool]
|
used_codes: list[bool] = Field(description="Количество использованых кодов")
|
||||||
generated_at: datetime | None
|
generated_at: datetime | None = Field(description="Дата и время генерации")
|
||||||
should_regenerate: bool
|
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
|
||||||
|
|
||||||
|
|
||||||
class RecoveryCodeUse(SQLModel):
|
class RecoveryCodeUse(SQLModel):
|
||||||
"""Запрос на сброс пароля через резервный код"""
|
"""Запрос на сброс пароля через резервный код"""
|
||||||
|
|
||||||
username: str
|
username: str = Field(description="Имя пользователя")
|
||||||
recovery_code: str = Field(min_length=19, max_length=19)
|
recovery_code: str = Field(
|
||||||
new_password: str = Field(min_length=8, max_length=100)
|
min_length=19, max_length=19, description="Код восстановления"
|
||||||
|
)
|
||||||
|
new_password: str = Field(min_length=8, max_length=100, description="Новый пароль")
|
||||||
|
|
||||||
@field_validator("recovery_code")
|
@field_validator("recovery_code")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -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,27 +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 PartialToken(SQLModel):
|
|
||||||
"""Частичный токен — для подтверждения 2FA"""
|
|
||||||
|
|
||||||
partial_token: str
|
|
||||||
token_type: str = "partial"
|
|
||||||
requires_2fa: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class TokenData(SQLModel):
|
class TokenData(SQLModel):
|
||||||
"""Модель содержимого токена"""
|
"""Модель содержимого токена"""
|
||||||
|
|
||||||
username: str | None = None
|
username: str | None = Field(None, description="Имя пользователя")
|
||||||
user_id: int | None = None
|
user_id: int | None = Field(None, description="Идентификатор пользователя")
|
||||||
is_partial: bool = False
|
is_partial: bool = Field(False, description="Является ли токен частичным")
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ from sqlmodel import Field, SQLModel
|
|||||||
class UserBase(SQLModel):
|
class UserBase(SQLModel):
|
||||||
"""Базовая модель пользователя"""
|
"""Базовая модель пользователя"""
|
||||||
|
|
||||||
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
|
username: str = Field(
|
||||||
email: EmailStr = Field(index=True, unique=True)
|
min_length=3,
|
||||||
full_name: str | None = Field(default=None, max_length=100)
|
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={
|
||||||
@@ -28,7 +36,7 @@ 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
|
||||||
@@ -46,30 +54,30 @@ class UserCreate(UserBase):
|
|||||||
class UserLogin(SQLModel):
|
class UserLogin(SQLModel):
|
||||||
"""Модель аутентификации для пользователя"""
|
"""Модель аутентификации для пользователя"""
|
||||||
|
|
||||||
username: str
|
username: str = Field(description="Имя пользователя")
|
||||||
password: str
|
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="Является ли верифицированым")
|
||||||
is_2fa_enabled: bool
|
is_2fa_enabled: bool = Field(description="Включен ли TOTP 2FA")
|
||||||
roles: List[str] = []
|
roles: List[str] = Field([], description="Роли")
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(SQLModel):
|
class UserUpdate(SQLModel):
|
||||||
"""Модель пользователя для обновления"""
|
"""Модель пользователя для обновления"""
|
||||||
|
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = Field(None, description="Email")
|
||||||
full_name: str | None = None
|
full_name: str | None = Field(None, description="Полное имя")
|
||||||
password: str | None = None
|
password: str | None = Field(None, description="Пароль")
|
||||||
|
|
||||||
|
|
||||||
class UserList(SQLModel):
|
class UserList(SQLModel):
|
||||||
"""Список пользователей"""
|
"""Список пользователей"""
|
||||||
|
|
||||||
users: List[UserRead]
|
users: List[UserRead] = Field(description="Список пользователей")
|
||||||
total: int
|
total: int = Field(description="Количество пользователей")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 .users import router as users_router
|
||||||
from .misc import router as misc_router
|
from .misc import router as misc_router
|
||||||
|
|
||||||
@@ -22,5 +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(users_router, prefix="/api")
|
||||||
api_router.include_router(relationships_router, prefix="/api")
|
api_router.include_router(relationships_router, prefix="/api")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Модуль работы с авторизацией и аутентификацией пользователей"""
|
"""Модуль работы с авторизацией и аутентификацией пользователей"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
@@ -7,17 +9,15 @@ 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
|
||||||
|
|
||||||
|
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,
|
||||||
Token,
|
|
||||||
PartialToken,
|
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
RecoveryCodeUse,
|
RecoveryCodeUse,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
@@ -50,6 +50,7 @@ from library_service.auth import (
|
|||||||
create_partial_token,
|
create_partial_token,
|
||||||
RequirePartialAuth,
|
RequirePartialAuth,
|
||||||
verify_and_use_code,
|
verify_and_use_code,
|
||||||
|
cipher,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +64,11 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
|
|||||||
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)
|
||||||
@@ -139,11 +144,14 @@ def login(
|
|||||||
)
|
)
|
||||||
|
|
||||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
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(
|
return LoginResponse(
|
||||||
access_token=create_access_token(
|
access_token=new_access_token,
|
||||||
data=token_data, expires_delta=access_token_expires
|
refresh_token=new_refresh_token,
|
||||||
),
|
|
||||||
refresh_token=create_refresh_token(data=token_data),
|
|
||||||
token_type="bearer",
|
token_type="bearer",
|
||||||
requires_2fa=False,
|
requires_2fa=False,
|
||||||
)
|
)
|
||||||
@@ -151,7 +159,7 @@ def login(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/refresh",
|
"/refresh",
|
||||||
response_model=Token,
|
response_model=LoginResponse,
|
||||||
summary="Обновление токена",
|
summary="Обновление токена",
|
||||||
description="Получение новой пары токенов, используя действующий Refresh токен",
|
description="Получение новой пары токенов, используя действующий Refresh токен",
|
||||||
)
|
)
|
||||||
@@ -182,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -245,9 +252,19 @@ def update_user_me(
|
|||||||
summary="Создание QR-кода TOTP 2FA",
|
summary="Создание QR-кода TOTP 2FA",
|
||||||
description="Генерирует секрет и QR-код для настройки TOTP",
|
description="Генерирует секрет и QR-код для настройки TOTP",
|
||||||
)
|
)
|
||||||
def get_totp_qr_bitmap(auth: RequireAuth):
|
def get_totp_qr_bitmap(
|
||||||
|
current_user: RequireAuth,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
"""Возвращает данные для настройки TOTP"""
|
"""Возвращает данные для настройки TOTP"""
|
||||||
return TOTPSetupResponse(**generate_totp_setup(auth.username))
|
totp_data = generate_totp_setup(current_user.username)
|
||||||
|
encrypted = cipher.encrypt(totp_data["secret"].encode())
|
||||||
|
|
||||||
|
current_user.totp_secret = base64.b64encode(encrypted).decode()
|
||||||
|
session.add(current_user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return TOTPSetupResponse(**totp_data)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -268,13 +285,23 @@ def enable_2fa(
|
|||||||
detail="2FA already enabled",
|
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):
|
if not verify_totp_code(secret, data.code):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Invalid TOTP code",
|
detail="Invalid TOTP code",
|
||||||
)
|
)
|
||||||
|
|
||||||
current_user.totp_secret = secret
|
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
|
current_user.is_2fa_enabled = True
|
||||||
session.add(current_user)
|
session.add(current_user)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -315,7 +342,7 @@ def disable_2fa(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/2fa/verify",
|
"/2fa/verify",
|
||||||
response_model=Token,
|
response_model=LoginResponse,
|
||||||
summary="Верификация 2FA",
|
summary="Верификация 2FA",
|
||||||
description="Завершает аутентификацию с помощью TOTP кода или резервного кода",
|
description="Завершает аутентификацию с помощью TOTP кода или резервного кода",
|
||||||
)
|
)
|
||||||
@@ -334,23 +361,28 @@ def verify_2fa(
|
|||||||
verified = False
|
verified = False
|
||||||
|
|
||||||
if data.code and user.totp_secret:
|
if data.code and user.totp_secret:
|
||||||
if verify_totp_code(user.totp_secret, data.code):
|
decrypted = cipher.decrypt(base64.b64decode(user.totp_secret.encode()))
|
||||||
|
if verify_totp_code(decrypted.decode(), data.code):
|
||||||
verified = True
|
verified = True
|
||||||
|
|
||||||
if not verified:
|
if not verified:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Invalid 2FA code",
|
detail="Invalid 2FA code",
|
||||||
)
|
)
|
||||||
|
|
||||||
token_data = {"sub": user.username, "user_id": user.id}
|
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(
|
||||||
|
data=token_data, expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
new_refresh_token = create_refresh_token(data=token_data)
|
||||||
|
|
||||||
return Token(
|
return LoginResponse(
|
||||||
access_token=create_access_token(
|
access_token=new_access_token,
|
||||||
data=token_data, expires_delta=access_token_expires
|
refresh_token=new_refresh_token,
|
||||||
),
|
token_type="bearer",
|
||||||
refresh_token=create_refresh_token(data=token_data),
|
requires_2fa=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +1,24 @@
|
|||||||
"""Модуль работы с книгами"""
|
"""Модуль работы с книгами"""
|
||||||
|
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 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 (
|
from library_service.models.db import (
|
||||||
Author,
|
Author,
|
||||||
@@ -32,75 +43,80 @@ from library_service.models.dto.misc import (
|
|||||||
|
|
||||||
|
|
||||||
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.now(timezone.utc)
|
active_loan.returned_at = datetime.now(timezone.utc)
|
||||||
session.add(active_loan)
|
session.add(active_loan)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
from sqlalchemy import select, func, distinct, case, exists
|
||||||
"/filter",
|
from sqlalchemy.orm import selectinload
|
||||||
response_model=BookFilteredList,
|
|
||||||
summary="Фильтрация книг",
|
|
||||||
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией",
|
@router.get("/filter", response_model=BookFilteredList)
|
||||||
)
|
|
||||||
def filter_books(
|
def filter_books(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
q: str | None = Query(None, max_length=50, description="Поиск"),
|
q: str | None = Query(None, max_length=50, description="Поиск"),
|
||||||
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
|
min_page_count: int | None = Query(None, ge=0),
|
||||||
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
|
max_page_count: int | None = Query(None, ge=0),
|
||||||
page: int = Query(1, gt=0, description="Номер страницы"),
|
author_ids: List[Annotated[int, Field(gt=0)]] | None = Query(None),
|
||||||
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
|
genre_ids: List[Annotated[int, Field(gt=0)]] | None = Query(None),
|
||||||
|
page: int = Query(1, gt=0),
|
||||||
|
size: int = Query(20, gt=0, le=100),
|
||||||
):
|
):
|
||||||
"""Возвращает отфильтрованный список книг с пагинацией"""
|
statement = select(Book).options(
|
||||||
statement = select(Book).distinct()
|
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) # ty: ignore
|
||||||
|
)
|
||||||
|
|
||||||
if q:
|
if min_page_count:
|
||||||
statement = statement.where(
|
statement = statement.where(Book.page_count >= min_page_count) # ty: ignore
|
||||||
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
|
if max_page_count:
|
||||||
)
|
statement = statement.where(Book.page_count <= max_page_count) # ty: ignore
|
||||||
|
|
||||||
if author_ids:
|
if author_ids:
|
||||||
statement = statement.join(AuthorBookLink).where(
|
statement = statement.where(
|
||||||
AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
|
exists().where(
|
||||||
author_ids
|
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(
|
for genre_id in genre_ids:
|
||||||
GenreBookLink.genre_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
|
statement = statement.where(
|
||||||
genre_ids
|
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(
|
||||||
@@ -115,11 +131,21 @@ def create_book(
|
|||||||
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(
|
||||||
@@ -130,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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -149,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
|
||||||
|
|
||||||
@@ -172,7 +217,7 @@ def get_book(
|
|||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/{book_id}",
|
"/{book_id}",
|
||||||
response_model=Book,
|
response_model=BookRead,
|
||||||
summary="Обновить информацию о книге",
|
summary="Обновить информацию о книге",
|
||||||
description="Обновляет информацию о книге в системе",
|
description="Обновляет информацию о книге в системе",
|
||||||
)
|
)
|
||||||
@@ -185,12 +230,14 @@ 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' устанавливается только через выдачу книги",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,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(
|
||||||
@@ -226,13 +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),
|
id=(book.id or 0),
|
||||||
title=book.title,
|
title=book.title,
|
||||||
description=book.description,
|
description=book.description,
|
||||||
|
page_count=book.page_count,
|
||||||
status=book.status,
|
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)
|
||||||
|
|||||||
@@ -40,17 +40,21 @@ def create_loan(
|
|||||||
|
|
||||||
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,
|
||||||
@@ -248,7 +252,9 @@ 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)
|
||||||
|
|
||||||
@@ -275,7 +281,9 @@ 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)
|
||||||
|
|
||||||
@@ -287,7 +295,9 @@ def update_loan(
|
|||||||
|
|
||||||
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:
|
||||||
@@ -297,7 +307,9 @@ def update_loan(
|
|||||||
)
|
)
|
||||||
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:
|
||||||
@@ -305,7 +317,10 @@ 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(status_code=400, detail="Loan is already returned")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
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
|
||||||
|
|
||||||
@@ -331,18 +346,24 @@ 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}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -370,10 +391,14 @@ 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.now(timezone.utc)
|
loan.returned_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -403,7 +428,9 @@ 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)
|
||||||
|
|
||||||
@@ -417,7 +444,7 @@ def delete_loan(
|
|||||||
|
|
||||||
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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -471,16 +498,21 @@ 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, detail=f"Book is not available (status: {book.status})"
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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("/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()
|
||||||
@@ -56,21 +61,22 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
|
|||||||
|
|
||||||
|
|
||||||
def get_related(
|
def get_related(
|
||||||
session,
|
session,
|
||||||
main_model,
|
main_model,
|
||||||
main_id,
|
main_id,
|
||||||
main_name,
|
main_name,
|
||||||
related_model,
|
related_model,
|
||||||
link_model,
|
link_model,
|
||||||
link_main_field,
|
link_main_field,
|
||||||
link_related_field,
|
link_related_field,
|
||||||
read_model
|
read_model,
|
||||||
):
|
):
|
||||||
"""Возвращает список связанных сущностей"""
|
"""Возвращает список связанных сущностей"""
|
||||||
check_entity_exists(session, main_model, main_id, main_name)
|
check_entity_exists(session, main_model, main_id, main_name)
|
||||||
|
|
||||||
related = session.exec(
|
related = session.exec(
|
||||||
select(related_model).join(link_model)
|
select(related_model)
|
||||||
|
.join(link_model)
|
||||||
.where(getattr(link_model, link_main_field) == main_id)
|
.where(getattr(link_model, link_main_field) == main_id)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@@ -93,8 +99,15 @@ def add_author_to_book(
|
|||||||
check_entity_exists(session, Author, author_id, "Author")
|
check_entity_exists(session, Author, author_id, "Author")
|
||||||
check_entity_exists(session, Book, book_id, "Book")
|
check_entity_exists(session, Book, book_id, "Book")
|
||||||
|
|
||||||
return add_relationship(session, AuthorBookLink,
|
return add_relationship(
|
||||||
author_id, "author_id", book_id, "book_id", "Relationship already exists")
|
session,
|
||||||
|
AuthorBookLink,
|
||||||
|
author_id,
|
||||||
|
"author_id",
|
||||||
|
book_id,
|
||||||
|
"book_id",
|
||||||
|
"Relationship already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -110,8 +123,9 @@ def remove_author_from_book(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Удаляет связь между автором и книгой"""
|
"""Удаляет связь между автором и книгой"""
|
||||||
return remove_relationship(session, AuthorBookLink,
|
return remove_relationship(
|
||||||
author_id, "author_id", book_id, "book_id")
|
session, AuthorBookLink, author_id, "author_id", book_id, "book_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -122,9 +136,17 @@ def remove_author_from_book(
|
|||||||
)
|
)
|
||||||
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
|
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
|
||||||
"""Возвращает список книг автора"""
|
"""Возвращает список книг автора"""
|
||||||
return get_related(session,
|
return get_related(
|
||||||
Author, author_id, "Author", Book,
|
session,
|
||||||
AuthorBookLink, "author_id", "book_id", BookRead)
|
Author,
|
||||||
|
author_id,
|
||||||
|
"Author",
|
||||||
|
Book,
|
||||||
|
AuthorBookLink,
|
||||||
|
"author_id",
|
||||||
|
"book_id",
|
||||||
|
BookRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -135,9 +157,17 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
|
|||||||
)
|
)
|
||||||
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||||
"""Возвращает список авторов книги"""
|
"""Возвращает список авторов книги"""
|
||||||
return get_related(session,
|
return get_related(
|
||||||
Book, book_id, "Book", Author,
|
session,
|
||||||
AuthorBookLink, "book_id", "author_id", AuthorRead)
|
Book,
|
||||||
|
book_id,
|
||||||
|
"Book",
|
||||||
|
Author,
|
||||||
|
AuthorBookLink,
|
||||||
|
"book_id",
|
||||||
|
"author_id",
|
||||||
|
AuthorRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -156,8 +186,15 @@ def add_genre_to_book(
|
|||||||
check_entity_exists(session, Genre, genre_id, "Genre")
|
check_entity_exists(session, Genre, genre_id, "Genre")
|
||||||
check_entity_exists(session, Book, book_id, "Book")
|
check_entity_exists(session, Book, book_id, "Book")
|
||||||
|
|
||||||
return add_relationship(session, GenreBookLink,
|
return add_relationship(
|
||||||
genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
|
session,
|
||||||
|
GenreBookLink,
|
||||||
|
genre_id,
|
||||||
|
"genre_id",
|
||||||
|
book_id,
|
||||||
|
"book_id",
|
||||||
|
"Relationship already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -173,8 +210,9 @@ def remove_genre_from_book(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Удаляет связь между жанром и книгой"""
|
"""Удаляет связь между жанром и книгой"""
|
||||||
return remove_relationship(session, GenreBookLink,
|
return remove_relationship(
|
||||||
genre_id, "genre_id", book_id, "book_id")
|
session, GenreBookLink, genre_id, "genre_id", book_id, "book_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -185,9 +223,17 @@ def remove_genre_from_book(
|
|||||||
)
|
)
|
||||||
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||||
"""Возвращает список книг в жанре"""
|
"""Возвращает список книг в жанре"""
|
||||||
return get_related(session,
|
return get_related(
|
||||||
Genre, genre_id, "Genre", Book,
|
session,
|
||||||
GenreBookLink, "genre_id", "book_id", BookRead)
|
Genre,
|
||||||
|
genre_id,
|
||||||
|
"Genre",
|
||||||
|
Book,
|
||||||
|
GenreBookLink,
|
||||||
|
"genre_id",
|
||||||
|
"book_id",
|
||||||
|
BookRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -198,6 +244,14 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
|||||||
)
|
)
|
||||||
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
|
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||||
"""Возвращает список жанров книги"""
|
"""Возвращает список жанров книги"""
|
||||||
return get_related(session,
|
return get_related(
|
||||||
Book, book_id, "Book", Genre,
|
session,
|
||||||
GenreBookLink, "book_id", "genre_id", GenreRead)
|
Book,
|
||||||
|
book_id,
|
||||||
|
"Book",
|
||||||
|
Genre,
|
||||||
|
GenreBookLink,
|
||||||
|
"book_id",
|
||||||
|
"genre_id",
|
||||||
|
GenreRead,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ OPENAPI_TAGS = [
|
|||||||
{"name": "loans", "description": "Действия с выдачами."},
|
{"name": "loans", "description": "Действия с выдачами."},
|
||||||
{"name": "relations", "description": "Действия со связями."},
|
{"name": "relations", "description": "Действия со связями."},
|
||||||
{"name": "users", "description": "Действия с пользователями."},
|
{"name": "users", "description": "Действия с пользователями."},
|
||||||
|
{"name": "captcha", "description": "Создание и проверка cap.js каптчи."},
|
||||||
{"name": "misc", "description": "Прочие."},
|
{"name": "misc", "description": "Прочие."},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -94,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}"
|
||||||
|
|||||||
+324
-279
@@ -1,6 +1,71 @@
|
|||||||
$(() => {
|
$(() => {
|
||||||
const PARTIAL_TOKEN_KEY = "partial_token";
|
const SELECTORS = {
|
||||||
const PARTIAL_USERNAME_KEY = "partial_username";
|
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 TOTP_PERIOD = 30;
|
||||||
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38;
|
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38;
|
||||||
|
|
||||||
@@ -14,96 +79,89 @@ $(() => {
|
|||||||
let registeredRecoveryCodes = [];
|
let registeredRecoveryCodes = [];
|
||||||
let totpAnimationFrame = null;
|
let totpAnimationFrame = null;
|
||||||
|
|
||||||
function getTotpProgress() {
|
const getTotpProgress = () => {
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
const elapsed = now % TOTP_PERIOD;
|
const elapsed = now % TOTP_PERIOD;
|
||||||
return elapsed / TOTP_PERIOD;
|
return elapsed / TOTP_PERIOD;
|
||||||
}
|
};
|
||||||
|
|
||||||
function updateTotpTimer() {
|
const updateTotpTimer = () => {
|
||||||
const circle = document.getElementById("lock-progress-circle");
|
const circle = $(SELECTORS.lockProgressCircle).get(0);
|
||||||
if (!circle) return;
|
if (!circle) return;
|
||||||
|
|
||||||
const progress = getTotpProgress();
|
const progress = getTotpProgress();
|
||||||
const offset = CIRCLE_CIRCUMFERENCE * (1 - progress);
|
const offset = CIRCLE_CIRCUMFERENCE * (1 - progress);
|
||||||
circle.style.strokeDashoffset = offset;
|
circle.style.strokeDashoffset = offset;
|
||||||
|
|
||||||
totpAnimationFrame = requestAnimationFrame(updateTotpTimer);
|
totpAnimationFrame = requestAnimationFrame(updateTotpTimer);
|
||||||
}
|
};
|
||||||
|
|
||||||
function startTotpTimer() {
|
const startTotpTimer = () => {
|
||||||
stopTotpTimer();
|
stopTotpTimer();
|
||||||
updateTotpTimer();
|
updateTotpTimer();
|
||||||
}
|
};
|
||||||
|
|
||||||
function stopTotpTimer() {
|
const stopTotpTimer = () => {
|
||||||
if (totpAnimationFrame) {
|
if (totpAnimationFrame) {
|
||||||
cancelAnimationFrame(totpAnimationFrame);
|
cancelAnimationFrame(totpAnimationFrame);
|
||||||
totpAnimationFrame = null;
|
totpAnimationFrame = null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function resetCircle() {
|
const resetCircle = () => {
|
||||||
const circle = document.getElementById("lock-progress-circle");
|
const circle = $(SELECTORS.lockProgressCircle).get(0);
|
||||||
if (circle) {
|
if (circle) circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE;
|
||||||
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}`,
|
||||||
function initLoginState() {
|
).addClass("hidden");
|
||||||
const savedToken = sessionStorage.getItem(PARTIAL_TOKEN_KEY);
|
|
||||||
const savedUsername = sessionStorage.getItem(PARTIAL_USERNAME_KEY);
|
|
||||||
|
|
||||||
if (savedToken && savedUsername) {
|
|
||||||
loginState.partialToken = savedToken;
|
|
||||||
loginState.username = savedUsername;
|
|
||||||
loginState.step = "2fa";
|
|
||||||
|
|
||||||
$("#login-username").val(savedUsername);
|
|
||||||
$("#credentials-section").addClass("hidden");
|
|
||||||
$("#totp-section").removeClass("hidden");
|
|
||||||
$("#login-submit").text("Подтвердить");
|
|
||||||
|
|
||||||
startTotpTimer();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const totpInput = document.getElementById("login-totp");
|
|
||||||
if (totpInput) totpInput.focus();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePartialToken(token, username) {
|
|
||||||
sessionStorage.setItem(PARTIAL_TOKEN_KEY, token);
|
|
||||||
sessionStorage.setItem(PARTIAL_USERNAME_KEY, username);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPartialToken() {
|
|
||||||
sessionStorage.removeItem(PARTIAL_TOKEN_KEY);
|
|
||||||
sessionStorage.removeItem(PARTIAL_USERNAME_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showForm(formId) {
|
|
||||||
$("#login-form, #register-form, #reset-password-form").addClass("hidden");
|
|
||||||
$(formId).removeClass("hidden");
|
$(formId).removeClass("hidden");
|
||||||
|
|
||||||
$("#login-tab, #register-tab")
|
$(`${SELECTORS.loginTab}, ${SELECTORS.registerTab}`)
|
||||||
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
|
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
|
||||||
.addClass("text-gray-400 hover:text-gray-600");
|
.addClass("text-gray-400 hover:text-gray-600");
|
||||||
|
|
||||||
if (formId === "#login-form") {
|
if (formId === SELECTORS.loginForm) {
|
||||||
$("#login-tab")
|
$(SELECTORS.loginTab)
|
||||||
.removeClass("text-gray-400 hover:text-gray-600")
|
.removeClass("text-gray-400 hover:text-gray-600")
|
||||||
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
||||||
resetLoginState();
|
resetLoginState();
|
||||||
} else if (formId === "#register-form") {
|
} else if (formId === SELECTORS.registerForm) {
|
||||||
$("#register-tab")
|
$(SELECTORS.registerTab)
|
||||||
.removeClass("text-gray-400 hover:text-gray-600")
|
.removeClass("text-gray-400 hover:text-gray-600")
|
||||||
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function resetLoginState() {
|
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();
|
clearPartialToken();
|
||||||
stopTotpTimer();
|
stopTotpTimer();
|
||||||
loginState = {
|
loginState = {
|
||||||
@@ -112,30 +170,70 @@ $(() => {
|
|||||||
username: "",
|
username: "",
|
||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
};
|
};
|
||||||
$("#totp-section").addClass("hidden");
|
$(SELECTORS.authTabs).removeClass("hide-animated");
|
||||||
$("#login-totp").val("");
|
$(SELECTORS.totpSection).addClass("hidden");
|
||||||
$("#credentials-section").removeClass("hidden");
|
$(SELECTORS.totpInput).val("");
|
||||||
$("#login-submit").text("Войти");
|
$(SELECTORS.credentialsSection).removeClass("hidden");
|
||||||
|
$(SELECTORS.submitLogin).text(TEXTS.login);
|
||||||
resetCircle();
|
resetCircle();
|
||||||
}
|
};
|
||||||
|
|
||||||
$("#login-tab").on("click", () => showForm("#login-form"));
|
const checkPasswordMatch = (passwordId, confirmId, errorId) => {
|
||||||
$("#register-tab").on("click", () => showForm("#register-form"));
|
const password = $(passwordId).val();
|
||||||
$("#forgot-password-btn").on("click", () => showForm("#reset-password-form"));
|
const confirm = $(confirmId).val();
|
||||||
$("#back-to-login-btn").on("click", () => showForm("#login-form"));
|
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 () {
|
$("body").on("click", ".toggle-password", function () {
|
||||||
const $btn = $(this);
|
const $input = $(this).siblings("input");
|
||||||
const $input = $btn.siblings("input");
|
|
||||||
const isPassword = $input.attr("type") === "password";
|
const isPassword = $input.attr("type") === "password";
|
||||||
$input.attr("type", isPassword ? "text" : "password");
|
$input.attr("type", isPassword ? "text" : "password");
|
||||||
$btn.find("svg").toggleClass("hidden");
|
$(this).find("svg").toggleClass("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#register-password").on("input", function () {
|
$(SELECTORS.registerPassword).on("input", function () {
|
||||||
const password = $(this).val();
|
const password = $(this).val();
|
||||||
let strength = 0;
|
let strength = 0;
|
||||||
|
|
||||||
if (password.length >= 8) strength++;
|
if (password.length >= 8) strength++;
|
||||||
if (password.length >= 12) strength++;
|
if (password.length >= 12) strength++;
|
||||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
||||||
@@ -150,91 +248,65 @@ $(() => {
|
|||||||
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
|
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
|
||||||
{ width: "100%", color: "bg-green-500", text: "Отличный" },
|
{ width: "100%", color: "bg-green-500", text: "Отличный" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const level = levels[strength];
|
const level = levels[strength];
|
||||||
$("#password-strength-bar")
|
$(SELECTORS.passwordStrengthBar)
|
||||||
.css("width", level.width)
|
.css("width", level.width)
|
||||||
.attr("class", "h-full transition-all duration-300 " + level.color);
|
.attr("class", `h-full transition-all duration-300 ${level.color}`);
|
||||||
$("#password-strength-text").text(level.text);
|
$(SELECTORS.passwordStrengthText).text(level.text);
|
||||||
|
checkPasswordMatch(
|
||||||
checkPasswordMatch();
|
SELECTORS.registerPassword,
|
||||||
|
SELECTORS.registerConfirm,
|
||||||
|
SELECTORS.passwordMatchError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkPasswordMatch() {
|
$(SELECTORS.registerConfirm).on("input", () =>
|
||||||
const password = $("#register-password").val();
|
checkPasswordMatch(
|
||||||
const confirm = $("#register-password-confirm").val();
|
SELECTORS.registerPassword,
|
||||||
if (confirm && password !== confirm) {
|
SELECTORS.registerConfirm,
|
||||||
$("#password-match-error").removeClass("hidden");
|
SELECTORS.passwordMatchError,
|
||||||
return false;
|
),
|
||||||
}
|
);
|
||||||
$("#password-match-error").addClass("hidden");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#register-password-confirm").on("input", checkPasswordMatch);
|
$(SELECTORS.resetCode).on("input", function () {
|
||||||
|
let value = this.value.toUpperCase().replace(/[^0-9A-F]/g, "");
|
||||||
function formatRecoveryCode(input) {
|
|
||||||
let value = input.value.toUpperCase().replace(/[^0-9A-F]/g, "");
|
|
||||||
let formatted = "";
|
let formatted = "";
|
||||||
for (let i = 0; i < value.length && i < 16; i++) {
|
for (let i = 0; i < value.length && i < 16; i++) {
|
||||||
if (i > 0 && i % 4 === 0) formatted += "-";
|
if (i > 0 && i % 4 === 0) formatted += "-";
|
||||||
formatted += value[i];
|
formatted += value[i];
|
||||||
}
|
}
|
||||||
input.value = formatted;
|
this.value = formatted;
|
||||||
}
|
|
||||||
|
|
||||||
$("#reset-recovery-code").on("input", function () {
|
|
||||||
formatRecoveryCode(this);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#login-totp").on("input", function () {
|
$(SELECTORS.totpInput).on("input", function () {
|
||||||
this.value = this.value.replace(/\D/g, "").slice(0, 6);
|
this.value = this.value.replace(/\D/g, "").slice(0, 6);
|
||||||
if (this.value.length === 6) {
|
if (this.value.length === 6) $(SELECTORS.loginForm).trigger("submit");
|
||||||
$("#login-form").trigger("submit");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#back-to-credentials-btn").on("click", function () {
|
$(SELECTORS.loginForm).on("submit", async function (event) {
|
||||||
resetLoginState();
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#login-form").on("submit", async function (event) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const $submitBtn = $("#login-submit");
|
const $submitBtn = $(SELECTORS.submitLogin);
|
||||||
|
|
||||||
if (loginState.step === "credentials") {
|
if (loginState.step === "credentials") {
|
||||||
const username = $("#login-username").val();
|
const username = $(SELECTORS.usernameLogin).val();
|
||||||
const password = $("#login-password").val();
|
const password = $(SELECTORS.passwordLogin).val();
|
||||||
const rememberMe = $("#remember-me").prop("checked");
|
const rememberMe = $(SELECTORS.rememberMe).prop("checked");
|
||||||
|
|
||||||
loginState.username = username;
|
loginState.username = username;
|
||||||
loginState.rememberMe = rememberMe;
|
loginState.rememberMe = rememberMe;
|
||||||
|
|
||||||
$submitBtn.prop("disabled", true).text("Вход...");
|
$submitBtn.prop("disabled", true).text("Вход...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams({ username, password });
|
||||||
formData.append("username", username);
|
|
||||||
formData.append("password", password);
|
|
||||||
|
|
||||||
const data = await Api.postForm("/api/auth/token", formData);
|
const data = await Api.postForm("/api/auth/token", formData);
|
||||||
|
|
||||||
if (data.requires_2fa && data.partial_token) {
|
if (data.requires_2fa && data.partial_token) {
|
||||||
loginState.partialToken = data.partial_token;
|
loginState.partialToken = data.partial_token;
|
||||||
loginState.step = "2fa";
|
loginState.step = "2fa";
|
||||||
|
|
||||||
savePartialToken(data.partial_token, username);
|
savePartialToken(data.partial_token, username);
|
||||||
|
$(SELECTORS.authTabs).addClass("hide-animated");
|
||||||
$("#credentials-section").addClass("hidden");
|
$(SELECTORS.credentialsSection).addClass("hidden");
|
||||||
$("#totp-section").removeClass("hidden");
|
$(SELECTORS.totpSection).removeClass("hidden");
|
||||||
|
|
||||||
startTotpTimer();
|
startTotpTimer();
|
||||||
|
$(SELECTORS.totpInput).get(0)?.focus();
|
||||||
const totpInput = document.getElementById("login-totp");
|
$submitBtn.text(TEXTS.confirm);
|
||||||
if (totpInput) totpInput.focus();
|
Utils.showToast(TEXTS.enterTotp, "info");
|
||||||
|
|
||||||
$submitBtn.text("Подтвердить");
|
|
||||||
Utils.showToast("Введите код из приложения аутентификатора", "info");
|
|
||||||
} else if (data.access_token) {
|
} else if (data.access_token) {
|
||||||
clearPartialToken();
|
clearPartialToken();
|
||||||
saveTokensAndRedirect(data, rememberMe);
|
saveTokensAndRedirect(data, rememberMe);
|
||||||
@@ -243,20 +315,15 @@ $(() => {
|
|||||||
Utils.showToast(error.message || "Ошибка входа", "error");
|
Utils.showToast(error.message || "Ошибка входа", "error");
|
||||||
} finally {
|
} finally {
|
||||||
$submitBtn.prop("disabled", false);
|
$submitBtn.prop("disabled", false);
|
||||||
if (loginState.step === "credentials") {
|
if (loginState.step === "credentials") $submitBtn.text(TEXTS.login);
|
||||||
$submitBtn.text("Войти");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (loginState.step === "2fa") {
|
} else if (loginState.step === "2fa") {
|
||||||
const totpCode = $("#login-totp").val();
|
const totpCode = $(SELECTORS.totpInput).val();
|
||||||
|
|
||||||
if (!totpCode || totpCode.length !== 6) {
|
if (!totpCode || totpCode.length !== 6) {
|
||||||
Utils.showToast("Введите 6-значный код", "error");
|
Utils.showToast("Введите 6-значный код", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$submitBtn.prop("disabled", true).text(TEXTS.checking);
|
||||||
$submitBtn.prop("disabled", true).text("Проверка...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/2fa/verify", {
|
const response = await fetch("/api/auth/2fa/verify", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -266,113 +333,114 @@ $(() => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ code: totpCode }),
|
body: JSON.stringify({ code: totpCode }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
resetLoginState();
|
resetLoginState();
|
||||||
throw new Error(
|
throw new Error(TEXTS.sessionExpired);
|
||||||
"Время сессии истекло. Пожалуйста, войдите заново.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
throw new Error(errorData.detail || TEXTS.invalidCode);
|
||||||
throw new Error(errorData.detail || "Неверный код");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
clearPartialToken();
|
clearPartialToken();
|
||||||
stopTotpTimer();
|
stopTotpTimer();
|
||||||
saveTokensAndRedirect(data, loginState.rememberMe);
|
saveTokensAndRedirect(data, loginState.rememberMe);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Utils.showToast(error.message || "Неверный код", "error");
|
Utils.showToast(error.message || TEXTS.invalidCode, "error");
|
||||||
$("#login-totp").val("");
|
$(SELECTORS.totpInput).val("");
|
||||||
const totpInput = document.getElementById("login-totp");
|
$(SELECTORS.totpInput).get(0)?.focus();
|
||||||
if (totpInput) totpInput.focus();
|
|
||||||
} finally {
|
} finally {
|
||||||
$submitBtn.prop("disabled", false).text("Подтвердить");
|
$submitBtn.prop("disabled", false).text(TEXTS.confirm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function saveTokensAndRedirect(data, rememberMe) {
|
$(SELECTORS.registerForm).on("submit", async function (event) {
|
||||||
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 = "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#register-form").on("submit", async function (event) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const $submitBtn = $("#register-submit");
|
const $submitBtn = $(SELECTORS.submitRegister);
|
||||||
const pass = $("#register-password").val();
|
const pass = $(SELECTORS.registerPassword).val();
|
||||||
const confirm = $("#register-password-confirm").val();
|
const confirm = $(SELECTORS.registerConfirm).val();
|
||||||
|
|
||||||
if (pass !== confirm) {
|
if (pass !== confirm) {
|
||||||
Utils.showToast("Пароли не совпадают", "error");
|
Utils.showToast(TEXTS.passwordsNotMatch, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userData = {
|
const userData = {
|
||||||
username: $("#register-username").val(),
|
username: $(SELECTORS.registerUsername).val(),
|
||||||
email: $("#register-email").val(),
|
email: $(SELECTORS.registerEmail).val(),
|
||||||
full_name: $("#register-fullname").val() || null,
|
full_name: $(SELECTORS.registerFullname).val() || null,
|
||||||
password: pass,
|
password: pass,
|
||||||
};
|
};
|
||||||
|
$submitBtn.prop("disabled", true).text(TEXTS.registering);
|
||||||
$submitBtn.prop("disabled", true).text("Регистрация...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await Api.post("/api/auth/register", userData);
|
const response = await Api.post("/api/auth/register", userData);
|
||||||
|
|
||||||
if (response.recovery_codes && response.recovery_codes.codes) {
|
if (response.recovery_codes && response.recovery_codes.codes) {
|
||||||
registeredRecoveryCodes = response.recovery_codes.codes;
|
registeredRecoveryCodes = response.recovery_codes.codes;
|
||||||
showRecoveryCodesModal(registeredRecoveryCodes, userData.username);
|
showRecoveryCodesModal(registeredRecoveryCodes, userData.username);
|
||||||
} else {
|
} else {
|
||||||
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
|
Utils.showToast(TEXTS.registrationSuccess, "success");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showForm("#login-form");
|
showForm(SELECTORS.loginForm);
|
||||||
$("#login-username").val(userData.username);
|
$(SELECTORS.usernameLogin).val(userData.username);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let msg = error.message;
|
console.log("Debug error object:", error);
|
||||||
if (error.detail && Array.isArray(error.detail)) {
|
|
||||||
msg = error.detail.map((e) => e.msg).join(". ");
|
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;
|
||||||
}
|
}
|
||||||
Utils.showToast(msg || "Ошибка регистрации", "error");
|
|
||||||
|
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 {
|
} finally {
|
||||||
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
|
$submitBtn
|
||||||
|
.prop("disabled", false)
|
||||||
|
.text(TEXTS.registering.replace("...", ""));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function showRecoveryCodesModal(codes, username) {
|
const showRecoveryCodesModal = (codes, username) => {
|
||||||
const $list = $("#recovery-codes-list");
|
const $list = $(SELECTORS.recoveryList);
|
||||||
$list.empty();
|
$list.empty();
|
||||||
|
|
||||||
codes.forEach((code, index) => {
|
codes.forEach((code, index) => {
|
||||||
$list.append(`
|
$list.append(
|
||||||
<div class="py-1 px-2 bg-white rounded border select-all font-mono">
|
`<div class="py-1 px-2 bg-white rounded border select-all font-mono">${index + 1}. ${Utils.escapeHtml(code)}</div>`,
|
||||||
${index + 1}. ${Utils.escapeHtml(code)}
|
);
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
$(SELECTORS.codesSavedCheckbox).prop("checked", false);
|
||||||
|
$(SELECTORS.closeRecoveryBtn).prop("disabled", true);
|
||||||
|
$(SELECTORS.recoveryModal).data("username", username).removeClass("hidden");
|
||||||
|
};
|
||||||
|
|
||||||
$("#codes-saved-checkbox").prop("checked", false);
|
const renderRecoveryCodesStatus = (usedCodes) => {
|
||||||
$("#close-recovery-modal-btn").prop("disabled", true);
|
|
||||||
$("#recovery-codes-modal").data("username", username);
|
|
||||||
$("#recovery-codes-modal").removeClass("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRecoveryCodesStatus(usedCodes) {
|
|
||||||
return usedCodes
|
return usedCodes
|
||||||
.map((used, index) => {
|
.map((used, index) => {
|
||||||
const codeDisplay = "████-████-████-████";
|
const codeDisplay = "████-████-████-████";
|
||||||
@@ -380,31 +448,25 @@ $(() => {
|
|||||||
? "text-gray-300 line-through"
|
? "text-gray-300 line-through"
|
||||||
: "text-green-600";
|
: "text-green-600";
|
||||||
const statusIcon = used ? "✗" : "✓";
|
const statusIcon = used ? "✗" : "✓";
|
||||||
return `
|
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>`;
|
||||||
<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("");
|
.join("");
|
||||||
}
|
};
|
||||||
|
|
||||||
$("#codes-saved-checkbox").on("change", function () {
|
$(SELECTORS.codesSavedCheckbox).on("change", function () {
|
||||||
$("#close-recovery-modal-btn").prop("disabled", !this.checked);
|
$(SELECTORS.closeRecoveryBtn).prop("disabled", !this.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#copy-codes-btn").on("click", function () {
|
$(SELECTORS.copyCodesBtn).on("click", function () {
|
||||||
const codesText = registeredRecoveryCodes.join("\n");
|
const codesText = registeredRecoveryCodes.join("\n");
|
||||||
navigator.clipboard.writeText(codesText).then(() => {
|
navigator.clipboard
|
||||||
Utils.showToast("Коды скопированы в буфер обмена", "success");
|
.writeText(codesText)
|
||||||
});
|
.then(() => Utils.showToast(TEXTS.codesCopied, "success"));
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#download-codes-btn").on("click", function () {
|
$(SELECTORS.downloadCodesBtn).on("click", function () {
|
||||||
const username = $("#recovery-codes-modal").data("username") || "user";
|
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 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 blob = new Blob([codesText], { type: "text/plain;charset=utf-8" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -412,69 +474,54 @@ $(() => {
|
|||||||
a.download = `recovery-codes-${username}.txt`;
|
a.download = `recovery-codes-${username}.txt`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
Utils.showToast(TEXTS.codesDownloaded, "success");
|
||||||
Utils.showToast("Файл с кодами скачан", "success");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#close-recovery-modal-btn").on("click", function () {
|
$(SELECTORS.closeRecoveryBtn).on("click", function () {
|
||||||
const username = $("#recovery-codes-modal").data("username");
|
const username = $(SELECTORS.recoveryModal).data("username");
|
||||||
$("#recovery-codes-modal").addClass("hidden");
|
$(SELECTORS.recoveryModal).addClass("hidden");
|
||||||
|
Utils.showToast(TEXTS.registrationSuccess, "success");
|
||||||
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
|
showForm(SELECTORS.loginForm);
|
||||||
showForm("#login-form");
|
$(SELECTORS.usernameLogin).val(username);
|
||||||
$("#login-username").val(username);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkResetPasswordMatch() {
|
$(SELECTORS.resetConfirmPassword).on("input", () =>
|
||||||
const password = $("#reset-new-password").val();
|
checkPasswordMatch(
|
||||||
const confirm = $("#reset-confirm-password").val();
|
SELECTORS.resetNewPassword,
|
||||||
if (confirm && password !== confirm) {
|
SELECTORS.resetConfirmPassword,
|
||||||
$("#reset-password-match-error").removeClass("hidden");
|
SELECTORS.resetMatchError,
|
||||||
return false;
|
),
|
||||||
}
|
);
|
||||||
$("#reset-password-match-error").addClass("hidden");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#reset-confirm-password").on("input", checkResetPasswordMatch);
|
$(SELECTORS.resetForm).on("submit", async function (event) {
|
||||||
|
|
||||||
$("#reset-password-form").on("submit", async function (event) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const $submitBtn = $("#reset-submit");
|
const $submitBtn = $(SELECTORS.submitReset);
|
||||||
|
const newPassword = $(SELECTORS.resetNewPassword).val();
|
||||||
const newPassword = $("#reset-new-password").val();
|
const confirmPassword = $(SELECTORS.resetConfirmPassword).val();
|
||||||
const confirmPassword = $("#reset-confirm-password").val();
|
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
Utils.showToast("Пароли не совпадают", "error");
|
Utils.showToast(TEXTS.passwordsNotMatch, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
if (newPassword.length < 8) {
|
||||||
Utils.showToast("Пароль должен содержать минимум 8 символов", "error");
|
Utils.showToast(TEXTS.passwordTooShort, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
username: $("#reset-username").val(),
|
username: $(SELECTORS.resetUsername).val(),
|
||||||
recovery_code: $("#reset-recovery-code").val().toUpperCase(),
|
recovery_code: $(SELECTORS.resetCode).val().toUpperCase(),
|
||||||
new_password: newPassword,
|
new_password: newPassword,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test(
|
!/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test(
|
||||||
data.recovery_code,
|
data.recovery_code,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Utils.showToast("Неверный формат резервного кода", "error");
|
Utils.showToast(TEXTS.invalidRecoveryCode, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$submitBtn.prop("disabled", true).text(TEXTS.resetting);
|
||||||
$submitBtn.prop("disabled", true).text("Сброс...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await Api.post("/api/auth/password/reset", data);
|
const response = await Api.post("/api/auth/password/reset", data);
|
||||||
|
|
||||||
showPasswordResetResult(response, data.username);
|
showPasswordResetResult(response, data.username);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Utils.showToast(error.message || "Ошибка сброса пароля", "error");
|
Utils.showToast(error.message || "Ошибка сброса пароля", "error");
|
||||||
@@ -482,9 +529,8 @@ $(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function showPasswordResetResult(response, username) {
|
const showPasswordResetResult = (response, username) => {
|
||||||
const $form = $("#reset-password-form");
|
const $form = $(SELECTORS.resetForm);
|
||||||
|
|
||||||
$form.html(`
|
$form.html(`
|
||||||
<div class="text-center mb-4">
|
<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">
|
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
|
||||||
@@ -492,22 +538,19 @@ $(() => {
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold text-gray-800">Пароль успешно изменён!</h3>
|
<h3 class="text-lg font-semibold text-gray-800">${TEXTS.passwordResetSuccess}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p class="text-sm text-gray-600 mb-2 text-center">
|
<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}
|
Осталось резервных кодов: <strong class="${response.remaining <= 2 ? "text-red-600" : "text-green-600"}">${response.remaining}</strong> из ${response.total}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
${
|
${
|
||||||
response.should_regenerate
|
response.should_regenerate
|
||||||
? `
|
? `
|
||||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
|
<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">
|
<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">
|
<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"
|
<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>
|
||||||
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>
|
</svg>
|
||||||
Рекомендуем сгенерировать новые коды в профиле
|
Рекомендуем сгенерировать новые коды в профиле
|
||||||
</p>
|
</p>
|
||||||
@@ -515,12 +558,10 @@ $(() => {
|
|||||||
`
|
`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="bg-gray-50 rounded-lg p-3 space-y-1 max-h-48 overflow-y-auto">
|
<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>
|
<p class="text-xs text-gray-500 mb-2 text-center">Статус резервных кодов:</p>
|
||||||
${renderRecoveryCodesStatus(response.used_codes)}
|
${renderRecoveryCodesStatus(response.used_codes)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
${
|
||||||
response.generated_at
|
response.generated_at
|
||||||
? `
|
? `
|
||||||
@@ -531,23 +572,27 @@ $(() => {
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
</div>
|
</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 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>
|
</button>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
$form.off("submit");
|
$form.off("submit");
|
||||||
|
|
||||||
$("#goto-login-after-reset").on("click", function () {
|
$("#goto-login-after-reset").on("click", function () {
|
||||||
location.reload();
|
location.reload();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showForm("#login-form");
|
showForm(SELECTORS.loginForm);
|
||||||
$("#login-username").val(username);
|
$(SELECTORS.usernameLogin).val(username);
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
initLoginState();
|
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) => {
|
||||||
@@ -234,6 +521,16 @@ $(document).ready(() => {
|
|||||||
function renderBook(book) {
|
function renderBook(book) {
|
||||||
$("#book-title").text(book.title);
|
$("#book-title").text(book.title);
|
||||||
$("#book-id").text(`ID: ${book.id}`);
|
$("#book-id").text(`ID: ${book.id}`);
|
||||||
|
|
||||||
|
renderBookCover(book);
|
||||||
|
|
||||||
|
if (book.page_count && book.page_count > 0) {
|
||||||
|
$("#book-page-count-value").text(book.page_count);
|
||||||
|
$("#book-page-count-text").removeClass("hidden");
|
||||||
|
} else {
|
||||||
|
$("#book-page-count-text").addClass("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
$("#book-authors-text").text(
|
$("#book-authors-text").text(
|
||||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
|
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
|
||||||
);
|
);
|
||||||
@@ -253,9 +550,9 @@ $(document).ready(() => {
|
|||||||
$genres.empty();
|
$genres.empty();
|
||||||
book.genres.forEach((g) => {
|
book.genres.forEach((g) => {
|
||||||
$genres.append(`
|
$genres.append(`
|
||||||
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
|
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
|
||||||
${Utils.escapeHtml(g.name)}
|
${Utils.escapeHtml(g.name)}
|
||||||
</a>
|
</a>
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -266,12 +563,12 @@ $(document).ready(() => {
|
|||||||
$authors.empty();
|
$authors.empty();
|
||||||
book.authors.forEach((a) => {
|
book.authors.forEach((a) => {
|
||||||
$authors.append(`
|
$authors.append(`
|
||||||
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
|
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
|
||||||
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
|
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
|
||||||
${a.name.charAt(0).toUpperCase()}
|
${a.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
|
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
|
||||||
</a>
|
</a>
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -435,7 +732,7 @@ $(document).ready(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await Api.get("/api/auth/users?skip=0&limit=500");
|
const data = await Api.get("/api/users?skip=0&limit=500");
|
||||||
cachedUsers = data.users;
|
cachedUsers = data.users;
|
||||||
renderUsersList(cachedUsers);
|
renderUsersList(cachedUsers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
$(document).ready(() => {
|
$(() => {
|
||||||
|
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 = {
|
const STATUS_CONFIG = {
|
||||||
active: {
|
active: {
|
||||||
label: "Доступна",
|
label: "Доступна",
|
||||||
@@ -27,6 +48,40 @@ $(document).ready(() => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
function getStatusConfig(status) {
|
||||||
return (
|
return (
|
||||||
STATUS_CONFIG[status] || {
|
STATUS_CONFIG[status] || {
|
||||||
@@ -37,219 +92,191 @@ $(document).ready(() => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function initAuthors(authors) {
|
||||||
const $dropdown = $("#author-dropdown");
|
const $dropdown = $(SELECTORS.authorDropdown);
|
||||||
authors.forEach((author) => {
|
const fragment = document.createDocumentFragment();
|
||||||
$("<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))) {
|
authors.forEach((author) => {
|
||||||
selectedAuthors.set(author.id, author.name);
|
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) {
|
function initGenres(genres) {
|
||||||
const $list = $("#genres-list");
|
const $list = $(SELECTORS.genresList);
|
||||||
genres.forEach((genre) => {
|
const canManage = USER_CAN_MANAGE;
|
||||||
const isChecked = genreIdsFromUrl.includes(String(genre.id));
|
let html = "";
|
||||||
if (isChecked) selectedGenres.set(genre.id, genre.name);
|
|
||||||
|
|
||||||
const editButton = window.canManage()
|
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="Редактировать жанр">
|
? `<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">
|
<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>
|
<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>
|
</svg>
|
||||||
</a>`
|
</a>`
|
||||||
: "";
|
: "";
|
||||||
|
html += `
|
||||||
$list.append(`
|
|
||||||
<li class="mb-1">
|
<li class="mb-1">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<label class="custom-checkbox flex items-center flex-1">
|
<label class="custom-checkbox flex items-center flex-1">
|
||||||
<input type="checkbox" data-id="${genre.id}" data-name="${Utils.escapeHtml(genre.name)}" ${isChecked ? "checked" : ""} />
|
<input type="checkbox" data-id="${genre.id}" data-name="${safeName}" ${
|
||||||
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)}
|
isChecked ? "checked" : ""
|
||||||
|
} />
|
||||||
|
<span class="checkmark"></span> ${safeName}
|
||||||
</label>
|
</label>
|
||||||
${editButton}
|
${editButton}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
`);
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
$list.on("change", "input", function () {
|
$list.html(html);
|
||||||
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 () {
|
$list.on("change", "input", function () {
|
||||||
const id = parseInt($(this).data("id"));
|
const id = parseInt($(this).data("id"), 10);
|
||||||
const name = $(this).data("name");
|
const name = $(this).data("name");
|
||||||
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
|
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() {
|
function loadBooks() {
|
||||||
const searchQuery = $("#book-search-input").val().trim();
|
const searchQuery = $(SELECTORS.bookSearchInput).val().trim();
|
||||||
const params = new URLSearchParams();
|
const $minPages = $(SELECTORS.pagesMin);
|
||||||
|
const $maxPages = $(SELECTORS.pagesMax);
|
||||||
params.append("q", searchQuery);
|
const minPages = $minPages.length ? $minPages.val() : "";
|
||||||
selectedAuthors.forEach((_, id) => params.append("author_ids", id));
|
const maxPages = $maxPages.length ? $maxPages.val() : "";
|
||||||
selectedGenres.forEach((_, id) => params.append("genre_ids", id));
|
|
||||||
|
|
||||||
|
const apiParams = new URLSearchParams();
|
||||||
const browserParams = new URLSearchParams();
|
const browserParams = new URLSearchParams();
|
||||||
browserParams.append("q", searchQuery);
|
|
||||||
selectedAuthors.forEach((_, id) => browserParams.append("author_id", id));
|
if (searchQuery) {
|
||||||
selectedGenres.forEach((_, id) => browserParams.append("genre_id", id));
|
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 =
|
const newUrl =
|
||||||
window.location.pathname +
|
window.location.pathname +
|
||||||
(browserParams.toString() ? `?${browserParams.toString()}` : "");
|
(browserParams.toString() ? `?${browserParams.toString()}` : "");
|
||||||
window.history.replaceState({}, "", newUrl);
|
window.history.replaceState({}, "", newUrl);
|
||||||
|
|
||||||
params.append("page", currentPage);
|
|
||||||
params.append("size", pageSize);
|
|
||||||
|
|
||||||
showLoadingState();
|
showLoadingState();
|
||||||
|
|
||||||
Api.get(`/api/books/filter?${params.toString()}`)
|
Api.get(`/api/books/filter?${apiParams.toString()}`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
totalBooks = data.total;
|
STATE.totalBooks = data.total || 0;
|
||||||
renderBooks(data.books);
|
renderBooks(data.books || []);
|
||||||
renderPagination();
|
renderPagination();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
Utils.showToast("Не удалось загрузить книги", "error");
|
Utils.showToast("Не удалось загрузить книги", "error");
|
||||||
$("#books-container").html(
|
$(SELECTORS.booksContainer).html(
|
||||||
document.getElementById("empty-state-template").innerHTML,
|
TEMPLATES.emptyState.content.cloneNode(true),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBooks(books) {
|
function renderBooks(books) {
|
||||||
const $container = $("#books-container");
|
const $container = $(SELECTORS.booksContainer);
|
||||||
const tpl = document.getElementById("book-card-template");
|
|
||||||
const emptyTpl = document.getElementById("empty-state-template");
|
|
||||||
const badgeTpl = document.getElementById("genre-badge-template");
|
|
||||||
|
|
||||||
$container.empty();
|
$container.empty();
|
||||||
|
|
||||||
if (books.length === 0) {
|
if (!books.length) {
|
||||||
$container.append(emptyTpl.content.cloneNode(true));
|
$container.append(TEMPLATES.emptyState.content.cloneNode(true));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
books.forEach((book) => {
|
const fragment = document.createDocumentFragment();
|
||||||
const clone = tpl.content.cloneNode(true);
|
|
||||||
const card = clone.querySelector(".book-card");
|
|
||||||
|
|
||||||
|
books.forEach((book) => {
|
||||||
|
const clone = TEMPLATES.bookCard.content.cloneNode(true);
|
||||||
|
const card = clone.querySelector(".book-card");
|
||||||
card.dataset.id = book.id;
|
card.dataset.id = book.id;
|
||||||
clone.querySelector(".book-title").textContent = book.title;
|
|
||||||
clone.querySelector(".book-authors").textContent =
|
const titleEl = clone.querySelector(".book-title");
|
||||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
const authorsEl = clone.querySelector(".book-authors");
|
||||||
clone.querySelector(".book-desc").textContent = book.description || "";
|
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);
|
const statusConfig = getStatusConfig(book.status);
|
||||||
const statusEl = clone.querySelector(".book-status");
|
|
||||||
statusEl.textContent = statusConfig.label;
|
statusEl.textContent = statusConfig.label;
|
||||||
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
|
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
|
||||||
|
|
||||||
const genresContainer = clone.querySelector(".book-genres");
|
if (Array.isArray(book.genres)) {
|
||||||
book.genres.forEach((g) => {
|
book.genres.forEach((g) => {
|
||||||
const badge = badgeTpl.content.cloneNode(true);
|
const badge = TEMPLATES.genreBadge.content.cloneNode(true);
|
||||||
const span = badge.querySelector("span");
|
const span = badge.querySelector("span");
|
||||||
span.textContent = g.name;
|
span.textContent = g.name;
|
||||||
genresContainer.appendChild(badge);
|
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>
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fragment.appendChild(clone);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#pagination-container").append($pagination);
|
$container.append(fragment);
|
||||||
|
|
||||||
$("#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) {
|
function generatePageNumbers(current, total) {
|
||||||
@@ -269,49 +296,81 @@ $(document).ready(() => {
|
|||||||
return pages;
|
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() {
|
function scrollToTop() {
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoadingState() {
|
function showLoadingState() {
|
||||||
$("#books-container").html(`
|
$(SELECTORS.booksContainer).html(LOADING_SKELETON_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() {
|
function renderSelectedAuthors() {
|
||||||
const $container = $("#selected-authors-container");
|
const $container = $(SELECTORS.selectedAuthorsContainer);
|
||||||
const $dropdown = $("#author-dropdown");
|
const $dropdown = $(SELECTORS.authorDropdown);
|
||||||
|
|
||||||
$container.empty();
|
$container.empty();
|
||||||
|
|
||||||
selectedAuthors.forEach((name, id) => {
|
const fragment = document.createDocumentFragment();
|
||||||
$(`<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)}
|
STATE.selectedAuthors.forEach((name, id) => {
|
||||||
<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}">
|
const wrapper = document.createElement("span");
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
wrapper.className =
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
"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";
|
||||||
</svg>
|
wrapper.innerHTML = `
|
||||||
</button>
|
${Utils.escapeHtml(name)}
|
||||||
</span>`).appendTo($container);
|
<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 () {
|
$dropdown.find(".author-item").each(function () {
|
||||||
const id = parseInt($(this).data("id"));
|
const id = parseInt($(this).data("id"), 10);
|
||||||
if (selectedAuthors.has(id)) {
|
if (STATE.selectedAuthors.has(id)) {
|
||||||
$(this)
|
$(this)
|
||||||
.addClass("bg-gray-200 text-gray-900 font-semibold")
|
.addClass("bg-gray-200 text-gray-900 font-semibold")
|
||||||
.removeClass("hover:bg-gray-100");
|
.removeClass("hover:bg-gray-100");
|
||||||
@@ -324,11 +383,11 @@ $(document).ready(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initializeAuthorDropdownListeners() {
|
function initializeAuthorDropdownListeners() {
|
||||||
const $input = $("#author-search-input");
|
const $input = $(SELECTORS.authorSearchInput);
|
||||||
const $dropdown = $("#author-dropdown");
|
const $dropdown = $(SELECTORS.authorDropdown);
|
||||||
const $container = $("#selected-authors-container");
|
const $container = $(SELECTORS.selectedAuthorsContainer);
|
||||||
|
|
||||||
$input.on("focus", function () {
|
$input.on("focus", () => {
|
||||||
$dropdown.removeClass("hidden");
|
$dropdown.removeClass("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,7 +403,7 @@ $(document).ready(() => {
|
|||||||
$(document).on("click", function (e) {
|
$(document).on("click", function (e) {
|
||||||
if (
|
if (
|
||||||
!$(e.target).closest(
|
!$(e.target).closest(
|
||||||
"#author-search-input, #author-dropdown, #selected-authors-container",
|
`${SELECTORS.authorSearchInput}, ${SELECTORS.authorDropdown}, ${SELECTORS.selectedAuthorsContainer}`,
|
||||||
).length
|
).length
|
||||||
) {
|
) {
|
||||||
$dropdown.addClass("hidden");
|
$dropdown.addClass("hidden");
|
||||||
@@ -353,61 +412,108 @@ $(document).ready(() => {
|
|||||||
|
|
||||||
$dropdown.on("click", ".author-item", function (e) {
|
$dropdown.on("click", ".author-item", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const id = parseInt($(this).data("id"));
|
const id = parseInt($(this).data("id"), 10);
|
||||||
const name = $(this).data("name");
|
const name = $(this).data("name");
|
||||||
|
|
||||||
if (selectedAuthors.has(id)) {
|
if (STATE.selectedAuthors.has(id)) {
|
||||||
selectedAuthors.delete(id);
|
STATE.selectedAuthors.delete(id);
|
||||||
} else {
|
} else {
|
||||||
selectedAuthors.set(id, name);
|
STATE.selectedAuthors.set(id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
$input.val("");
|
$input.val("");
|
||||||
$dropdown.find(".author-item").show();
|
$dropdown.find(".author-item").show();
|
||||||
renderChips();
|
renderSelectedAuthors();
|
||||||
$input[0].focus();
|
$input[0].focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
$container.on("click", ".remove-author", function (e) {
|
$container.on("click", ".remove-author", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const id = parseInt($(this).data("id"));
|
const id = parseInt($(this).data("id"), 10);
|
||||||
selectedAuthors.delete(id);
|
STATE.selectedAuthors.delete(id);
|
||||||
renderChips();
|
renderSelectedAuthors();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#books-container").on("click", ".book-card", function () {
|
$(SELECTORS.booksContainer).on("click", ".book-card", function () {
|
||||||
window.location.href = `/book/${$(this).data("id")}`;
|
const id = $(this).data("id");
|
||||||
|
if (id) {
|
||||||
|
window.location.href = `/book/${id}`;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#apply-filters-btn").on("click", function () {
|
$(SELECTORS.applyFiltersBtn).on("click", function () {
|
||||||
currentPage = 1;
|
STATE.currentPage = 1;
|
||||||
loadBooks();
|
loadBooks();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#reset-filters-btn").on("click", function () {
|
$(SELECTORS.resetFiltersBtn).on("click", function () {
|
||||||
$("#book-search-input").val("");
|
$(SELECTORS.bookSearchInput).val("");
|
||||||
selectedAuthors.clear();
|
STATE.selectedAuthors.clear();
|
||||||
selectedGenres.clear();
|
STATE.selectedGenres.clear();
|
||||||
$("#genres-list input").prop("checked", false);
|
$(`${SELECTORS.genresList} input`).prop("checked", false);
|
||||||
renderChips();
|
|
||||||
currentPage = 1;
|
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();
|
loadBooks();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#book-search-input").on("keypress", function (e) {
|
$(SELECTORS.bookSearchInput).on("keypress", function (e) {
|
||||||
if (e.which === 13) {
|
if (e.which === 13) {
|
||||||
currentPage = 1;
|
STATE.currentPage = 1;
|
||||||
loadBooks();
|
loadBooks();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function showAdminControls() {
|
$(SELECTORS.paginationContainer).on("click", "#prev-page", function () {
|
||||||
if (window.canManage()) {
|
if (STATE.currentPage > 1) {
|
||||||
$("#admin-actions").removeClass("hidden");
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
showAdminControls();
|
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
|
||||||
setTimeout(showAdminControls, 100);
|
.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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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-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>
|
||||||
@@ -10,8 +9,8 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{% 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 type="button" id="login-tab"
|
<button type="button" 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">
|
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500">
|
||||||
Вход
|
Вход
|
||||||
@@ -84,7 +83,7 @@
|
|||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<input type="text" id="login-totp" name="totp_code"
|
<input type="text" id="login-totp" name="totp_code"
|
||||||
class="w-full px-4 py-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"
|
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" />
|
placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,7 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" id="login-submit"
|
<button type="submit" id="login-submit"
|
||||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
|
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>
|
||||||
@@ -184,6 +183,27 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<cap-widget id="cap"
|
||||||
|
data-cap-api-endpoint="/api/cap/"
|
||||||
|
style="
|
||||||
|
--cap-widget-width: 100%;
|
||||||
|
--cap-background: #fdfdfd;
|
||||||
|
--cap-border-color: #d1d5db;
|
||||||
|
--cap-border-radius: 8px;
|
||||||
|
--cap-widget-height: auto;
|
||||||
|
--cap-color: #212121;
|
||||||
|
--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"
|
<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">
|
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">
|
||||||
Зарегистрироваться
|
Зарегистрироваться
|
||||||
@@ -319,6 +339,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
|
||||||
<script src="/static/page/auth.js"></script>
|
<script src="/static/page/auth.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-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">
|
||||||
|
|||||||
@@ -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
|
<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"
|
||||||
@@ -7,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"
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<title>{% block title %}LiB{% endblock %}</title>
|
<title>{{ title }}</title>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta property="og:title" content="{{ title }}" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:description" content="Ваша персональная библиотека книг" />
|
||||||
|
<meta property="og:url" content="{{ request.url.scheme }}://{{ domain }}/" />
|
||||||
<script
|
<script
|
||||||
defer
|
defer
|
||||||
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
|
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
|
||||||
@@ -17,42 +21,55 @@
|
|||||||
<body
|
<body
|
||||||
class="flex flex-col min-h-screen bg-gray-100"
|
class="flex flex-col min-h-screen bg-gray-100"
|
||||||
x-data="{
|
x-data="{
|
||||||
user: null,
|
user: null,
|
||||||
async init() {
|
menuOpen: false,
|
||||||
document.addEventListener('auth:login', async (e) => {
|
async init() {
|
||||||
this.user = e.detail;
|
document.addEventListener('auth:login', async (e) => {
|
||||||
this.user.avatar = await Utils.getGravatarUrl(this.user.email);
|
this.user = e.detail;
|
||||||
});
|
this.user.avatar = await Utils.getGravatarUrl(this.user.email);
|
||||||
await Auth.init();
|
});
|
||||||
}
|
await Auth.init();
|
||||||
}"
|
}
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<header class="bg-gray-600 text-white p-4 shadow-md">
|
<header class="bg-gray-600 text-white p-4 shadow-md">
|
||||||
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
|
<div class="mx-auto px-3 md:pl-5 md:pr-3 flex justify-between items-center">
|
||||||
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
|
<div class="flex items-center">
|
||||||
<img class="invert" src="/static/logo.svg" />
|
<button
|
||||||
<h1 class="text-2xl font-bold">LiB</h1>
|
@click="menuOpen = !menuOpen"
|
||||||
</a>
|
class="md:hidden flex gap-2 items-center hover:opacity-80 transition focus:outline-none"
|
||||||
<nav>
|
:aria-expanded="menuOpen"
|
||||||
|
aria-label="Меню навигации"
|
||||||
|
>
|
||||||
|
<img class="invert max-w-10 h-auto" src="/static/logo.svg" />
|
||||||
|
<h1 class="text-xl font-bold">
|
||||||
|
<span class="text-gray-300 mr-1">≡</span>LiB
|
||||||
|
</h1>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a class="hidden md:flex gap-4 items-center max-w-10 h-auto" href="/">
|
||||||
|
<img class="invert" src="/static/logo.svg" />
|
||||||
|
<h1 class="text-2xl font-bold">LiB</h1>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="hidden md:block">
|
||||||
<ul class="flex space-x-4">
|
<ul class="flex space-x-4">
|
||||||
<li>
|
<li>
|
||||||
<a href="/" class="hover:text-gray-200">Главная</a>
|
<a href="/" class="hover:text-gray-200">Главная</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/books" class="hover:text-gray-200"
|
<a href="/books" class="hover:text-gray-200">Книги</a>
|
||||||
>Книги</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/authors" class="hover:text-gray-200"
|
<a href="/authors" class="hover:text-gray-200">Авторы</a>
|
||||||
>Авторы</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/api" class="hover:text-gray-200">API</a>
|
<a href="/api" class="hover:text-gray-200">API</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="relative" x-data="{ open: false }">
|
<div class="relative" x-data="{ open: false }">
|
||||||
<template x-if="!user">
|
<template x-if="!user">
|
||||||
<a
|
<a
|
||||||
@@ -104,7 +121,7 @@
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-transition
|
x-transition
|
||||||
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
|
class="absolute right-0 mt-2 w-56 max-w-[calc(100vw-2rem)] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
|
||||||
style="display: none"
|
style="display: none"
|
||||||
>
|
>
|
||||||
<div class="px-4 py-3 border-b border-gray-200">
|
<div class="px-4 py-3 border-b border-gray-200">
|
||||||
@@ -229,17 +246,71 @@
|
|||||||
</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>© 2026 LiB Library. Разработано в рамках дипломного проекта.
|
<p>
|
||||||
Код открыт под лицензией <a href="https://github.com/wowlikon/LibraryAPI/blob/main/LICENSE">MIT</a>.
|
© 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -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-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">
|
||||||
@@ -67,29 +66,32 @@
|
|||||||
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
|
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4"
|
id="book-cover-container"
|
||||||
|
class="relative w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100 group"
|
||||||
>
|
>
|
||||||
<svg
|
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||||
class="w-20 h-20 text-white opacity-80"
|
<svg
|
||||||
fill="none"
|
class="w-20 h-20 text-white opacity-80"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="1.5"
|
stroke-linejoin="round"
|
||||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
stroke-width="1.5"
|
||||||
></path>
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
</svg>
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="file" id="cover-file-input" class="hidden" accept="image/*" />
|
||||||
<div
|
<div
|
||||||
id="book-status-container"
|
id="book-status-container"
|
||||||
class="relative w-full flex justify-center z-10 mb-4"
|
class="relative w-full flex justify-center z-10 mb-4"
|
||||||
></div>
|
></div>
|
||||||
<div id="book-actions-container" class="w-full"></div>
|
<div id="book-actions-container" class="w-full"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 w-full">
|
<div class="flex-1 w-full">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
|
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
|
||||||
@@ -107,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"
|
||||||
@@ -297,3 +303,8 @@
|
|||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/page/book.js"></script>
|
<script src="/static/page/book.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block extra_head %}
|
||||||
|
{% if img %}
|
||||||
|
<meta property="og:image" content="{{ request.url.scheme }}://{{ domain }}/static/books/{{ img }}.jpg" />
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,37 @@
|
|||||||
{% extends "base.html" %} {% block title %}LiB - Книги{% endblock %}
|
{% extends "base.html" %}{% block content %}
|
||||||
{% 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
|
||||||
@@ -88,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
|
||||||
@@ -152,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>
|
||||||
@@ -188,4 +267,34 @@
|
|||||||
</template>
|
</template>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/page/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">
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
>
|
>
|
||||||
</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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{% 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">
|
||||||
|
|||||||
@@ -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-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>
|
||||||
|
|||||||
@@ -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"
|
||||||
x-data="{ showPasswordModal: false, showDisable2FAModal: false, showRecoveryCodesModal: false, is2FAEnabled: false, recoveryCodesRemaining: null }"
|
x-data="{ showPasswordModal: false, showDisable2FAModal: false, showRecoveryCodesModal: false, is2FAEnabled: false, recoveryCodesRemaining: null }"
|
||||||
@update-2fa.window="is2FAEnabled = $event.detail"
|
@update-2fa.window="is2FAEnabled = $event.detail"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""recovery codes and totp
|
"""Recovery codes and totp
|
||||||
|
|
||||||
Revision ID: a585fd97b88c
|
Revision ID: a585fd97b88c
|
||||||
Revises: a8e40ab24138
|
Revises: a8e40ab24138
|
||||||
@@ -27,7 +27,7 @@ def upgrade() -> None:
|
|||||||
op.add_column(
|
op.add_column(
|
||||||
"users",
|
"users",
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"totp_secret", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True
|
"totp_secret", sqlmodel.sql.sqltypes.AutoString(length=80), nullable=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
op.add_column(
|
op.add_column(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""role payroll
|
"""Role payroll
|
||||||
|
|
||||||
Revision ID: a8e40ab24138
|
Revision ID: a8e40ab24138
|
||||||
Revises: 02ed6e775351
|
Revises: 02ed6e775351
|
||||||
Create Date: 2025-12-20 13:44:13.807704
|
Create Date: 2025-12-20 13:44:13.807704
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -13,29 +14,49 @@ import sqlmodel
|
|||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = 'a8e40ab24138'
|
revision: str = "a8e40ab24138"
|
||||||
down_revision: Union[str, None] = '02ed6e775351'
|
down_revision: Union[str, None] = "02ed6e775351"
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.alter_column('book', 'status',
|
op.alter_column(
|
||||||
existing_type=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
|
"book",
|
||||||
type_=sa.String(),
|
"status",
|
||||||
existing_nullable=False,
|
existing_type=postgresql.ENUM(
|
||||||
existing_server_default=sa.text("'active'::bookstatus"))
|
"active",
|
||||||
op.add_column('roles', sa.Column('payroll', sa.Integer(), nullable=False))
|
"borrowed",
|
||||||
|
"reserved",
|
||||||
|
"restoration",
|
||||||
|
"written_off",
|
||||||
|
name="bookstatus",
|
||||||
|
),
|
||||||
|
type_=sa.String(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("'active'::bookstatus"),
|
||||||
|
)
|
||||||
|
op.add_column("roles", sa.Column("payroll", sa.Integer(), nullable=False))
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_column('roles', 'payroll')
|
op.drop_column("roles", "payroll")
|
||||||
op.alter_column('book', 'status',
|
op.alter_column(
|
||||||
existing_type=sa.String(),
|
"book",
|
||||||
type_=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
|
"status",
|
||||||
existing_nullable=False,
|
existing_type=sa.String(),
|
||||||
existing_server_default=sa.text("'active'::bookstatus"))
|
type_=postgresql.ENUM(
|
||||||
|
"active",
|
||||||
|
"borrowed",
|
||||||
|
"reserved",
|
||||||
|
"restoration",
|
||||||
|
"written_off",
|
||||||
|
name="bookstatus",
|
||||||
|
),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("'active'::bookstatus"),
|
||||||
|
)
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Book preview
|
||||||
|
|
||||||
|
Revision ID: abbc38275032
|
||||||
|
Revises: 6c616cc9d1f0
|
||||||
|
Create Date: 2026-02-01 14:41:14.611420
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel, pgvector
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'abbc38275032'
|
||||||
|
down_revision: Union[str, None] = '6c616cc9d1f0'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('book', sa.Column('preview_id', sa.Uuid(), nullable=True))
|
||||||
|
op.create_index(op.f('ix_book_preview_id'), 'book', ['preview_id'], unique=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_book_preview_id'), table_name='book')
|
||||||
|
op.drop_column('book', 'preview_id')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Book page_count
|
||||||
|
|
||||||
|
Revision ID: c5dfc16bdc66
|
||||||
|
Revises: a585fd97b88c
|
||||||
|
Create Date: 2026-01-23 00:09:14.192263
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c5dfc16bdc66"
|
||||||
|
down_revision: Union[str, None] = "a585fd97b88c"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column("book", sa.Column("page_count", sa.Integer(), nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column("book", "page_count")
|
||||||
|
# ### end Alembic commands ###
|
||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "LibraryAPI"
|
name = "LiB"
|
||||||
version = "0.5.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]
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Настройка репликации ==="
|
||||||
|
echo "Этот узел: NODE_ID=${NODE_ID}"
|
||||||
|
echo "Удаленный хост: ${REMOTE_HOST}"
|
||||||
|
|
||||||
|
echo "Ждем локальную базу..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
until PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c '\q' 2>/dev/null; do
|
||||||
|
echo "Локальная база не готова, ждем..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Локальная база готова"
|
||||||
|
|
||||||
|
echo "Настройка генераторов ID (NODE_ID=${NODE_ID})..."
|
||||||
|
|
||||||
|
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" <<EOF
|
||||||
|
DO \$\$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT table_schema, table_name, column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE is_identity = 'YES'
|
||||||
|
AND table_schema = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER TABLE %I.%I ALTER COLUMN %I SET GENERATED BY DEFAULT AS IDENTITY (START WITH %s INCREMENT BY 2)',
|
||||||
|
r.table_schema, r.table_name, r.column_name, ${NODE_ID}
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Настроен ID для %.%', r.table_name, r.column_name;
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Проверяем/создаем публикацию..."
|
||||||
|
|
||||||
|
PUB_EXISTS=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_publication WHERE pubname = 'all_tables_pub';")
|
||||||
|
|
||||||
|
if [ "$PUB_EXISTS" -gt 0 ]; then
|
||||||
|
echo "Публикация уже существует"
|
||||||
|
else
|
||||||
|
echo "Создаем публикацию..."
|
||||||
|
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" <<EOF
|
||||||
|
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
|
||||||
|
EOF
|
||||||
|
echo "Публикация создана!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Ждем удаленный хост ${REMOTE_HOST}:${REMOTE_PORT}..."
|
||||||
|
TIMEOUT=300
|
||||||
|
ELAPSED=0
|
||||||
|
|
||||||
|
while ! PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${REMOTE_HOST}" -p ${REMOTE_PORT} -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c '\q' 2>/dev/null; do
|
||||||
|
sleep 5
|
||||||
|
ELAPSED=$((ELAPSED + 5))
|
||||||
|
if [ $ELAPSED -ge $TIMEOUT ]; then
|
||||||
|
echo "Таймаут ожидания удаленного хоста. Подписка НЕ настроена."
|
||||||
|
echo "Публикация создана - удаленный хост сможет подписаться на нас."
|
||||||
|
echo "Для создания подписки запустите позже:"
|
||||||
|
echo "docker compose restart replication-setup"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Удаленный хост недоступен, ждем... (${ELAPSED}s/${TIMEOUT}s)"
|
||||||
|
done
|
||||||
|
echo "Удаленный хост доступен"
|
||||||
|
|
||||||
|
REMOTE_PUB=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${REMOTE_HOST}" -p ${REMOTE_PORT} -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_publication WHERE pubname = 'all_tables_pub';" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [ "$REMOTE_PUB" -eq 0 ]; then
|
||||||
|
echo "ВНИМАНИЕ: На удалённом хосте нет публикации 'all_tables_pub'!"
|
||||||
|
echo "Подписка не будет создана. Сначала запустите скрипт на удалённом хосте."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXISTING=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_subscription WHERE subname = 'sub_from_remote';")
|
||||||
|
|
||||||
|
if [ "$EXISTING" -gt 0 ]; then
|
||||||
|
echo "Подписка уже существует, пропускаем создание"
|
||||||
|
else
|
||||||
|
echo "Создаем подписку на удаленный хост..."
|
||||||
|
|
||||||
|
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" <<EOF
|
||||||
|
CREATE SUBSCRIPTION sub_from_remote
|
||||||
|
CONNECTION 'host=${REMOTE_HOST} port=${REMOTE_PORT} user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB}'
|
||||||
|
PUBLICATION all_tables_pub
|
||||||
|
WITH (
|
||||||
|
origin = none
|
||||||
|
);
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Подписка создана!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Репликация настроена! ==="
|
||||||
|
echo "Публикация: all_tables_pub (другие могут подписаться на нас)"
|
||||||
|
echo "Подписка: sub_from_remote (мы получаем данные от ${REMOTE_HOST})"
|
||||||
-169
@@ -1,169 +0,0 @@
|
|||||||
# Тесты без базы данных
|
|
||||||
|
|
||||||
## Обзор изменений
|
|
||||||
|
|
||||||
Все тесты были переработаны для работы без реальной базы данных PostgreSQL. Вместо этого используется in-memory мок-хранилище.
|
|
||||||
|
|
||||||
## Новые компоненты
|
|
||||||
|
|
||||||
### 1. Мок-хранилище ()
|
|
||||||
- Реализует все операции с данными в памяти
|
|
||||||
- Поддерживает CRUD операции для книг, авторов и жанров
|
|
||||||
- Управляет связями между сущностями
|
|
||||||
- Автоматически генерирует ID
|
|
||||||
- Предоставляет метод для очистки данных между тестами
|
|
||||||
|
|
||||||
### 2. Мок-сессия ()
|
|
||||||
- Эмулирует поведение SQLModel Session
|
|
||||||
- Предоставляет совместимый интерфейс для dependency injection
|
|
||||||
|
|
||||||
### 3. Мок-роутеры ()
|
|
||||||
- - упрощенные роутеры для операций с книгами
|
|
||||||
- - упрощенные роутеры для операций с авторами
|
|
||||||
- - упрощенные роутеры для связей между сущностями
|
|
||||||
|
|
||||||
### 4. Мок-приложение ()
|
|
||||||
- FastAPI приложение для тестирования
|
|
||||||
- Использует мок-роутеры вместо реальных
|
|
||||||
- Включает реальный misc роутер (не требует БД)
|
|
||||||
|
|
||||||
## Обновленные тесты
|
|
||||||
|
|
||||||
Все тесты были обновлены:
|
|
||||||
|
|
||||||
###
|
|
||||||
- Переработана фикстура для работы с мок-хранилищем
|
|
||||||
- Добавлен автоматический cleanup между тестами
|
|
||||||
|
|
||||||
###
|
|
||||||
- Использует мок-приложение вместо реального
|
|
||||||
- Все тесты создают необходимые данные явно
|
|
||||||
- Автоматическая очистка данных между тестами
|
|
||||||
|
|
||||||
###
|
|
||||||
- Аналогично
|
|
||||||
- Полная поддержка всех CRUD операций
|
|
||||||
|
|
||||||
###
|
|
||||||
- Поддерживает создание и получение связей автор-книга
|
|
||||||
- Тестирует получение авторов по книге и книг по автору
|
|
||||||
|
|
||||||
## Запуск тестов
|
|
||||||
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 23 items
|
|
||||||
|
|
||||||
tests/test_authors.py::test_empty_list_authors PASSED [ 4%]
|
|
||||||
tests/test_authors.py::test_create_author PASSED [ 8%]
|
|
||||||
tests/test_authors.py::test_list_authors PASSED [ 13%]
|
|
||||||
tests/test_authors.py::test_get_existing_author PASSED [ 17%]
|
|
||||||
tests/test_authors.py::test_get_not_existing_author PASSED [ 21%]
|
|
||||||
tests/test_authors.py::test_update_author PASSED [ 26%]
|
|
||||||
tests/test_authors.py::test_update_not_existing_author PASSED [ 30%]
|
|
||||||
tests/test_authors.py::test_delete_author PASSED [ 34%]
|
|
||||||
tests/test_authors.py::test_not_existing_delete_author PASSED [ 39%]
|
|
||||||
tests/test_books.py::test_empty_list_books PASSED [ 43%]
|
|
||||||
tests/test_books.py::test_create_book PASSED [ 47%]
|
|
||||||
tests/test_books.py::test_list_books PASSED [ 52%]
|
|
||||||
tests/test_books.py::test_get_existing_book PASSED [ 56%]
|
|
||||||
tests/test_books.py::test_get_not_existing_book PASSED [ 60%]
|
|
||||||
tests/test_books.py::test_update_book PASSED [ 65%]
|
|
||||||
tests/test_books.py::test_update_not_existing_book PASSED [ 69%]
|
|
||||||
tests/test_books.py::test_delete_book PASSED [ 73%]
|
|
||||||
tests/test_books.py::test_not_existing_delete_book PASSED [ 78%]
|
|
||||||
tests/test_misc.py::test_main_page PASSED [ 82%]
|
|
||||||
tests/test_misc.py::test_app_info_test PASSED [ 86%]
|
|
||||||
tests/test_relationships.py::test_prepare_data PASSED [ 91%]
|
|
||||||
tests/test_relationships.py::test_get_book_authors PASSED [ 95%]
|
|
||||||
tests/test_relationships.py::test_get_author_books PASSED [100%]
|
|
||||||
|
|
||||||
============================== 23 passed in 1.42s ==============================
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 9 items
|
|
||||||
|
|
||||||
tests/test_books.py::test_empty_list_books PASSED [ 11%]
|
|
||||||
tests/test_books.py::test_create_book PASSED [ 22%]
|
|
||||||
tests/test_books.py::test_list_books PASSED [ 33%]
|
|
||||||
tests/test_books.py::test_get_existing_book PASSED [ 44%]
|
|
||||||
tests/test_books.py::test_get_not_existing_book PASSED [ 55%]
|
|
||||||
tests/test_books.py::test_update_book PASSED [ 66%]
|
|
||||||
tests/test_books.py::test_update_not_existing_book PASSED [ 77%]
|
|
||||||
tests/test_books.py::test_delete_book PASSED [ 88%]
|
|
||||||
tests/test_books.py::test_not_existing_delete_book PASSED [100%]
|
|
||||||
|
|
||||||
============================== 9 passed in 0.99s ===============================
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 9 items
|
|
||||||
|
|
||||||
tests/test_authors.py::test_empty_list_authors PASSED [ 11%]
|
|
||||||
tests/test_authors.py::test_create_author PASSED [ 22%]
|
|
||||||
tests/test_authors.py::test_list_authors PASSED [ 33%]
|
|
||||||
tests/test_authors.py::test_get_existing_author PASSED [ 44%]
|
|
||||||
tests/test_authors.py::test_get_not_existing_author PASSED [ 55%]
|
|
||||||
tests/test_authors.py::test_update_author PASSED [ 66%]
|
|
||||||
tests/test_authors.py::test_update_not_existing_author PASSED [ 77%]
|
|
||||||
tests/test_authors.py::test_delete_author PASSED [ 88%]
|
|
||||||
tests/test_authors.py::test_not_existing_delete_author PASSED [100%]
|
|
||||||
|
|
||||||
============================== 9 passed in 0.96s ===============================
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 3 items
|
|
||||||
|
|
||||||
tests/test_relationships.py::test_prepare_data PASSED [ 33%]
|
|
||||||
tests/test_relationships.py::test_get_book_authors PASSED [ 66%]
|
|
||||||
tests/test_relationships.py::test_get_author_books PASSED [100%]
|
|
||||||
|
|
||||||
============================== 3 passed in 1.09s ===============================
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 2 items
|
|
||||||
|
|
||||||
tests/test_misc.py::test_main_page PASSED [ 50%]
|
|
||||||
tests/test_misc.py::test_app_info_test PASSED [100%]
|
|
||||||
|
|
||||||
============================== 2 passed in 0.93s ===============================
|
|
||||||
|
|
||||||
## Преимущества нового подхода
|
|
||||||
|
|
||||||
1. **Независимость**: Тесты не требуют PostgreSQL или Docker
|
|
||||||
2. **Скорость**: Выполняются значительно быстрее
|
|
||||||
3. **Изоляция**: Каждый тест работает с чистым состоянием
|
|
||||||
4. **Стабильность**: Нет проблем с сетевыми подключениями или состоянием БД
|
|
||||||
5. **CI/CD готовность**: Легко интегрируются в CI пайплайны
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Мок-хранилище упрощено по сравнению с реальной БД
|
|
||||||
- Отсутствуют некоторые возможности SQLModel (сложные запросы, транзакции)
|
|
||||||
- Нет проверки целостности данных на уровне БД
|
|
||||||
|
|
||||||
Однако для юнит-тестирования API логики этого достаточно.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from library_service.routers.misc import router as misc_router
|
|
||||||
from tests.mock_routers import authors, books, genres, relationships
|
|
||||||
|
|
||||||
|
|
||||||
def create_mock_app() -> FastAPI:
|
|
||||||
"""Создание FastAPI app с моками роутеров для тестов"""
|
|
||||||
app = FastAPI(
|
|
||||||
title="Library API Test",
|
|
||||||
description="Library API for testing without database",
|
|
||||||
version="1.0.0",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Подключение мок-роутеров
|
|
||||||
app.include_router(books.router)
|
|
||||||
app.include_router(authors.router)
|
|
||||||
app.include_router(genres.router)
|
|
||||||
app.include_router(relationships.router)
|
|
||||||
|
|
||||||
# Подключение реального misc роутера
|
|
||||||
app.include_router(misc_router)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
mock_app = create_mock_app()
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/authors", tags=["authors"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
def create_author(author: dict):
|
|
||||||
return mock_storage.create_author(author["name"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
def read_authors():
|
|
||||||
authors = mock_storage.get_all_authors()
|
|
||||||
return {"authors": authors, "total": len(authors)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{author_id}")
|
|
||||||
def get_author(author_id: int):
|
|
||||||
author = mock_storage.get_author(author_id)
|
|
||||||
if not author:
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
|
|
||||||
books = mock_storage.get_books_by_author(author_id)
|
|
||||||
author_with_books = author.copy()
|
|
||||||
author_with_books["books"] = books
|
|
||||||
return author_with_books
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{author_id}")
|
|
||||||
def update_author(author_id: int, author: dict):
|
|
||||||
updated_author = mock_storage.update_author(author_id, author.get("name"))
|
|
||||||
if not updated_author:
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
return updated_author
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{author_id}")
|
|
||||||
def delete_author(author_id: int):
|
|
||||||
author = mock_storage.delete_author(author_id)
|
|
||||||
if not author:
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
return author
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/books", tags=["books"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
def create_book(book: dict):
|
|
||||||
return mock_storage.create_book(book["title"], book["description"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
def read_books():
|
|
||||||
books = mock_storage.get_all_books()
|
|
||||||
return {"books": books, "total": len(books)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{book_id}")
|
|
||||||
def get_book(book_id: int):
|
|
||||||
book = mock_storage.get_book(book_id)
|
|
||||||
if not book:
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
|
|
||||||
authors = mock_storage.get_authors_by_book(book_id)
|
|
||||||
book_with_authors = book.copy()
|
|
||||||
book_with_authors["authors"] = authors
|
|
||||||
return book_with_authors
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{book_id}")
|
|
||||||
def update_book(book_id: int, book: dict):
|
|
||||||
updated_book = mock_storage.update_book(
|
|
||||||
book_id, book.get("title"), book.get("description")
|
|
||||||
)
|
|
||||||
if not updated_book:
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
return updated_book
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{book_id}")
|
|
||||||
def delete_book(book_id: int):
|
|
||||||
book = mock_storage.delete_book(book_id)
|
|
||||||
if not book:
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
return book
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
def create_genre(genre: dict):
|
|
||||||
return mock_storage.create_genre(genre["name"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
def read_genres():
|
|
||||||
genres = mock_storage.get_all_genres()
|
|
||||||
return {"genres": genres, "total": len(genres)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{genre_id}")
|
|
||||||
def get_genre(genre_id: int):
|
|
||||||
genre = mock_storage.get_genre(genre_id)
|
|
||||||
if not genre:
|
|
||||||
raise HTTPException(status_code=404, detail="genre not found")
|
|
||||||
|
|
||||||
books = mock_storage.get_books_by_genre(genre_id)
|
|
||||||
genre_with_books = genre.copy()
|
|
||||||
genre_with_books["books"] = books
|
|
||||||
return genre_with_books
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{genre_id}")
|
|
||||||
def update_genre(genre_id: int, genre: dict):
|
|
||||||
updated_genre = mock_storage.update_genre(genre_id, genre.get("name"))
|
|
||||||
if not updated_genre:
|
|
||||||
raise HTTPException(status_code=404, detail="genre not found")
|
|
||||||
return updated_genre
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{genre_id}")
|
|
||||||
def delete_genre(genre_id: int):
|
|
||||||
genre = mock_storage.delete_genre(genre_id)
|
|
||||||
if not genre:
|
|
||||||
raise HTTPException(status_code=404, detail="genre not found")
|
|
||||||
return genre
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
router = APIRouter(tags=["relations"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/relationships/author-book")
|
|
||||||
def add_author_to_book(author_id: int, book_id: int):
|
|
||||||
if not mock_storage.create_author_book_link(author_id, book_id):
|
|
||||||
if not mock_storage.get_author(author_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
if not mock_storage.get_book(book_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
raise HTTPException(status_code=400, detail="Relationship already exists")
|
|
||||||
|
|
||||||
return {"author_id": author_id, "book_id": book_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/authors/{author_id}/books")
|
|
||||||
def get_books_for_author(author_id: int):
|
|
||||||
author = mock_storage.get_author(author_id)
|
|
||||||
if not author:
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
|
|
||||||
return mock_storage.get_books_by_author(author_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/books/{book_id}/authors")
|
|
||||||
def get_authors_for_book(book_id: int):
|
|
||||||
book = mock_storage.get_book(book_id)
|
|
||||||
if not book:
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
|
|
||||||
return mock_storage.get_authors_by_book(book_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/relationships/genre-book")
|
|
||||||
def add_genre_to_book(genre_id: int, book_id: int):
|
|
||||||
return {"genre_id": genre_id, "book_id": book_id}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
from typing import Any, List
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
|
|
||||||
class MockSession:
|
|
||||||
"""Mock SQLModel Session that works with MockStorage"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.storage = mock_storage
|
|
||||||
|
|
||||||
def add(self, obj: Any): ...
|
|
||||||
|
|
||||||
def commit(self): ...
|
|
||||||
|
|
||||||
def refresh(self, obj: Any): ...
|
|
||||||
|
|
||||||
def get(self, model_class, pk: int):
|
|
||||||
if hasattr(model_class, "__name__"):
|
|
||||||
model_name = model_class.__name__.lower()
|
|
||||||
else:
|
|
||||||
model_name = str(model_class).lower()
|
|
||||||
|
|
||||||
if "book" in model_name:
|
|
||||||
return self.storage.get_book(pk)
|
|
||||||
elif "author" in model_name:
|
|
||||||
return self.storage.get_author(pk)
|
|
||||||
elif "genre" in model_name:
|
|
||||||
return self.storage.get_genre(pk)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def delete(self, obj: Any): ...
|
|
||||||
|
|
||||||
def exec(self, statement):
|
|
||||||
return MockResult([])
|
|
||||||
|
|
||||||
|
|
||||||
class MockResult:
|
|
||||||
"""Mock result for query operations"""
|
|
||||||
|
|
||||||
def __init__(self, data: List):
|
|
||||||
self.data = data
|
|
||||||
|
|
||||||
def all(self):
|
|
||||||
return self.data
|
|
||||||
|
|
||||||
def first(self):
|
|
||||||
return self.data[0] if self.data else None
|
|
||||||
|
|
||||||
|
|
||||||
def mock_get_session():
|
|
||||||
"""Mock session dependency"""
|
|
||||||
return MockSession()
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
class MockStorage:
|
|
||||||
"""In-memory storage for testing without database"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.books = {}
|
|
||||||
self.authors = {}
|
|
||||||
self.genres = {}
|
|
||||||
self.author_book_links = []
|
|
||||||
self.genre_book_links = []
|
|
||||||
self.book_id_counter = 1
|
|
||||||
self.author_id_counter = 1
|
|
||||||
self.genre_id_counter = 1
|
|
||||||
|
|
||||||
def clear_all(self):
|
|
||||||
"""Очистка всех данных"""
|
|
||||||
self.books.clear()
|
|
||||||
self.authors.clear()
|
|
||||||
self.genres.clear()
|
|
||||||
self.author_book_links.clear()
|
|
||||||
self.genre_book_links.clear()
|
|
||||||
self.book_id_counter = 1
|
|
||||||
self.author_id_counter = 1
|
|
||||||
self.genre_id_counter = 1
|
|
||||||
|
|
||||||
# Book operations
|
|
||||||
def create_book(self, title: str, description: str) -> dict:
|
|
||||||
book_id = self.book_id_counter
|
|
||||||
book = {"id": book_id, "title": title, "description": description}
|
|
||||||
self.books[book_id] = book
|
|
||||||
self.book_id_counter += 1
|
|
||||||
return book
|
|
||||||
|
|
||||||
def get_book(self, book_id: int) -> dict | None:
|
|
||||||
return self.books.get(book_id)
|
|
||||||
|
|
||||||
def get_all_books(self) -> List[dict]:
|
|
||||||
return list(self.books.values())
|
|
||||||
|
|
||||||
def update_book(
|
|
||||||
self,
|
|
||||||
book_id: int,
|
|
||||||
title: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
) -> dict | None:
|
|
||||||
if book_id not in self.books:
|
|
||||||
return None
|
|
||||||
book = self.books[book_id]
|
|
||||||
if title is not None:
|
|
||||||
book["title"] = title
|
|
||||||
if description is not None:
|
|
||||||
book["description"] = description
|
|
||||||
return book
|
|
||||||
|
|
||||||
def delete_book(self, book_id: int) -> dict | None:
|
|
||||||
if book_id not in self.books:
|
|
||||||
return None
|
|
||||||
book = self.books.pop(book_id)
|
|
||||||
self.author_book_links = [
|
|
||||||
link for link in self.author_book_links if link["book_id"] != book_id
|
|
||||||
]
|
|
||||||
self.genre_book_links = [
|
|
||||||
link for link in self.genre_book_links if link["book_id"] != book_id
|
|
||||||
]
|
|
||||||
return book
|
|
||||||
|
|
||||||
# Author operations
|
|
||||||
def create_author(self, name: str) -> dict:
|
|
||||||
author_id = self.author_id_counter
|
|
||||||
author = {"id": author_id, "name": name}
|
|
||||||
self.authors[author_id] = author
|
|
||||||
self.author_id_counter += 1
|
|
||||||
return author
|
|
||||||
|
|
||||||
def get_author(self, author_id: int) -> dict | None:
|
|
||||||
return self.authors.get(author_id)
|
|
||||||
|
|
||||||
def get_all_authors(self) -> List[dict]:
|
|
||||||
return list(self.authors.values())
|
|
||||||
|
|
||||||
def update_author(
|
|
||||||
self, author_id: int, name: str | None = None
|
|
||||||
) -> dict | None:
|
|
||||||
if author_id not in self.authors:
|
|
||||||
return None
|
|
||||||
author = self.authors[author_id]
|
|
||||||
if name is not None:
|
|
||||||
author["name"] = name
|
|
||||||
return author
|
|
||||||
|
|
||||||
def delete_author(self, author_id: int) -> dict | None:
|
|
||||||
if author_id not in self.authors:
|
|
||||||
return None
|
|
||||||
author = self.authors.pop(author_id)
|
|
||||||
self.author_book_links = [
|
|
||||||
link for link in self.author_book_links if link["author_id"] != author_id
|
|
||||||
]
|
|
||||||
return author
|
|
||||||
|
|
||||||
# Genre operations
|
|
||||||
def create_genre(self, name: str) -> dict:
|
|
||||||
genre_id = self.genre_id_counter
|
|
||||||
genre = {"id": genre_id, "name": name}
|
|
||||||
self.genres[genre_id] = genre
|
|
||||||
self.genre_id_counter += 1
|
|
||||||
return genre
|
|
||||||
|
|
||||||
def get_genre(self, genre_id: int) -> dict | None:
|
|
||||||
return self.genres.get(genre)
|
|
||||||
|
|
||||||
def get_all_authors(self) -> List[dict]:
|
|
||||||
return list(self.authors.values())
|
|
||||||
|
|
||||||
def update_genre(self, genre_id: int, name: str | None = None) -> dict | None:
|
|
||||||
if genre_id not in self.genres:
|
|
||||||
return None
|
|
||||||
genre = self.genres[genre_id]
|
|
||||||
if name is not None:
|
|
||||||
genre["name"] = name
|
|
||||||
return genre
|
|
||||||
|
|
||||||
def delete_genre(self, genre_id: int) -> dict | None:
|
|
||||||
if genre_id not in self.genres:
|
|
||||||
return None
|
|
||||||
genre = self.genres.pop(genre_id)
|
|
||||||
self.genre_book_links = [
|
|
||||||
link for link in self.genre_book_links if link["genre_id"] != genre_id
|
|
||||||
]
|
|
||||||
return genre
|
|
||||||
|
|
||||||
# Relationship operations
|
|
||||||
def create_author_book_link(self, author_id: int, book_id: int) -> bool:
|
|
||||||
if author_id not in self.authors or book_id not in self.books:
|
|
||||||
return False
|
|
||||||
for link in self.author_book_links:
|
|
||||||
if link["author_id"] == author_id and link["book_id"] == book_id:
|
|
||||||
return False
|
|
||||||
self.author_book_links.append({"author_id": author_id, "book_id": book_id})
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_authors_by_book(self, book_id: int) -> List[dict]:
|
|
||||||
author_ids = [
|
|
||||||
link["author_id"]
|
|
||||||
for link in self.author_book_links
|
|
||||||
if link["book_id"] == book_id
|
|
||||||
]
|
|
||||||
return [
|
|
||||||
self.authors[author_id]
|
|
||||||
for author_id in author_ids
|
|
||||||
if author_id in self.authors
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_books_by_author(self, author_id: int) -> List[dict]:
|
|
||||||
book_ids = [
|
|
||||||
link["book_id"]
|
|
||||||
for link in self.author_book_links
|
|
||||||
if link["author_id"] == author_id
|
|
||||||
]
|
|
||||||
return [self.books[book_id] for book_id in book_ids if book_id in self.books]
|
|
||||||
|
|
||||||
def get_all_author_book_links(self) -> List[dict]:
|
|
||||||
return list(self.author_book_links)
|
|
||||||
|
|
||||||
|
|
||||||
mock_storage = MockStorage()
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from tests.mock_app import mock_app
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
client = TestClient(mock_app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_database():
|
|
||||||
mock_storage.clear_all()
|
|
||||||
yield
|
|
||||||
mock_storage.clear_all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_list_authors():
|
|
||||||
response = client.get("/authors")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {"authors": [], "total": 0}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_author():
|
|
||||||
response = client.post("/authors", json={"name": "Test Author"})
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {"id": 1, "name": "Test Author"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_authors():
|
|
||||||
client.post("/authors", json={"name": "Test Author"})
|
|
||||||
|
|
||||||
response = client.get("/authors")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"authors": [{"id": 1, "name": "Test Author"}],
|
|
||||||
"total": 1,
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_existing_author():
|
|
||||||
client.post("/authors", json={"name": "Test Author"})
|
|
||||||
|
|
||||||
response = client.get("/authors/1")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"name": "Test Author",
|
|
||||||
"books": [],
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_not_existing_author():
|
|
||||||
response = client.get("/authors/2")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_author():
|
|
||||||
client.post("/authors", json={"name": "Test Author"})
|
|
||||||
|
|
||||||
response = client.get("/authors/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
response = client.put("/authors/1", json={"name": "Updated Author"})
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"name": "Updated Author",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_not_existing_author():
|
|
||||||
response = client.put("/authors/2", json={"name": "Updated Author"})
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_author():
|
|
||||||
client.post("/authors", json={"name": "Test Author"})
|
|
||||||
client.put("/authors/1", json={"name": "Updated Author"})
|
|
||||||
|
|
||||||
response = client.get("/authors/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
response = client.delete("/authors/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"name": "Updated Author",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_not_existing_delete_author():
|
|
||||||
response = client.delete("/authors/2")
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from tests.mock_app import mock_app
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
client = TestClient(mock_app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_database():
|
|
||||||
mock_storage.clear_all()
|
|
||||||
yield
|
|
||||||
mock_storage.clear_all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_list_books():
|
|
||||||
response = client.get("/books")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {"books": [], "total": 0}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_book():
|
|
||||||
response = client.post(
|
|
||||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
|
||||||
)
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"title": "Test Book",
|
|
||||||
"description": "Test Description",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_books():
|
|
||||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/books")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"books": [{"id": 1, "title": "Test Book", "description": "Test Description"}],
|
|
||||||
"total": 1,
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_existing_book():
|
|
||||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/books/1")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"title": "Test Book",
|
|
||||||
"description": "Test Description",
|
|
||||||
"authors": [],
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_not_existing_book():
|
|
||||||
response = client.get("/books/2")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_book():
|
|
||||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/books/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
response = client.put(
|
|
||||||
"/books/1", json={"title": "Updated Book", "description": "Updated Description"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"title": "Updated Book",
|
|
||||||
"description": "Updated Description",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_not_existing_book():
|
|
||||||
response = client.put(
|
|
||||||
"/books/2", json={"title": "Updated Book", "description": "Updated Description"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_book():
|
|
||||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"})
|
|
||||||
client.put("/books/1", json={"title": "Updated Book", "description": "Updated Description"}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/books/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
response = client.delete("/books/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"title": "Updated Book",
|
|
||||||
"description": "Updated Description",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_not_existing_delete_book():
|
|
||||||
response = client.delete("/books/2")
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from tests.mock_app import mock_app
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
client = TestClient(mock_app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_database():
|
|
||||||
mock_storage.clear_all()
|
|
||||||
yield
|
|
||||||
mock_storage.clear_all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_page():
|
|
||||||
response = client.get("/api")
|
|
||||||
try:
|
|
||||||
content = response.content.decode("utf-8")
|
|
||||||
title_idx = content.index("Welcome to ")
|
|
||||||
description_idx = content.index("Description: ")
|
|
||||||
version_idx = content.index("Version: ")
|
|
||||||
time_idx = content.index("Current Time: ")
|
|
||||||
status_idx = content.index("Status: ")
|
|
||||||
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert content.startswith("<!doctype html>"), "Not HTML"
|
|
||||||
assert content.endswith("</html>"), "HTML tag not closed"
|
|
||||||
assert content[title_idx + 1] != "<", "Title not provided"
|
|
||||||
assert content[description_idx + 1] != "<", "Description not provided"
|
|
||||||
assert content[version_idx + 1] != "<", "Version not provided"
|
|
||||||
assert content[time_idx + 1] != "<", "Time not provided"
|
|
||||||
assert content[status_idx + 1] != "<", "Status not provided"
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
assert False, "Unexpected error"
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_info_test():
|
|
||||||
response = client.get("/api/info")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json()["status"] == "ok", "Status not ok"
|
|
||||||
assert response.json()["app_info"]["title"] != "", "Title not provided"
|
|
||||||
assert response.json()["app_info"]["description"] != "", "Description not provided"
|
|
||||||
assert response.json()["app_info"]["version"] != "", "Version not provided"
|
|
||||||
assert (0 < (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds()), "Negative time difference"
|
|
||||||
assert (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds() < 1, "Time difference too large"
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from tests.mock_app import mock_app
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
client = TestClient(mock_app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_database():
|
|
||||||
mock_storage.clear_all()
|
|
||||||
yield
|
|
||||||
mock_storage.clear_all()
|
|
||||||
|
|
||||||
|
|
||||||
def make_authorbook_relationship(author_id, book_id):
|
|
||||||
response = client.post(
|
|
||||||
"/relationships/author-book",
|
|
||||||
params={"author_id": author_id, "book_id": book_id},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
|
|
||||||
def make_genrebook_relationship(genre_id, book_id):
|
|
||||||
response = client.post(
|
|
||||||
"/relationships/genre-book", params={"genre_id": genre_id, "book_id": book_id}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_data():
|
|
||||||
assert (client.post("/books", json={"title": "Test Book 1", "description": "Test Description 1"}).status_code == 200)
|
|
||||||
assert (client.post("/books", json={"title": "Test Book 2", "description": "Test Description 2"}).status_code == 200)
|
|
||||||
assert (client.post("/books", json={"title": "Test Book 3", "description": "Test Description 3"}).status_code == 200)
|
|
||||||
|
|
||||||
assert client.post("/authors", json={"name": "Test Author 1"}).status_code == 200
|
|
||||||
assert client.post("/authors", json={"name": "Test Author 2"}).status_code == 200
|
|
||||||
assert client.post("/authors", json={"name": "Test Author 3"}).status_code == 200
|
|
||||||
|
|
||||||
assert client.post("/genres", json={"name": "Test Genre 1"}).status_code == 200
|
|
||||||
assert client.post("/genres", json={"name": "Test Genre 2"}).status_code == 200
|
|
||||||
assert client.post("/genres", json={"name": "Test Genre 3"}).status_code == 200
|
|
||||||
|
|
||||||
make_authorbook_relationship(1, 1)
|
|
||||||
make_authorbook_relationship(2, 1)
|
|
||||||
make_authorbook_relationship(1, 2)
|
|
||||||
make_authorbook_relationship(2, 3)
|
|
||||||
make_authorbook_relationship(3, 3)
|
|
||||||
make_genrebook_relationship(1, 1)
|
|
||||||
make_genrebook_relationship(2, 1)
|
|
||||||
make_genrebook_relationship(1, 2)
|
|
||||||
make_genrebook_relationship(2, 3)
|
|
||||||
make_genrebook_relationship(3, 3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_book_authors():
|
|
||||||
test_prepare_data()
|
|
||||||
|
|
||||||
response1 = client.get("/books/1/authors")
|
|
||||||
assert response1.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response1.json()) == 2, "Invalid number of authors"
|
|
||||||
assert response1.json()[0]["name"] == "Test Author 1"
|
|
||||||
assert response1.json()[1]["name"] == "Test Author 2"
|
|
||||||
assert response1.json()[0]["id"] == 1
|
|
||||||
assert response1.json()[1]["id"] == 2
|
|
||||||
|
|
||||||
response2 = client.get("/books/2/authors")
|
|
||||||
assert response2.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response2.json()) == 1, "Invalid number of authors"
|
|
||||||
assert response2.json()[0]["name"] == "Test Author 1"
|
|
||||||
assert response2.json()[0]["id"] == 1
|
|
||||||
|
|
||||||
response3 = client.get("/books/3/authors")
|
|
||||||
assert response3.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response3.json()) == 2, "Invalid number of authors"
|
|
||||||
assert response3.json()[0]["name"] == "Test Author 2"
|
|
||||||
assert response3.json()[1]["name"] == "Test Author 3"
|
|
||||||
assert response3.json()[0]["id"] == 2
|
|
||||||
assert response3.json()[1]["id"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_author_books():
|
|
||||||
test_prepare_data()
|
|
||||||
|
|
||||||
response1 = client.get("/authors/1/books")
|
|
||||||
assert response1.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response1.json()) == 2, "Invalid number of books"
|
|
||||||
assert response1.json()[0]["title"] == "Test Book 1"
|
|
||||||
assert response1.json()[1]["title"] == "Test Book 2"
|
|
||||||
assert response1.json()[0]["id"] == 1
|
|
||||||
assert response1.json()[1]["id"] == 2
|
|
||||||
|
|
||||||
response2 = client.get("/authors/2/books")
|
|
||||||
assert response2.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response2.json()) == 2, "Invalid number of books"
|
|
||||||
assert response2.json()[0]["title"] == "Test Book 1"
|
|
||||||
assert response2.json()[1]["title"] == "Test Book 3"
|
|
||||||
assert response2.json()[0]["id"] == 1
|
|
||||||
assert response2.json()[1]["id"] == 3
|
|
||||||
|
|
||||||
response3 = client.get("/authors/3/books")
|
|
||||||
assert response3.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response3.json()) == 1, "Invalid number of books"
|
|
||||||
assert response3.json()[0]["title"] == "Test Book 3"
|
|
||||||
assert response3.json()[0]["id"] == 3
|
|
||||||
@@ -278,6 +278,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deprecated"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "wrapt" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dill"
|
name = "dill"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -467,7 +479,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
|
||||||
@@ -475,7 +486,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
|
||||||
@@ -483,7 +493,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
|
||||||
@@ -491,7 +500,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
|
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
|
||||||
@@ -618,8 +626,8 @@ source = { registry = "https://pypi.org/simple" }
|
|||||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32b80685b26316b604218e4ac77487ea82719c3c28bc6/json_log_formatter-1.1.1.tar.gz", hash = "sha256:0815e3b4469e5c79cf3f6dc8a0613ba6601f4a7464f85ba03655cfa6e3e17d10", size = 5896, upload-time = "2025-02-27T22:56:15.643Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32b80685b26316b604218e4ac77487ea82719c3c28bc6/json_log_formatter-1.1.1.tar.gz", hash = "sha256:0815e3b4469e5c79cf3f6dc8a0613ba6601f4a7464f85ba03655cfa6e3e17d10", size = 5896, upload-time = "2025-02-27T22:56:15.643Z" }
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libraryapi"
|
name = "lib"
|
||||||
version = "0.5.0"
|
version = "0.9.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -627,13 +635,17 @@ dependencies = [
|
|||||||
{ name = "fastapi", extra = ["all"] },
|
{ name = "fastapi", extra = ["all"] },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "json-log-formatter" },
|
{ name = "json-log-formatter" },
|
||||||
|
{ name = "limits" },
|
||||||
|
{ name = "ollama" },
|
||||||
{ name = "passlib", extra = ["argon2"] },
|
{ name = "passlib", extra = ["argon2"] },
|
||||||
|
{ name = "pgvector" },
|
||||||
{ name = "psycopg2-binary" },
|
{ name = "psycopg2-binary" },
|
||||||
{ name = "pydantic", extra = ["email"] },
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pyotp" },
|
{ name = "pyotp" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "python-jose", extra = ["cryptography"] },
|
{ name = "python-jose", extra = ["cryptography"] },
|
||||||
{ name = "qrcode", extra = ["pil"] },
|
{ name = "qrcode", extra = ["pil"] },
|
||||||
|
{ name = "slowapi" },
|
||||||
{ name = "sqlmodel" },
|
{ name = "sqlmodel" },
|
||||||
{ name = "toml" },
|
{ name = "toml" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
@@ -655,13 +667,17 @@ requires-dist = [
|
|||||||
{ name = "fastapi", extras = ["all"], specifier = ">=0.115.14" },
|
{ name = "fastapi", extras = ["all"], specifier = ">=0.115.14" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
{ name = "json-log-formatter", specifier = ">=1.1.1" },
|
{ name = "json-log-formatter", specifier = ">=1.1.1" },
|
||||||
|
{ name = "limits", specifier = ">=5.6.0" },
|
||||||
|
{ name = "ollama", specifier = ">=0.6.1" },
|
||||||
{ name = "passlib", extras = ["argon2"], specifier = ">=1.7.4" },
|
{ name = "passlib", extras = ["argon2"], specifier = ">=1.7.4" },
|
||||||
|
{ name = "pgvector", specifier = ">=0.4.2" },
|
||||||
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
||||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.5" },
|
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.5" },
|
||||||
{ name = "pyotp", specifier = ">=2.9.0" },
|
{ name = "pyotp", specifier = ">=2.9.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=0.21.1" },
|
{ name = "python-dotenv", specifier = ">=0.21.1" },
|
||||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
|
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
|
||||||
|
{ name = "slowapi", specifier = ">=0.1.9" },
|
||||||
{ name = "sqlmodel", specifier = ">=0.0.31" },
|
{ name = "sqlmodel", specifier = ">=0.0.31" },
|
||||||
{ name = "toml", specifier = ">=0.10.2" },
|
{ name = "toml", specifier = ">=0.10.2" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" },
|
||||||
@@ -676,6 +692,20 @@ dev = [
|
|||||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "limits"
|
||||||
|
version = "5.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "deprecated" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5", size = 182984, upload-time = "2025-09-29T17:15:22.689Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604, upload-time = "2025-09-29T17:15:18.419Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mako"
|
name = "mako"
|
||||||
version = "1.3.10"
|
version = "1.3.10"
|
||||||
@@ -790,6 +820,80 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ollama"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "orjson"
|
name = "orjson"
|
||||||
version = "3.11.5"
|
version = "3.11.5"
|
||||||
@@ -875,6 +979,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pgvector"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.1.0"
|
version = "12.1.0"
|
||||||
@@ -1451,6 +1567,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slowapi"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "limits" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.45"
|
version = "2.0.45"
|
||||||
@@ -1796,3 +1924,72 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wrapt"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user