Compare commits

...

18 Commits

Author SHA1 Message Date
a336d50ad0 превью и улучшение seo 2026-02-01 20:10:21 +03:00
38642a6910 удаление файла после транскодирования 2026-02-01 19:28:36 +03:00
d442a37820 транскодирование изображений 2026-02-01 19:21:06 +03:00
80acdceba6 изображение книги 2026-02-01 16:55:43 +03:00
4368ee0d3c Merge remote-tracking branch 'origin/main' 2026-01-31 23:50:22 +03:00
4f9c472a54 удаление .env 2026-01-31 23:50:09 +03:00
a6811a3e86 удаление .env 2026-01-31 23:43:00 +03:00
19d322c9d9 Единый тип ответа авторизации, добавление кнопки создания автора на странице авторы 2026-01-31 23:41:56 +03:00
dfa4d14afc Добавление мета-тэгов 2026-01-31 15:29:15 +03:00
6014db3c81 Исправление репликации 2026-01-31 01:30:07 +03:00
0e159df16e Локальный мердж 2026-01-31 00:55:45 +03:00
2f3d6f0e1e Страница 404, более подробная инофрмация об ошибках, улучшение фронтэнда и логирования, исправление docker-compose 2026-01-31 00:49:05 +03:00
657f1b96f2 Доавление векторного поиска и репликации 2026-01-29 00:58:48 +03:00
9f814e7271 Доавление векторного поиска и репликации 2026-01-29 00:42:52 +03:00
09d5739256 Динамическое создание er-диаграммы по моделям 2026-01-25 20:19:55 +03:00
ec1c32a5bd Улучшение документации и KDF с шифрованием totp 2026-01-24 10:52:08 +03:00
c1ac0ca246 Добавление catpcha при регистрации, фильтрация по количеству страниц 2026-01-23 23:32:09 +03:00
7c3074e8fe Добавление количества страниц книгам 2026-01-23 01:31:50 +03:00
96 changed files with 4115 additions and 2434 deletions
-37
View File
@@ -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
View File
@@ -1,4 +1,5 @@
.env
library_service/static/books/
*.log
# Byte-compiled / optimized / DLL files
-1
View File
@@ -19,7 +19,6 @@ RUN uv sync --group dev --no-install-project
COPY ./library_service /code/library_service
COPY ./alembic.ini /code/
COPY ./data.py /code/
RUN useradd app && \
chown -R app:app /code && \
+19 -16
View File
@@ -19,16 +19,17 @@
1. Клонируйте репозиторий:
```bash
git clone https://github.com/wowlikon/libraryapi.git
git clone https://github.com/wowlikon/LiB.git
```
2. Перейдите в каталог проекта:
```bash
cd libraryapi
cd LiB
```
3. Настройте переменные окружения:
```bash
cp example-docker.env .env # или example-local.env для запуска без docker
edit .env
```
@@ -44,22 +45,12 @@
Для создания новых миграций:
```bash
alembic revision --autogenerate -m "Migration name"
```
Для запуска тестов:
```bash
docker compose up test
```
Для добавления данных для примера используйте:
```bash
python data.py
uv run alembic revision --autogenerate -m "Migration name"
```
### **Роли пользователей**
- **Админ**: Полный доступ ко всем функциям системы
- **admin**: Полный доступ ко всем функциям системы
- **librarian**: Управление книгами, авторами, жанрами и выдачами
- **member**: Просмотр каталога и управление своими выдачами
@@ -145,7 +136,7 @@
#### **Пользователи** (`/api/users`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|-------------------------------|----------------|------------------------------|
|--------|--------------------------------|----------------|------------------------------|
| POST | `/` | Админ | Создать нового пользователя |
| GET | `/` | Админ | Список всех пользователей |
| GET | `/{id}` | Админ | Получить пользователя по ID |
@@ -156,12 +147,21 @@
| GET | `/roles` | Авторизованный | Список ролей в системе |
#### **CAPTCHA** (`/api/cap`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|---------------|-----------|-----------------|
| POST | `/challenge` | Публичный | Создание задачи |
| POST | `/redeem` | Публичный | Проверка задачи |
#### **Прочее** (`/api`)
| Метод | Эндпоинт | Доступ | Описание |
|-------|----------|-----------|----------------------|
|-------|-----------|-----------|----------------------|
| GET | `/info` | Публичный | Информация о сервисе |
| GET | `/stats` | Публичный | Статистика системы |
| GET | `/schema` | Публичный | Схема базы данных |
### **Веб-страницы**
@@ -265,6 +265,8 @@ erDiagram
- **ACTIVE**: Книга доступна для выдачи
- **RESERVED**: Книга забронирована (ожидает подтверждения)
- **BORROWED**: Книга выдана пользователю
- **RESTORATION**: Книга на реставрации
- **WRITTEN_OFF**: Книга списана
### **Используемые технологии**
@@ -273,6 +275,7 @@ erDiagram
- **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic
- **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy
- **PostgreSQL**: Реляционная система управления базами данных
- **Ollama**: Инструмент для локального запуска и управления большими языковыми моделями
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker
- **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса
-356
View File
@@ -1,356 +0,0 @@
import requests
from typing import Optional
# Конфигурация
USERNAME = "admin"
PASSWORD = "your-password-here"
BASE_URL = "http://localhost:8000"
class LibraryAPI:
def __init__(self, base_url: str):
self.base_url = base_url
self.token: Optional[str] = None
self.session = requests.Session()
def login(self, username: str, password: str) -> bool:
"""Авторизация и получение токена"""
response = self.session.post(
f"{self.base_url}/api/auth/token",
data={"username": username, "password": password},
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
if response.status_code == 200:
self.token = response.json()["access_token"]
self.session.headers.update({"Authorization": f"Bearer {self.token}"})
print(f"✓ Авторизация успешна для пользователя: {username}")
return True
else:
print(f"✗ Ошибка авторизации: {response.text}")
return False
def register(self, username: str, email: str, password: str, full_name: str = None) -> bool:
"""Регистрация нового пользователя"""
data = {
"username": username,
"email": email,
"password": password
}
if full_name:
data["full_name"] = full_name
response = self.session.post(
f"{self.base_url}/api/auth/register",
json=data
)
if response.status_code == 201:
print(f"✓ Пользователь {username} зарегистрирован")
return True
else:
print(f"✗ Ошибка регистрации: {response.text}")
return False
def create_author(self, name: str) -> Optional[int]:
"""Создание автора"""
response = self.session.post(
f"{self.base_url}/api/authors/",
json={"name": name}
)
if response.status_code == 200:
author_id = response.json()["id"]
print(f" ✓ Автор создан: {name} (ID: {author_id})")
return author_id
else:
print(f" ✗ Ошибка создания автора {name}: {response.text}")
return None
def create_book(self, title: str, description: str) -> Optional[int]:
"""Создание книги"""
response = self.session.post(
f"{self.base_url}/api/books/",
json={"title": title, "description": description}
)
if response.status_code == 200:
book_id = response.json()["id"]
print(f" ✓ Книга создана: {title} (ID: {book_id})")
return book_id
else:
print(f" ✗ Ошибка создания книги {title}: {response.text}")
return None
def create_genre(self, name: str) -> Optional[int]:
"""Создание жанра"""
response = self.session.post(
f"{self.base_url}/api/genres/",
json={"name": name}
)
if response.status_code == 200:
genre_id = response.json()["id"]
print(f" ✓ Жанр создан: {name} (ID: {genre_id})")
return genre_id
else:
print(f" ✗ Ошибка создания жанра {name}: {response.text}")
return None
def link_author_book(self, author_id: int, book_id: int) -> bool:
"""Связь автора и книги"""
response = self.session.post(
f"{self.base_url}/api/relationships/author-book",
params={"author_id": author_id, "book_id": book_id}
)
if response.status_code == 200:
print(f" ↔ Связь автор-книга: {author_id}{book_id}")
return True
else:
print(f" ✗ Ошибка связи автор-книга: {response.text}")
return False
def link_genre_book(self, genre_id: int, book_id: int) -> bool:
"""Связь жанра и книги"""
response = self.session.post(
f"{self.base_url}/api/relationships/genre-book",
params={"genre_id": genre_id, "book_id": book_id}
)
if response.status_code == 200:
print(f" ↔ Связь жанр-книга: {genre_id}{book_id}")
return True
else:
print(f" ✗ Ошибка связи жанр-книга: {response.text}")
return False
def main():
api = LibraryAPI(BASE_URL)
# Авторизация
if not api.login(USERNAME, PASSWORD):
print("Не удалось авторизоваться. Проверьте логин и пароль.")
return
print("\n📚 Создание авторов...")
authors_data = [
"Лев Толстой",
"Фёдор Достоевский",
"Антон Чехов",
"Александр Пушкин",
"Михаил Булгаков",
"Николай Гоголь",
"Иван Тургенев",
"Борис Пастернак",
"Михаил Лермонтов",
"Александр Солженицын",
"Максим Горький",
"Иван Бунин"
]
authors = {}
for name in authors_data:
author_id = api.create_author(name)
if author_id:
authors[name] = author_id
print("\n🏷️ Создание жанров...")
genres_data = [
"Роман",
"Повесть",
"Рассказ",
"Поэзия",
"Драма",
"Философская проза",
"Историческая проза",
"Сатира"
]
genres = {}
for name in genres_data:
genre_id = api.create_genre(name)
if genre_id:
genres[name] = genre_id
print("\n📖 Создание книг...")
books_data = [
{
"title": "Война и мир",
"description": "Роман-эпопея Льва Толстого, описывающий русское общество в эпоху войн против Наполеона в 1805—1812 годах. Одно из величайших произведений мировой литературы.",
"authors": ["Лев Толстой"],
"genres": ["Роман", "Историческая проза"]
},
{
"title": "Анна Каренина",
"description": "Роман Льва Толстого о трагической любви замужней дамы Анны Карениной к блестящему офицеру Вронскому. История страсти, ревности и роковых решений.",
"authors": ["Лев Толстой"],
"genres": ["Роман", "Драма"]
},
{
"title": "Преступление и наказание",
"description": "Социально-психологический роман Фёдора Достоевского о бедном студенте Раскольникове, совершившем убийство и мучающемся угрызениями совести.",
"authors": ["Фёдор Достоевский"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Братья Карамазовы",
"description": "Последний роман Достоевского, история семьи Карамазовых, затрагивающая глубокие вопросы веры, свободы воли и морали.",
"authors": ["Фёдор Достоевский"],
"genres": ["Роман", "Философская проза", "Драма"]
},
{
"title": "Идиот",
"description": "Роман о князе Мышкине — человеке с чистой душой, который сталкивается с жестокостью и корыстью петербургского общества.",
"authors": ["Фёдор Достоевский"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Вишнёвый сад",
"description": "Пьеса Антона Чехова о разорении дворянского гнезда и продаже родового имения с вишнёвым садом.",
"authors": ["Антон Чехов"],
"genres": ["Драма"]
},
{
"title": "Чайка",
"description": "Пьеса Чехова о любви, искусстве и несбывшихся мечтах, разворачивающаяся в усадьбе на берегу озера.",
"authors": ["Антон Чехов"],
"genres": ["Драма"]
},
{
"title": "Палата № 6",
"description": "Повесть о враче психиатрической больницы, который начинает сомневаться в границах между нормой и безумием.",
"authors": ["Антон Чехов"],
"genres": ["Повесть", "Философская проза"]
},
{
"title": "Евгений Онегин",
"description": "Роман в стихах Александра Пушкина — энциклопедия русской жизни начала XIX века и история несчастной любви.",
"authors": ["Александр Пушкин"],
"genres": ["Роман", "Поэзия"]
},
{
"title": "Капитанская дочка",
"description": "Исторический роман Пушкина о событиях Пугачёвского восстания, любви и чести.",
"authors": ["Александр Пушкин"],
"genres": ["Роман", "Историческая проза"]
},
{
"title": "Пиковая дама",
"description": "Повесть о молодом офицере Германне, одержимом желанием узнать тайну трёх карт.",
"authors": ["Александр Пушкин"],
"genres": ["Повесть"]
},
{
"title": "Мастер и Маргарита",
"description": "Роман Михаила Булгакова о визите дьявола в Москву 1930-х годов, переплетённый с историей Понтия Пилата.",
"authors": ["Михаил Булгаков"],
"genres": ["Роман", "Сатира", "Философская проза"]
},
{
"title": "Собачье сердце",
"description": "Повесть-сатира о профессоре Преображенском, превратившем бродячего пса в человека.",
"authors": ["Михаил Булгаков"],
"genres": ["Повесть", "Сатира"]
},
{
"title": "Белая гвардия",
"description": "Роман о семье Турбиных в Киеве во время Гражданской войны 1918-1919 годов.",
"authors": ["Михаил Булгаков"],
"genres": ["Роман", "Историческая проза"]
},
{
"title": "Мёртвые души",
"description": "Поэма Николая Гоголя о похождениях Чичикова, скупающего «мёртвые души» крепостных крестьян.",
"authors": ["Николай Гоголь"],
"genres": ["Роман", "Сатира"]
},
{
"title": "Ревизор",
"description": "Комедия о чиновниках уездного города, принявших проезжего за ревизора из Петербурга.",
"authors": ["Николай Гоголь"],
"genres": ["Драма", "Сатира"]
},
{
"title": "Шинель",
"description": "Повесть о маленьком человеке — титулярном советнике Акакии Башмачкине и его мечте о новой шинели.",
"authors": ["Николай Гоголь"],
"genres": ["Повесть"]
},
{
"title": "Отцы и дети",
"description": "Роман Ивана Тургенева о конфликте поколений и нигилизме на примере Евгения Базарова.",
"authors": ["Иван Тургенев"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Записки охотника",
"description": "Цикл рассказов Тургенева о русской деревне и крестьянах, написанный с глубоким сочувствием к народу.",
"authors": ["Иван Тургенев"],
"genres": ["Рассказ"]
},
{
"title": "Доктор Живаго",
"description": "Роман Бориса Пастернака о судьбе русского интеллигента в эпоху революции и Гражданской войны.",
"authors": ["Борис Пастернак"],
"genres": ["Роман", "Историческая проза", "Поэзия"]
},
{
"title": "Герой нашего времени",
"description": "Роман Михаила Лермонтова о Печорине — «лишнем человеке», скучающем и разочарованном в жизни.",
"authors": ["Михаил Лермонтов"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Архипелаг ГУЛАГ",
"description": "Документально-художественное исследование Александра Солженицына о системе советских лагерей.",
"authors": ["Александр Солженицын"],
"genres": ["Историческая проза"]
},
{
"title": "Один день Ивана Денисовича",
"description": "Повесть о одном дне заключённого советского лагеря, положившая начало лагерной прозе.",
"authors": ["Александр Солженицын"],
"genres": ["Повесть", "Историческая проза"]
},
{
"title": "На дне",
"description": "Пьеса Максима Горького о жителях ночлежки для бездомных — людях, оказавшихся на дне жизни.",
"authors": ["Максим Горький"],
"genres": ["Драма", "Философская проза"]
},
{
"title": "Тёмные аллеи",
"description": "Сборник рассказов Ивана Бунина о любви — трагической, мимолётной и прекрасной.",
"authors": ["Иван Бунин"],
"genres": ["Рассказ"]
}
]
books = {}
for book in books_data:
book_id = api.create_book(book["title"], book["description"])
if book_id:
books[book["title"]] = {
"id": book_id,
"authors": book["authors"],
"genres": book["genres"]
}
print("\n🔗 Создание связей...")
for book_title, book_info in books.items():
book_id = book_info["id"]
for author_name in book_info["authors"]:
if author_name in authors:
api.link_author_book(authors[author_name], book_id)
for genre_name in book_info["genres"]:
if genre_name in genres:
api.link_genre_book(genres[genre_name], book_id)
print("\n" + "=" * 50)
print("📊 ИТОГИ:")
print(f" • Авторов создано: {len(authors)}")
print(f" • Жанров создано: {len(genres)}")
print(f" • Книг создано: {len(books)}")
print("=" * 50)
if __name__ == "__main__":
main()
+60 -22
View File
@@ -1,6 +1,6 @@
services:
db:
image: postgres:17
image: pgvector/pgvector:pg17
container_name: db
restart: unless-stopped
logging:
@@ -11,26 +11,81 @@ services:
- ./data/db:/var/lib/postgresql/data
networks:
- proxy
ports: # !сменить внешний порт перед использованием!
- 5432:5432
env_file:
- ./.env
command:
- "postgres"
- "-c"
- "wal_level=logical"
- "-c"
- "max_replication_slots=10"
- "-c"
- "max_wal_senders=10"
- "-c"
- "listen_addresses=*"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
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:
build: .
container_name: api
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:
options:
max-size: "10m"
max-file: "3"
networks:
- proxy
ports:
ports: # !только локальный тест!
- 8000:8000
env_file:
- ./.env
@@ -39,24 +94,7 @@ services:
depends_on:
db:
condition: service_healthy
tests:
container_name: tests
build: .
command: bash -c "pytest tests"
restart: no
logging:
options:
max-size: "10m"
max-file: "3"
networks:
- proxy
env_file:
- ./.env
volumes:
- .:/code
depends_on:
db:
llm:
condition: service_healthy
networks:
+47
View File
@@ -0,0 +1,47 @@
# Postgres
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=lib
REMOTE_HOST=
REMOTE_PORT=
NODE_ID=
# Ollama
OLLAMA_URL="http://llm:11434"
OLLAMA_MAX_LOADED_MODELS=1
OLLAMA_NUM_THREADS=4
OLLAMA_KEEP_ALIVE=5m
# Default admin account
DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_EMAIL="admin@example.com"
DEFAULT_ADMIN_PASSWORD="Password12345"
SECRET_KEY="your-secret-key-change-in-production"
DOMAIN="mydomain.com"
# JWT
ALGORITHM=HS256
REFRESH_TOKEN_EXPIRE_DAYS=7
ACCESS_TOKEN_EXPIRE_MINUTES=15
PARTIAL_TOKEN_EXPIRE_MINUTES=5
# Hash
ARGON2_TYPE=id
ARGON2_TIME_COST=3
ARGON2_MEMORY_COST=65536
ARGON2_PARALLELISM=4
ARGON2_SALT_LENGTH=16
ARGON2_HASH_LENGTH=48
# Recovery codes
RECOVERY_CODES_COUNT=10
RECOVERY_CODE_SEGMENTS=4
RECOVERY_CODE_SEGMENT_BYTES=2
RECOVERY_MIN_REMAINING_WARNING=3
RECOVERY_MAX_AGE_DAYS=365
# TOTP_2FA
TOTP_ISSUER=LiB
TOTP_VALID_WINDOW=1
+44
View File
@@ -0,0 +1,44 @@
# Postgres
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=lib
# Ollama
OLLAMA_URL="http://localhost:11434"
OLLAMA_MAX_LOADED_MODELS=1
OLLAMA_NUM_THREADS=4
OLLAMA_KEEP_ALIVE=5m
# Default admin account
DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_EMAIL="admin@example.com"
DEFAULT_ADMIN_PASSWORD="Password12345"
SECRET_KEY="your-secret-key-change-in-production"
DOMAIN="mydomain.com"
# JWT
ALGORITHM=HS256
REFRESH_TOKEN_EXPIRE_DAYS=7
ACCESS_TOKEN_EXPIRE_MINUTES=15
PARTIAL_TOKEN_EXPIRE_MINUTES=5
# Hash
ARGON2_TYPE=id
ARGON2_TIME_COST=3
ARGON2_MEMORY_COST=65536
ARGON2_PARALLELISM=4
ARGON2_SALT_LENGTH=16
ARGON2_HASH_LENGTH=48
# Recovery codes
RECOVERY_CODES_COUNT=10
RECOVERY_CODE_SEGMENTS=4
RECOVERY_CODE_SEGMENT_BYTES=2
RECOVERY_MIN_REMAINING_WARNING=3
RECOVERY_MAX_AGE_DAYS=365
# TOTP_2FA
TOTP_ISSUER=LiB
TOTP_VALID_WINDOW=1
+4
View File
@@ -0,0 +1,4 @@
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SELECT pg_reload_conf();
+8
View File
@@ -16,6 +16,10 @@ from .core import (
RECOVERY_CODE_SEGMENT_BYTES,
RECOVERY_MIN_REMAINING_WARNING,
RECOVERY_MAX_AGE_DAYS,
KeyDeriver,
deriver,
AES256Cipher,
cipher,
verify_password,
get_password_hash,
create_access_token,
@@ -75,6 +79,10 @@ __all__ = [
"RECOVERY_CODE_SEGMENT_BYTES",
"RECOVERY_MIN_REMAINING_WARNING",
"RECOVERY_MAX_AGE_DAYS",
"KeyDeriver",
"deriver",
"AES256Cipher",
"cipher",
"verify_password",
"get_password_hash",
"create_access_token",
+67 -5
View File
@@ -1,10 +1,13 @@
"""Модуль основного функционала авторизации и аутентификации"""
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated
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.security import OAuth2PasswordBearer
from jose import jwt, JWTError, ExpiredSignatureError
@@ -17,7 +20,6 @@ from library_service.settings import get_session, get_logger
# Конфигурация JWT из переменных окружения
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = os.getenv("ALGORITHM", "HS256")
PARTIAL_TOKEN_EXPIRE_MINUTES = int(os.getenv("PARTIAL_TOKEN_EXPIRE_MINUTES", "5"))
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
@@ -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_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365"))
SECRET_KEY = os.getenv("SECRET_KEY")
# Получение логгера
logger = get_logger()
# OAuth2 схема
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:
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(
schemes=["argon2"],
@@ -88,7 +150,7 @@ def _create_token(
}
if token_type == "refresh":
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:
@@ -119,7 +181,7 @@ def decode_token(
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
payload = jwt.decode(token, jwt_key, algorithms=[ALGORITHM])
username: str | None = payload.get("sub")
user_id: int | None = payload.get("user_id")
token_type: str | None = payload.get("type")
+58 -3
View File
@@ -1,4 +1,6 @@
"""Основной модуль"""
import asyncio, sys, traceback
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
@@ -7,19 +9,25 @@ from uuid import uuid4
from alembic import command
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.responses import JSONResponse
from ollama import Client, ResponseError
from sqlmodel import Session
from library_service.auth import run_seeds
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 (
LOGGING_CONFIG,
engine,
get_app,
get_logger,
OLLAMA_URL,
)
SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"})
@@ -47,6 +55,14 @@ async def lifespan(_):
except Exception as 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...")
yield # Обработка запросов
logger.info("[+] Application shutdown")
@@ -55,7 +71,40 @@ async def 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")
async def log_requests(request: Request, call_next):
"""Middleware для логирования HTTP-запросов"""
@@ -113,7 +162,10 @@ async def log_requests(request: Request, call_next):
},
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__":
import uvicorn
uvicorn.run(
"library_service.main:app",
host="0.0.0.0",
port=8000,
proxy_headers=True,
forwarded_allow_ips="*",
log_config=LOGGING_CONFIG,
access_log=False,
)
+5 -1
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей авторов"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
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(
back_populates="authors", link_model=AuthorBookLink
)
+8 -1
View File
@@ -1,7 +1,9 @@
"""Модуль DB-моделей книг"""
from typing import TYPE_CHECKING, List
from uuid import UUID
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, String
from sqlmodel import Field, Relationship
@@ -17,11 +19,16 @@ if TYPE_CHECKING:
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(
default=BookStatus.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(
back_populates="books", link_model=AuthorBookLink
)
+5 -1
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей жанров"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
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(
back_populates="genres", link_model=GenreBookLink
)
+46 -12
View File
@@ -10,14 +10,29 @@ class AuthorBookLink(SQLModel, table=True):
author_id: int | None = Field(
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):
"""Модель связи жанра и книги"""
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
genre_id: int | None = Field(
default=None,
foreign_key="genre.id",
primary_key=True,
description="Идентификатор жанра",
)
book_id: int | None = Field(
default=None,
foreign_key="book.id",
primary_key=True,
description="Идентификатор книги",
)
class UserRoleLink(SQLModel, table=True):
@@ -25,8 +40,18 @@ class UserRoleLink(SQLModel, table=True):
__tablename__ = "user_roles"
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
user_id: int | None = Field(
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):
@@ -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")
user_id: int = Field(foreign_key="users.id")
book_id: int = Field(foreign_key="book.id", description="Идентификатор")
user_id: int = Field(
foreign_key="users.id", description="Идентификатор пользователя"
)
borrowed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
due_date: datetime
returned_at: datetime | None = Field(default=None)
borrowed_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="Дата и время выдачи",
)
due_date: datetime = Field(description="Дата и время запланированного возврата")
returned_at: datetime | None = Field(
default=None, description="Дата и время фактического возврата"
)
+5 -1
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей ролей"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
@@ -12,8 +13,11 @@ if TYPE_CHECKING:
class Role(RoleBase, table=True):
"""Модель роли в базе данных"""
__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)
+25 -10
View File
@@ -17,17 +17,32 @@ class User(UserBase, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True, index=True)
hashed_password: str = Field(nullable=False)
is_2fa_enabled: bool = Field(default=False)
totp_secret: str | None = Field(default=None, max_length=64)
recovery_code_hashes: str | None = Field(default=None, max_length=1500)
recovery_codes_generated_at: datetime | None = Field(default=None)
is_active: bool = Field(default=True)
is_verified: bool = Field(default=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
hashed_password: str = Field(nullable=False, description="Argon2id хэш пароля")
is_2fa_enabled: bool = Field(default=False, description="Включен TOTP 2FA")
totp_secret: str | None = Field(
default=None, max_length=80, description="Зашифрованный секрет TOTP"
)
recovery_code_hashes: str | None = Field(
default=None,
max_length=1500,
description="Argon2id хэши одноразовыхкодов восстановления",
)
recovery_codes_generated_at: datetime | None = Field(
default=None, description="Дата и время создания кодов восстановления"
)
is_active: bool = Field(default=True, description="Не является ли заблокированым")
is_verified: bool = Field(default=False, description="Является ли верифицированным")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="Дата и время создания",
)
updated_at: datetime | None = Field(
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)
+1 -3
View File
@@ -7,7 +7,7 @@ from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
from .token import Token, TokenData, PartialToken
from .token import TokenData
from .misc import (
AuthorWithBooks,
GenreWithBooks,
@@ -62,9 +62,7 @@ __all__ = [
"RoleUpdate",
"RoleRead",
"RoleList",
"Token",
"TokenData",
"PartialToken",
"TOTPSetupResponse",
"TOTPVerifyRequest",
"TOTPDisableRequest",
+14 -8
View File
@@ -1,35 +1,41 @@
"""Модуль DTO-моделей авторов"""
from typing import List
from pydantic import ConfigDict
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
class AuthorBase(SQLModel):
"""Базовая модель автора"""
name: str
model_config = ConfigDict( # pyright: ignore
json_schema_extra={"example": {"name": "author_name"}}
name: str = Field(description="Псевдоним")
model_config = ConfigDict(
json_schema_extra={"example": {"name": "John Doe"}}
)
class AuthorCreate(AuthorBase):
"""Модель автора для создания"""
pass
class AuthorUpdate(SQLModel):
"""Модель автора для обновления"""
name: str | None = None
name: str | None = Field(None, description="Псевдоним")
class AuthorRead(AuthorBase):
"""Модель автора для чтения"""
id: int
id: int = Field(description="Идентификатор")
class AuthorList(SQLModel):
"""Список авторов"""
authors: List[AuthorRead]
total: int
authors: List[AuthorRead] = Field(description="Список авторов")
total: int = Field(description="Количество авторов")
+24 -11
View File
@@ -1,43 +1,56 @@
"""Модуль DTO-моделей книг"""
from typing import List
from pydantic import ConfigDict
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
from library_service.models.enums import BookStatus
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
json_schema_extra={
"example": {"title": "book_title", "description": "book_description"}
"example": {
"title": "book_title",
"description": "book_description",
"page_count": 1,
}
}
)
class BookCreate(BookBase):
"""Модель книги для создания"""
pass
class BookUpdate(SQLModel):
"""Модель книги для обновления"""
title: str | None = None
description: str | None = None
status: BookStatus | None = None
title: str | None = Field(None, description="Название")
description: str | None = Field(None, description="Описание")
page_count: int | None = Field(None, description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
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):
"""Список книг"""
books: List[BookRead]
total: int
books: List[BookRead] = Field(description="Список книг")
total: int = Field(description="Количество книг")
+12 -6
View File
@@ -1,13 +1,15 @@
"""Модуль DTO-моделей жанров"""
from typing import List
from pydantic import ConfigDict
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
class GenreBase(SQLModel):
"""Базовая модель жанра"""
name: str
name: str = Field(description="Название")
model_config = ConfigDict( # pyright: ignore
json_schema_extra={"example": {"name": "genre_name"}}
@@ -16,20 +18,24 @@ class GenreBase(SQLModel):
class GenreCreate(GenreBase):
"""Модель жанра для создания"""
pass
class GenreUpdate(SQLModel):
"""Модель жанра для обновления"""
name: str | None = None
name: str | None = Field(None, description="Название")
class GenreRead(GenreBase):
"""Модель жанра для чтения"""
id: int
id: int = Field(description="Идентификатор")
class GenreList(SQLModel):
"""Списко жанров"""
genres: List[GenreRead]
total: int
genres: List[GenreRead] = Field(description="Список жанров")
total: int = Field(description="Количество жанров")
+24 -12
View File
@@ -1,37 +1,49 @@
"""Модуль DTO-моделей для выдачи книг"""
from typing import List
from datetime import datetime
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
class LoanBase(SQLModel):
"""Базовая модель выдачи"""
book_id: int
user_id: int
due_date: datetime
book_id: int = Field(description="Идентификатор книги")
user_id: int = Field(description="Идентификатор пользователя")
due_date: datetime = Field(description="Дата и время планируемого возврата")
class LoanCreate(LoanBase):
"""Модель для создания записи о выдаче"""
pass
class LoanUpdate(SQLModel):
"""Модель для обновления записи о выдаче"""
user_id: int | None = None
due_date: datetime | None = None
returned_at: datetime | None = None
user_id: int | None = Field(None, description="Идентификатор пользователя")
due_date: datetime | None = Field(
None, description="дата и время планируемого возврата"
)
returned_at: datetime | None = Field(
None, description="Дата и время фактического возврата"
)
class LoanRead(LoanBase):
"""Модель чтения записи о выдаче"""
id: int
borrowed_at: datetime
returned_at: datetime | None = None
id: int = Field(description="Идентификатор")
borrowed_at: datetime = Field(description="Дата и время выдачи")
returned_at: datetime | None = Field(
None, description="Дата и время фактического возврата"
)
class LoanList(SQLModel):
"""Список выдач"""
loans: List[LoanRead]
total: int
loans: List[LoanRead] = Field(description="Список выдач")
total: int = Field(description="Количество выдач")
+67 -49
View File
@@ -18,127 +18,145 @@ from .recovery import RecoveryCodesResponse
class AuthorWithBooks(SQLModel):
"""Модель автора с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
name: str = Field(description="Псевдоним")
books: List[BookRead] = Field(default_factory=list, description="Список книг")
class GenreWithBooks(SQLModel):
"""Модель жанра с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
name: str = Field(description="Название")
books: List[BookRead] = Field(default_factory=list, description="Список книг")
class BookWithAuthors(SQLModel):
"""Модель книги с авторами"""
id: int
title: str
description: str
authors: List[AuthorRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
class BookWithGenres(SQLModel):
"""Модель книги с жанрами"""
id: int
title: str
description: str
status: BookStatus | None = None
genres: List[GenreRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
class BookWithAuthorsAndGenres(SQLModel):
"""Модель с авторами и жанрами"""
id: int
title: str
description: str
status: BookStatus | None = None
authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
class BookFilteredList(SQLModel):
"""Список книг с фильтрацией"""
books: List[BookWithAuthorsAndGenres]
total: int
books: List[BookWithAuthorsAndGenres] = Field(
description="Список отфильтрованных книг"
)
total: int = Field(description="Количество книг")
class LoanWithBook(LoanRead):
"""Модель выдачи, включающая данные о книге"""
book: BookRead
book: BookRead = Field(description="Книга")
class BookStatusUpdate(SQLModel):
"""Модель для ручного изменения статуса библиотекарем"""
status: str
status: str = Field(description="Статус книги")
class UserCreateByAdmin(UserCreate):
"""Создание пользователя администратором"""
is_active: bool = True
roles: list[str] | None = None
is_active: bool = Field(True, description="Не является ли заблокированным")
roles: list[str] | None = Field(None, description="Роли")
class UserUpdateByAdmin(UserUpdate):
"""Обновление пользователя администратором"""
is_active: bool | None = None
roles: list[str] | None = None
is_active: bool = Field(True, description="Не является ли заблокированным")
roles: list[str] | None = Field(None, description="Роли")
class LoginResponse(SQLModel):
"""Модель для авторизации пользователя"""
access_token: str | None = None
partial_token: str | None = None
refresh_token: str | None = None
token_type: str = "bearer"
requires_2fa: bool = False
access_token: str | None = Field(None, description="Токен доступа")
partial_token: str | None = Field(None, description="Частичный токен")
refresh_token: str | None = Field(None, description="Токен обновления")
token_type: str = Field("bearer", description="Тип токена")
requires_2fa: bool = Field(False, description="Требуется ли TOTP=код")
class RegisterResponse(SQLModel):
"""Модель для регистрации пользователя"""
user: UserRead
recovery_codes: RecoveryCodesResponse
user: UserRead = Field(description="Пользователь")
recovery_codes: RecoveryCodesResponse = Field(description="Коды восстановления")
class PasswordResetResponse(SQLModel):
"""Модель для сброса пароля"""
total: int
remaining: int
used_codes: list[bool]
generated_at: datetime | None
should_regenerate: bool
total: int = Field(description="Общее количество кодов")
remaining: int = Field(description="Количество оставшихся кодов")
used_codes: list[bool] = Field(description="Количество использованых кодов")
generated_at: datetime | None = Field(description="Дата и время генерации")
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
class TOTPSetupResponse(SQLModel):
"""Модель для генерации данных для настройки TOTP"""
secret: str
username: str
issuer: str
size: int
padding: int
bitmap_b64: str
secret: str = Field(description="Секрет TOTP")
username: str = Field(description="Имя пользователя")
issuer: str = Field(description="Запрашивающий сервис")
size: int = Field(description="Размер кода")
padding: int = Field(description="Отступ")
bitmap_b64: str = Field(description="QR-код")
class TOTPVerifyRequest(SQLModel):
"""Модель для проверки TOTP кода"""
code: str = Field(min_length=6, max_length=6, regex=r"^\d{6}$")
code: str = Field(
min_length=6,
max_length=6,
regex=r"^\d{6}$",
description="Шестизначный TOTP-код",
)
class TOTPDisableRequest(SQLModel):
"""Модель для отключения TOTP 2FA"""
password: str
password: str = Field(description="Пароль")
+12 -10
View File
@@ -10,26 +10,28 @@ from sqlmodel import SQLModel, Field
class RecoveryCodesResponse(SQLModel):
"""Ответ при генерации резервных кодов"""
codes: list[str]
generated_at: datetime
codes: list[str] = Field(description="Список кодов восстановления")
generated_at: datetime = Field(description="Дата и время генерации")
class RecoveryCodesStatus(SQLModel):
"""Статус резервных кодов пользователя"""
total: int
remaining: int
used_codes: list[bool]
generated_at: datetime | None
should_regenerate: bool
total: int = Field(description="Общее количество кодов")
remaining: int = Field(description="Количество оставшихся кодов")
used_codes: list[bool] = Field(description="Количество использованых кодов")
generated_at: datetime | None = Field(description="Дата и время генерации")
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
class RecoveryCodeUse(SQLModel):
"""Запрос на сброс пароля через резервный код"""
username: str
recovery_code: str = Field(min_length=19, max_length=19)
new_password: str = Field(min_length=8, max_length=100)
username: str = Field(description="Имя пользователя")
recovery_code: str = Field(
min_length=19, max_length=19, description="Код восстановления"
)
new_password: str = Field(min_length=8, max_length=100, description="Новый пароль")
@field_validator("recovery_code")
@classmethod
+25 -8
View File
@@ -1,32 +1,49 @@
"""Модуль DTO-моделей ролей"""
from typing import List
from sqlmodel import SQLModel
from pydantic import ConfigDict
from sqlmodel import SQLModel, Field
class RoleBase(SQLModel):
"""Базовая модель роли"""
name: str
description: str | None = None
payroll: int = 0
name: str = Field(description="Название")
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):
"""Модель роли для создания"""
pass
class RoleUpdate(SQLModel):
"""Модель роли для обновления"""
name: str | None = None
name: str | None = Field(None, description="Название")
class RoleRead(RoleBase):
"""Модель роли для чтения"""
id: int
id: int = Field(description="Идентификатор")
class RoleList(SQLModel):
"""Список ролей"""
roles: List[RoleRead]
total: int
roles: List[RoleRead] = Field(description="Список ролей")
total: int = Field(description="Количество ролей")
+5 -21
View File
@@ -1,27 +1,11 @@
"""Модуль DTO-моделей токенов"""
"""Модуль DTO-модели токена"""
from sqlmodel import SQLModel
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
from sqlmodel import SQLModel, Field
class TokenData(SQLModel):
"""Модель содержимого токена"""
username: str | None = None
user_id: int | None = None
is_partial: bool = False
username: str | None = Field(None, description="Имя пользователя")
user_id: int | None = Field(None, description="Идентификатор пользователя")
is_partial: bool = Field(False, description="Является ли токен частичным")
+23 -15
View File
@@ -10,9 +10,17 @@ from sqlmodel import Field, SQLModel
class UserBase(SQLModel):
"""Базовая модель пользователя"""
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
email: EmailStr = Field(index=True, unique=True)
full_name: str | None = Field(default=None, max_length=100)
username: str = Field(
min_length=3,
max_length=50,
index=True,
unique=True,
description="Имя пользователя",
)
email: EmailStr = Field(index=True, unique=True, description="Email")
full_name: str | None = Field(
default=None, max_length=100, description="Полное имя"
)
model_config = ConfigDict(
json_schema_extra={
@@ -28,7 +36,7 @@ class UserBase(SQLModel):
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")
@classmethod
@@ -46,30 +54,30 @@ class UserCreate(UserBase):
class UserLogin(SQLModel):
"""Модель аутентификации для пользователя"""
username: str
password: str
username: str = Field(description="Имя пользователя")
password: str = Field(description="Пароль")
class UserRead(UserBase):
"""Модель пользователя для чтения"""
id: int
is_active: bool
is_verified: bool
is_2fa_enabled: bool
roles: List[str] = []
is_active: bool = Field(description="Не является ли заблокированым")
is_verified: bool = Field(description="Является ли верифицированым")
is_2fa_enabled: bool = Field(description="Включен ли TOTP 2FA")
roles: List[str] = Field([], description="Роли")
class UserUpdate(SQLModel):
"""Модель пользователя для обновления"""
email: EmailStr | None = None
full_name: str | None = None
password: str | None = None
email: EmailStr | None = Field(None, description="Email")
full_name: str | None = Field(None, description="Полное имя")
password: str | None = Field(None, description="Пароль")
class UserList(SQLModel):
"""Список пользователей"""
users: List[UserRead]
total: int
users: List[UserRead] = Field(description="Список пользователей")
total: int = Field(description="Количество пользователей")
+2
View File
@@ -8,6 +8,7 @@ from .books import router as books_router
from .genres import router as genres_router
from .loans import router as loans_router
from .relationships import router as relationships_router
from .cap import router as cap_router
from .users import router as users_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(genres_router, prefix="/api")
api_router.include_router(loans_router, prefix="/api")
api_router.include_router(cap_router, prefix="/api")
api_router.include_router(users_router, prefix="/api")
api_router.include_router(relationships_router, prefix="/api")
+58 -26
View File
@@ -1,5 +1,7 @@
"""Модуль работы с авторизацией и аутентификацией пользователей"""
import base64
from datetime import timedelta
from typing import Annotated
@@ -7,17 +9,15 @@ from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from library_service.services import require_captcha
from library_service.models.db import Role, User
from library_service.models.dto import (
Token,
UserCreate,
UserRead,
UserUpdate,
UserList,
RoleRead,
RoleList,
Token,
PartialToken,
LoginResponse,
RecoveryCodeUse,
RegisterResponse,
@@ -50,6 +50,7 @@ from library_service.auth import (
create_partial_token,
RequirePartialAuth,
verify_and_use_code,
cipher,
)
@@ -63,7 +64,11 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
summary="Регистрация нового пользователя",
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(
select(User).where(User.username == user_data.username)
@@ -139,11 +144,14 @@ def login(
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return LoginResponse(
access_token=create_access_token(
new_access_token = create_access_token(
data=token_data, expires_delta=access_token_expires
),
refresh_token=create_refresh_token(data=token_data),
)
new_refresh_token = create_refresh_token(data=token_data)
return LoginResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
requires_2fa=False,
)
@@ -151,7 +159,7 @@ def login(
@router.post(
"/refresh",
response_model=Token,
response_model=LoginResponse,
summary="Обновление токена",
description="Получение новой пары токенов, используя действующий Refresh токен",
)
@@ -182,19 +190,18 @@ def refresh_token(
detail="User is inactive",
)
token_data = {"sub": user.username, "user_id": user.id}
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
new_access_token = create_access_token(
data={"sub": user.username, "user_id": user.id},
expires_delta=access_token_expires,
)
new_refresh_token = create_refresh_token(
data={"sub": user.username, "user_id": user.id}
data=token_data, expires_delta=access_token_expires
)
new_refresh_token = create_refresh_token(data=token_data)
return Token(
return LoginResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
requires_2fa=False,
)
@@ -245,9 +252,19 @@ def update_user_me(
summary="Создание QR-кода TOTP 2FA",
description="Генерирует секрет и QR-код для настройки TOTP",
)
def get_totp_qr_bitmap(auth: RequireAuth):
def get_totp_qr_bitmap(
current_user: RequireAuth,
session: Session = Depends(get_session),
):
"""Возвращает данные для настройки 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(
@@ -268,13 +285,23 @@ def enable_2fa(
detail="2FA already enabled",
)
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Secret key not generated"
)
if not verify_totp_code(secret, data.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid TOTP code",
)
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
session.add(current_user)
session.commit()
@@ -315,7 +342,7 @@ def disable_2fa(
@router.post(
"/2fa/verify",
response_model=Token,
response_model=LoginResponse,
summary="Верификация 2FA",
description="Завершает аутентификацию с помощью TOTP кода или резервного кода",
)
@@ -334,23 +361,28 @@ def verify_2fa(
verified = False
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
if not verified:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code",
)
token_data = {"sub": user.username, "user_id": user.id}
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return Token(
access_token=create_access_token(
new_access_token = create_access_token(
data=token_data, expires_delta=access_token_expires
),
refresh_token=create_refresh_token(data=token_data),
)
new_refresh_token = create_refresh_token(data=token_data)
return LoginResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
requires_2fa=False,
)
+19 -6
View File
@@ -1,12 +1,19 @@
"""Модуль работы с авторами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select
from library_service.auth import RequireStaff
from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import (BookRead, AuthorWithBooks,
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
from library_service.models.dto import (
BookRead,
AuthorWithBooks,
AuthorCreate,
AuthorList,
AuthorRead,
AuthorUpdate,
)
router = APIRouter(prefix="/authors", tags=["authors"])
@@ -59,7 +66,9 @@ def get_author(
"""Возвращает информацию об авторе и его книгах"""
author = session.get(Author, author_id)
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(
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
@@ -88,7 +97,9 @@ def update_author(
"""Обновляет информацию об авторе"""
db_author = session.get(Author, author_id)
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)
for field, value in update_data.items():
@@ -113,7 +124,9 @@ def delete_author(
"""Удаляет автора из системы"""
author = session.get(Author, author_id)
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())
session.delete(author)
+190 -56
View File
@@ -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 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 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.db import (
Author,
@@ -32,75 +43,80 @@ from library_service.models.dto.misc import (
router = APIRouter(prefix="/books", tags=["books"])
ollama_client = Client(host=OLLAMA_URL)
def close_active_loan(session: Session, book_id: int) -> None:
"""Закрывает активную выдачу книги при изменении статуса"""
active_loan = session.exec(
select(BookUserLink)
.where(BookUserLink.book_id == book_id)
.where(BookUserLink.returned_at == None) # noqa: E711
).first()
.where(BookUserLink.book_id == book_id) # ty: ignore
.where(BookUserLink.returned_at == None) # ty: ignore
).first() # ty: ignore
if active_loan:
active_loan.returned_at = datetime.now(timezone.utc)
session.add(active_loan)
@router.get(
"/filter",
response_model=BookFilteredList,
summary="Фильтрация книг",
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией",
)
from sqlalchemy import select, func, distinct, case, exists
from sqlalchemy.orm import selectinload
@router.get("/filter", response_model=BookFilteredList)
def filter_books(
session: Session = Depends(get_session),
q: str | None = Query(None, max_length=50, description="Поиск"),
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
page: int = Query(1, gt=0, description="Номер страницы"),
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
min_page_count: int | None = Query(None, ge=0),
max_page_count: int | None = Query(None, ge=0),
author_ids: List[Annotated[int, Field(gt=0)]] | None = Query(None),
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).distinct()
if q:
statement = statement.where(
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
statement = select(Book).options(
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) # ty: ignore
)
if min_page_count:
statement = statement.where(Book.page_count >= min_page_count) # ty: ignore
if max_page_count:
statement = statement.where(Book.page_count <= max_page_count) # ty: ignore
if author_ids:
statement = statement.join(AuthorBookLink).where(
AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
author_ids
statement = statement.where(
exists().where(
AuthorBookLink.book_id == Book.id, # ty: ignore
AuthorBookLink.author_id.in_(author_ids), # ty: ignore
)
)
if genre_ids:
statement = statement.join(GenreBookLink).where(
GenreBookLink.genre_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
genre_ids
for genre_id in genre_ids:
statement = statement.where(
exists().where(
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id # ty: ignore
)
)
total_statement = select(func.count()).select_from(statement.subquery())
total = session.exec(total_statement).one()
count_statement = select(func.count()).select_from(statement.subquery())
total = session.scalar(count_statement)
if q:
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"]
distance_col = Book.embedding.cosine_distance(emb) # ty: ignore
statement = statement.where(Book.embedding.is_not(None)) # ty: ignore
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1) # ty: ignore
statement = statement.order_by(keyword_match, distance_col)
else:
statement = statement.order_by(Book.id) # ty: ignore
offset = (page - 1) * size
statement = statement.offset(offset).limit(size)
results = session.exec(statement).all()
results = session.scalars(statement).unique().all()
books_with_data = []
for db_book in results:
books_with_data.append(
BookWithAuthorsAndGenres(
**db_book.model_dump(),
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
genres=[GenreRead(**g.model_dump()) for g in db_book.genres],
)
)
return BookFilteredList(books=books_with_data, total=total)
return BookFilteredList(books=results, total=total)
@router.post(
@@ -115,11 +131,21 @@ def create_book(
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.commit()
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(
@@ -130,9 +156,21 @@ def create_book(
)
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(
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)
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(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
authors = session.scalars(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) # ty: ignore
).all()
author_reads = [AuthorRead(**author.model_dump()) for author in authors]
genres = session.exec(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
genres = session.scalars(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) # ty: ignore
).all()
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["genres"] = genre_reads
@@ -172,7 +217,7 @@ def get_book(
@router.put(
"/{book_id}",
response_model=Book,
response_model=BookRead,
summary="Обновить информацию о книге",
description="Обновляет информацию о книге в системе",
)
@@ -185,12 +230,14 @@ def update_book(
"""Обновляет информацию о книге"""
db_book = session.get(Book, book_id)
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 == BookStatus.BORROWED:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Статус 'borrowed' устанавливается только через выдачу книги",
)
@@ -205,11 +252,29 @@ def update_book(
if book_update.description is not None:
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.commit()
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(
@@ -226,13 +291,82 @@ def delete_book(
"""Удаляет книгу из системы"""
book = session.get(Book, book_id)
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(
id=(book.id or 0),
title=book.title,
description=book.description,
page_count=book.page_count,
status=book.status,
)
session.delete(book)
session.commit()
return book_read
@router.post("/{book_id}/preview")
async def upload_book_preview(
current_user: RequireStaff,
file: UploadFile = File(...),
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
if not (file.content_type or "").startswith("image/"):
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Image required")
if (file.size or 0) > 32 * 1024 * 1024:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB")
file_uuid= uuid4()
tmp_path = BOOKS_PREVIEW_DIR / f"{file_uuid}.upload"
with open(tmp_path, "wb") as f:
shutil.copyfileobj(file.file, f)
book = session.get(Book, book_id)
if not book:
tmp_path.unlink()
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
transcode_image(tmp_path)
tmp_path.unlink()
if book.preview_id:
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
if path.exists():
path.unlink(missing_ok=True)
book.preview_id = file_uuid
session.add(book)
session.commit()
return {
"preview": {
"png": f"/static/books/{file_uuid}.png",
"jpeg": f"/static/books/{file_uuid}.jpg",
"webp": f"/static/books/{file_uuid}.webp",
}
}
@router.delete("/{book_id}/preview")
async def remove_book_preview(
current_user: RequireStaff,
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
if book.preview_id:
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
if path.exists():
path.unlink(missing_ok=True)
book.preview_id = None
session.add(book)
session.commit()
return {"preview_urls": []}
+103
View File
@@ -0,0 +1,103 @@
import asyncio
import hashlib
import secrets
from fastapi import APIRouter, Request, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from library_service.services.captcha import (
limiter,
get_ip,
active_challenges,
challenges_by_ip,
MAX_CHALLENGES_PER_IP,
MAX_TOTAL_CHALLENGES,
CHALLENGE_TTL,
REDEEM_TTL,
prng,
now_ms,
redeem_tokens,
)
router = APIRouter(prefix="/cap", tags=["captcha"])
@router.post("/challenge", summary="Задача capjs")
@limiter.limit("15/minute")
async def challenge(request: Request, ip: str = Depends(get_ip)):
"""Возвращает задачу capjs"""
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges"
)
if len(active_challenges) >= MAX_TOTAL_CHALLENGES:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Server busy"
)
token = secrets.token_hex(25)
redeem = secrets.token_hex(25)
expires = now_ms() + CHALLENGE_TTL
active_challenges[token] = {
"c": 50,
"s": 32,
"d": 4,
"expires": expires,
"redeem_token": redeem,
"ip": ip,
}
challenges_by_ip[ip] += 1
return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires}
@router.post("/redeem", summary="Проверка задачи")
@limiter.limit("30/minute")
async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
"""Возвращает capjs_token"""
token = payload.get("token")
solutions = payload.get("solutions", [])
if token not in active_challenges:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid challenge"
)
ch = active_challenges.pop(token)
challenges_by_ip[ch["ip"]] -= 1
if now_ms() > ch["expires"]:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Expired")
if len(solutions) < ch["c"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Bad solutions"
)
def verify(i: int) -> bool:
salt = prng(f"{token}{i+1}", ch["s"])
target = prng(f"{token}{i+1}d", ch["d"])
h = hashlib.sha256((salt + str(solutions[i])).encode()).hexdigest()
return h.startswith(target)
results = await asyncio.gather(
*(asyncio.to_thread(verify, i) for i in range(ch["c"]))
)
if not all(results):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid solution"
)
r_token = ch["redeem_token"]
redeem_tokens[r_token] = now_ms() + REDEEM_TTL
resp = JSONResponse(
{"success": True, "token": r_token, "expires": redeem_tokens[r_token]}
)
resp.set_cookie(
key="capjs_token",
value=r_token,
httponly=True,
samesite="lax",
max_age=REDEEM_TTL // 1000,
)
return resp
+19 -5
View File
@@ -1,10 +1,18 @@
"""Модуль работы с жанрами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select
from library_service.auth import RequireStaff
from library_service.models.db import Book, Genre, GenreBookLink
from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks
from library_service.models.dto import (
BookRead,
GenreCreate,
GenreList,
GenreRead,
GenreUpdate,
GenreWithBooks,
)
from library_service.settings import get_session
@@ -57,7 +65,9 @@ def get_genre(
"""Возвращает информацию о жанре и книгах с ним"""
genre = session.get(Genre, genre_id)
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(
select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
@@ -86,7 +96,9 @@ def update_genre(
"""Обновляет информацию о жанре"""
db_genre = session.get(Genre, genre_id)
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)
for field, value in update_data.items():
@@ -111,7 +123,9 @@ def delete_genre(
"""Удаляет жанр из системы"""
genre = session.get(Genre, genre_id)
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())
session.delete(genre)
+51 -19
View File
@@ -40,17 +40,21 @@ def create_loan(
book = session.get(Book, loan.book_id)
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:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Book is not available for loan (status: {book.status})",
)
target_user = session.get(User, loan.user_id)
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan = BookUserLink(
book_id=loan.book_id,
@@ -248,7 +252,9 @@ def get_loan(
loan = session.get(BookUserLink, loan_id)
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)
@@ -275,7 +281,9 @@ def update_loan(
"""Обновляет информацию о выдаче"""
db_loan = session.get(BookUserLink, loan_id)
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)
@@ -287,7 +295,9 @@ def update_loan(
book = session.get(Book, db_loan.book_id)
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 not is_staff:
@@ -297,7 +307,9 @@ def update_loan(
)
new_user = session.get(User, loan_update.user_id)
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
if loan_update.due_date is not None:
@@ -305,7 +317,10 @@ def update_loan(
if loan_update.returned_at is not None:
if db_loan.returned_at is not None:
raise HTTPException(status_code=400, detail="Loan is already returned")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Loan is already returned",
)
db_loan.returned_at = loan_update.returned_at
book.status = BookStatus.ACTIVE
@@ -331,18 +346,24 @@ def confirm_loan(
"""Подтверждает бронирование и меняет статус книги на BORROWED"""
loan = session.get(BookUserLink, loan_id)
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:
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)
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]:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot confirm loan for book with status: {book.status}",
)
@@ -370,10 +391,14 @@ def return_loan(
"""Возвращает книгу и закрывает выдачу"""
loan = session.get(BookUserLink, loan_id)
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:
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)
@@ -403,7 +428,9 @@ def delete_loan(
"""Удаляет выдачу или бронирование (только для RESERVED статуса)"""
loan = session.get(BookUserLink, loan_id)
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)
@@ -417,7 +444,7 @@ def delete_loan(
if book and book.status != BookStatus.RESERVED:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
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)
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:
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)
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(
book_id=loan.book_id,
+65 -42
View File
@@ -1,4 +1,6 @@
"""Модуль прочих эндпоинтов"""
"""Модуль прочих эндпоинтов и веб-страниц"""
import os
import sys
from datetime import datetime
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.models.db import Author, Book, Genre, User
from library_service.services import SchemaGenerator
from library_service import models
router = APIRouter(tags=["misc"])
generator = SchemaGenerator(models.db, models.dto)
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
@@ -28,115 +33,117 @@ def get_info(app) -> Dict:
"description": app.description.rsplit("|", 1)[0],
},
"server_time": datetime.now().isoformat(),
"domain": os.getenv("DOMAIN", ""),
}
@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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
async def analytics(request: Request):
async def analytics(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу аналитики выдач"""
return templates.TemplateResponse(request, "analytics.html")
@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))
return templates.TemplateResponse(request, "analytics.html", get_info(app) | {"request": request, "title": "LiB - Аналитика"})
@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(
"/api/info",
summary="Информация о сервисе",
@@ -163,12 +176,22 @@ async def api_info(app=Depends(lambda: get_app())):
return JSONResponse(content=get_info(app))
@router.get(
"/api/schema",
summary="Информация о таблицах и связях",
description="Возвращает схему базы данных с описаниями полей",
)
async def api_schema():
"""Возвращает информацию для создания er-диаграммы"""
return generator.generate()
@router.get(
"/api/stats",
summary="Статистика сервиса",
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)
books = select(func.count()).select_from(Book)
+81 -27
View File
@@ -1,7 +1,8 @@
"""Модуль работы со связями"""
from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
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)
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
@@ -30,7 +33,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
).first()
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})
session.add(link)
@@ -48,7 +51,9 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
).first()
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.commit()
@@ -64,13 +69,14 @@ def get_related(
link_model,
link_main_field,
link_related_field,
read_model
):
read_model,
):
"""Возвращает список связанных сущностей"""
check_entity_exists(session, main_model, main_id, main_name)
related = session.exec(
select(related_model).join(link_model)
select(related_model)
.join(link_model)
.where(getattr(link_model, link_main_field) == main_id)
).all()
@@ -93,8 +99,15 @@ def add_author_to_book(
check_entity_exists(session, Author, author_id, "Author")
check_entity_exists(session, Book, book_id, "Book")
return add_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id", "Relationship already exists")
return add_relationship(
session,
AuthorBookLink,
author_id,
"author_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete(
@@ -110,8 +123,9 @@ def remove_author_from_book(
session: Session = Depends(get_session),
):
"""Удаляет связь между автором и книгой"""
return remove_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id")
return remove_relationship(
session, AuthorBookLink, author_id, "author_id", book_id, "book_id"
)
@router.get(
@@ -122,9 +136,17 @@ def remove_author_from_book(
)
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
"""Возвращает список книг автора"""
return get_related(session,
Author, author_id, "Author", Book,
AuthorBookLink, "author_id", "book_id", BookRead)
return get_related(
session,
Author,
author_id,
"Author",
Book,
AuthorBookLink,
"author_id",
"book_id",
BookRead,
)
@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)):
"""Возвращает список авторов книги"""
return get_related(session,
Book, book_id, "Book", Author,
AuthorBookLink, "book_id", "author_id", AuthorRead)
return get_related(
session,
Book,
book_id,
"Book",
Author,
AuthorBookLink,
"book_id",
"author_id",
AuthorRead,
)
@router.post(
@@ -156,8 +186,15 @@ def add_genre_to_book(
check_entity_exists(session, Genre, genre_id, "Genre")
check_entity_exists(session, Book, book_id, "Book")
return add_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
return add_relationship(
session,
GenreBookLink,
genre_id,
"genre_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete(
@@ -173,8 +210,9 @@ def remove_genre_from_book(
session: Session = Depends(get_session),
):
"""Удаляет связь между жанром и книгой"""
return remove_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id")
return remove_relationship(
session, GenreBookLink, genre_id, "genre_id", book_id, "book_id"
)
@router.get(
@@ -185,9 +223,17 @@ def remove_genre_from_book(
)
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
"""Возвращает список книг в жанре"""
return get_related(session,
Genre, genre_id, "Genre", Book,
GenreBookLink, "genre_id", "book_id", BookRead)
return get_related(
session,
Genre,
genre_id,
"Genre",
Book,
GenreBookLink,
"genre_id",
"book_id",
BookRead,
)
@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)):
"""Возвращает список жанров книги"""
return get_related(session,
Book, book_id, "Book", Genre,
GenreBookLink, "book_id", "genre_id", GenreRead)
return get_related(
session,
Book,
book_id,
"Book",
Genre,
GenreBookLink,
"book_id",
"genre_id",
GenreRead,
)
+33
View File
@@ -0,0 +1,33 @@
from .captcha import (
limiter,
cleanup_task,
get_ip,
require_captcha,
active_challenges,
redeem_tokens,
challenges_by_ip,
MAX_CHALLENGES_PER_IP,
MAX_TOTAL_CHALLENGES,
CHALLENGE_TTL,
REDEEM_TTL,
prng,
)
from .describe_er import SchemaGenerator
from .image_processing import transcode_image
__all__ = [
"limiter",
"cleanup_task",
"get_ip",
"require_captcha",
"active_challenges",
"redeem_tokens",
"challenges_by_ip",
"MAX_CHALLENGES_PER_IP",
"MAX_TOTAL_CHALLENGES",
"CHALLENGE_TTL",
"REDEEM_TTL",
"prng",
"SchemaGenerator",
"transcode_image",
]
+77
View File
@@ -0,0 +1,77 @@
"""Модуль создания и проверки capjs"""
import os
import asyncio
import hashlib
import secrets
import time
from collections import defaultdict
from fastapi import Request, HTTPException, Depends, status
from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
CLEANUP_INTERVAL = int(os.getenv("CAP_CLEANUP_INTERVAL", "10"))
REDEEM_TTL = int(os.getenv("CAP_REDEEM_TTL_SECONDS", "180")) * 1000
CHALLENGE_TTL = int(os.getenv("CAP_CHALLENGE_TTL_SECONDS", "120")) * 1000
MAX_CHALLENGES_PER_IP = int(os.getenv("CAP_MAX_CHALLENGES_PER_IP", "12"))
MAX_TOTAL_CHALLENGES = int(os.getenv("CAP_MAX_TOTAL_CHALLENGES", "1000"))
active_challenges: dict[str, dict] = {}
redeem_tokens: dict[str, int] = {}
challenges_by_ip: defaultdict[str, int] = defaultdict(int)
limiter = Limiter(key_func=get_remote_address)
def now_ms() -> int:
return int(time.time() * 1000)
def fnv1a_utf16(seed: str) -> int:
h = 2166136261
data = seed.encode("utf-16le")
i = 0
while i < len(data):
unit = data[i] + (data[i + 1] << 8)
h ^= unit
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & 0xFFFFFFFF
i += 2
return h
def prng(seed: str, length: int) -> str:
state = fnv1a_utf16(seed)
out = ""
while len(out) < length:
state ^= (state << 13) & 0xFFFFFFFF
state ^= state >> 17
state ^= (state << 5) & 0xFFFFFFFF
out += f"{state & 0xFFFFFFFF:08x}"
return out[:length]
async def cleanup_task():
while True:
now = now_ms()
for token, data in list(active_challenges.items()):
if data["expires"] < now:
challenges_by_ip[data["ip"]] -= 1
del active_challenges[token]
for token, exp in list(redeem_tokens.items()):
if exp < now:
del redeem_tokens[token]
await asyncio.sleep(CLEANUP_INTERVAL)
def get_ip(request: Request) -> str:
return get_remote_address(request)
async def require_captcha(request: Request):
token = request.cookies.get("capjs_token")
if not token or token not in redeem_tokens or redeem_tokens[token] < now_ms():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail={"error": "captcha_required"}
)
del redeem_tokens[token]
+283
View File
@@ -0,0 +1,283 @@
"""Модуль генерации описания схемы БД"""
import enum
import inspect
from typing import (
List,
Dict,
Any,
Set,
Type,
Tuple,
Optional,
Union,
get_origin,
get_args,
)
from sqlalchemy import Enum as SAEnum
from sqlalchemy.inspection import inspect as sa_inspect
from sqlmodel import SQLModel
class SchemaGenerator:
"""Сервис генерации json описания схемы БД"""
def __init__(self, db_module, dto_module=None):
self.db_models = self._get_classes(db_module, is_table=True)
self.dto_models = (
self._get_classes(dto_module, is_table=False) if dto_module else []
)
self.link_table_names = self._identify_link_tables()
self.field_descriptions = self._collect_all_descriptions()
self._table_to_model = {m.__tablename__: m for m in self.db_models}
def _get_classes(
self, module, is_table: bool | None = None
) -> List[Type[SQLModel]]:
if module is None:
return []
classes = []
for name, obj in inspect.getmembers(module):
if (
inspect.isclass(obj)
and issubclass(obj, SQLModel)
and obj is not SQLModel
):
if is_table is True and hasattr(obj, "__table__"):
classes.append(obj)
elif is_table is False and not hasattr(obj, "__table__"):
classes.append(obj)
return classes
def _normalize_model_name(self, name: str) -> str:
suffixes = [
"Create",
"Read",
"Update",
"DTO",
"Base",
"List",
"Detail",
"Response",
"Request",
]
result = name
for suffix in suffixes:
if result.endswith(suffix) and len(result) > len(suffix):
result = result[: -len(suffix)]
return result
def _get_field_descriptions_from_class(self, cls: Type) -> Dict[str, str]:
descriptions = {}
for parent in cls.__mro__:
if parent is SQLModel or parent is object:
continue
fields = getattr(parent, "model_fields", {})
for field_name, field_info in fields.items():
if field_name in descriptions:
continue
desc = getattr(field_info, "description", None) or getattr(
field_info, "title", None
)
if desc:
descriptions[field_name] = desc
return descriptions
def _collect_all_descriptions(self) -> Dict[str, Dict[str, str]]:
result = {}
dto_map = {}
for dto in self.dto_models:
base_name = self._normalize_model_name(dto.__name__)
if base_name not in dto_map:
dto_map[base_name] = {}
for field, desc in self._get_field_descriptions_from_class(dto).items():
if field not in dto_map[base_name]:
dto_map[base_name][field] = desc
for model in self.db_models:
model_name = model.__name__
result[model_name] = {
**dto_map.get(model_name, {}),
**self._get_field_descriptions_from_class(model),
}
return result
def _identify_link_tables(self) -> Set[str]:
link_tables = set()
for model in self.db_models:
try:
for rel in sa_inspect(model).relationships:
if rel.secondary is not None:
link_tables.add(rel.secondary.name)
except Exception:
continue
return link_tables
def _collect_fk_relations(self) -> List[Dict[str, Any]]:
relations = []
processed: Set[Tuple[str, str, str, str]] = set()
for model in self.db_models:
if model.__tablename__ in self.link_table_names:
continue
for col in sa_inspect(model).columns:
for fk in col.foreign_keys:
target_table = fk.column.table.name
if target_table in self.link_table_names:
continue
target_model = self._table_to_model.get(target_table)
if not target_model:
continue
key = (
model.__name__,
col.name,
target_model.__name__,
fk.column.name,
)
if key not in processed:
relations.append(
{
"fromEntity": model.__name__,
"fromField": col.name,
"toEntity": target_model.__name__,
"toField": fk.column.name,
"fromMultiplicity": "N",
"toMultiplicity": "1",
}
)
processed.add(key)
return relations
def _collect_m2m_relations(self) -> List[Dict[str, Any]]:
relations = []
processed: Set[Tuple[str, str]] = set()
for model in self.db_models:
if model.__tablename__ in self.link_table_names:
continue
try:
for rel in sa_inspect(model).relationships:
if rel.direction.name != "MANYTOMANY":
continue
target_model = rel.mapper.class_
if target_model.__tablename__ in self.link_table_names:
continue
pair = tuple(sorted([model.__name__, target_model.__name__]))
if pair not in processed:
relations.append(
{
"fromEntity": pair[0],
"fromField": "id",
"toEntity": pair[1],
"toField": "id",
"fromMultiplicity": "N",
"toMultiplicity": "N",
}
)
processed.add(pair)
except Exception:
continue
return relations
def _extract_enum_from_annotation(self, annotation) -> Optional[Type[enum.Enum]]:
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
return annotation
origin = get_origin(annotation)
if origin is Union:
for arg in get_args(annotation):
if isinstance(arg, type) and issubclass(arg, enum.Enum):
return arg
return None
def _get_enum_values(self, model: Type[SQLModel], col) -> Optional[List[str]]:
if isinstance(col.type, SAEnum):
if col.type.enum_class is not None:
return [e.value for e in col.type.enum_class]
if col.type.enums:
return list(col.type.enums)
try:
annotations = {}
for cls in model.__mro__:
if hasattr(cls, "__annotations__"):
annotations.update(cls.__annotations__)
if col.name in annotations:
annotation = annotations[col.name]
enum_class = self._extract_enum_from_annotation(annotation)
if enum_class:
return [e.value for e in enum_class]
except Exception:
pass
return None
def generate(self) -> Dict[str, Any]:
entities = []
for model in self.db_models:
table_name = model.__tablename__
if table_name in self.link_table_names:
continue
columns = sorted(
sa_inspect(model).columns,
key=lambda c: (
0 if c.primary_key else (1 if c.foreign_keys else 2),
c.name,
),
)
entity_fields = []
descriptions = self.field_descriptions.get(model.__name__, {})
for col in columns:
label = col.name
if col.primary_key:
label += " (PK)"
if col.foreign_keys:
label += " (FK)"
field_obj = {"id": col.name, "label": label}
tooltip_parts = []
if col.name in descriptions:
tooltip_parts.append(descriptions[col.name])
enum_values = self._get_enum_values(model, col)
if enum_values:
tooltip_parts.append(
"Варианты:\n" + "\n".join(f"{v}" for v in enum_values)
)
if tooltip_parts:
field_obj["tooltip"] = "\n\n".join(tooltip_parts)
entity_fields.append(field_obj)
entities.append(
{"id": model.__name__, "title": table_name, "fields": entity_fields}
)
relations = self._collect_fk_relations() + self._collect_m2m_relations()
return {"entities": entities, "relations": relations}
@@ -0,0 +1,81 @@
from pathlib import Path
from PIL import Image
TARGET_RATIO = 5 / 7
def crop_image(img: Image.Image, target_ratio: float = TARGET_RATIO) -> Image.Image:
w, h = img.size
current_ratio = w / h
if current_ratio > target_ratio:
new_w = int(h * target_ratio)
left = (w - new_w) // 2
right = left + new_w
top = 0
bottom = h
else:
new_h = int(w / target_ratio)
top = (h - new_h) // 2
bottom = top + new_h
left = 0
right = w
return img.crop((left, top, right, bottom))
def transcode_image(
src_path: str | Path,
*,
jpeg_quality: int = 85,
webp_quality: int = 80,
webp_lossless: bool = False,
resize_to: tuple[int, int] | None = None,
):
src_path = Path(src_path)
if not src_path.exists():
raise FileNotFoundError(src_path)
stem = src_path.stem
folder = src_path.parent
img = Image.open(src_path).convert("RGBA")
img = crop_image(img)
if resize_to:
img = img.resize(resize_to, Image.LANCZOS)
png_path = folder / f"{stem}.png"
img.save(
png_path,
format="PNG",
optimize=True,
interlace=1,
)
jpg_path = folder / f"{stem}.jpg"
img.convert("RGB").save(
jpg_path,
format="JPEG",
quality=jpeg_quality,
progressive=True,
optimize=True,
subsampling="4:2:0",
)
webp_path = folder / f"{stem}.webp"
img.save(
webp_path,
format="WEBP",
quality=webp_quality,
lossless=webp_lossless,
method=6,
)
return {
"png": png_path,
"jpeg": jpg_path,
"webp": webp_path,
}
+7 -1
View File
@@ -10,6 +10,9 @@ from toml import load
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:
_pyproject = load(f)
@@ -61,6 +64,7 @@ OPENAPI_TAGS = [
{"name": "loans", "description": "Действия с выдачами."},
{"name": "relations", "description": "Действия со связями."},
{"name": "users", "description": "Действия с пользователями."},
{"name": "captcha", "description": "Создание и проверка cap.js каптчи."},
{"name": "misc", "description": "Прочие."},
]
@@ -94,7 +98,9 @@ USER = os.getenv("POSTGRES_USER")
PASSWORD = os.getenv("POSTGRES_PASSWORD")
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")
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
+324 -279
View File
@@ -1,6 +1,71 @@
$(() => {
const PARTIAL_TOKEN_KEY = "partial_token";
const PARTIAL_USERNAME_KEY = "partial_username";
const SELECTORS = {
loginForm: "#login-form",
registerForm: "#register-form",
resetForm: "#reset-password-form",
authTabs: "#auth-tabs",
loginTab: "#login-tab",
registerTab: "#register-tab",
forgotBtn: "#forgot-password-btn",
backToLoginBtn: "#back-to-login-btn",
backToCredentialsBtn: "#back-to-credentials-btn",
submitLogin: "#login-submit",
submitRegister: "#register-submit",
submitReset: "#reset-submit",
usernameLogin: "#login-username",
passwordLogin: "#login-password",
totpInput: "#login-totp",
rememberMe: "#remember-me",
credentialsSection: "#credentials-section",
totpSection: "#totp-section",
registerUsername: "#register-username",
registerEmail: "#register-email",
registerFullname: "#register-fullname",
registerPassword: "#register-password",
registerConfirm: "#register-password-confirm",
passwordStrengthBar: "#password-strength-bar",
passwordStrengthText: "#password-strength-text",
passwordMatchError: "#password-match-error",
resetUsername: "#reset-username",
resetCode: "#reset-recovery-code",
resetNewPassword: "#reset-new-password",
resetConfirmPassword: "#reset-confirm-password",
resetMatchError: "#reset-password-match-error",
recoveryModal: "#recovery-codes-modal",
recoveryList: "#recovery-codes-list",
codesSavedCheckbox: "#codes-saved-checkbox",
closeRecoveryBtn: "#close-recovery-modal-btn",
copyCodesBtn: "#copy-codes-btn",
downloadCodesBtn: "#download-codes-btn",
gotoLoginAfterReset: "#goto-login-after-reset",
capWidget: "#cap",
lockProgressCircle: "#lock-progress-circle",
};
const STORAGE_KEYS = {
partialToken: "partial_token",
partialUsername: "partial_username",
};
const TEXTS = {
login: "Войти",
confirm: "Подтвердить",
checking: "Проверка...",
registering: "Регистрация...",
resetting: "Сброс...",
enterTotp: "Введите код из приложения аутентификатора",
sessionExpired: "Время сессии истекло. Пожалуйста, войдите заново.",
invalidCode: "Неверный код",
passwordsNotMatch: "Пароли не совпадают",
captchaRequired: "Пожалуйста, пройдите проверку Captcha",
registrationSuccess: "Регистрация успешна! Войдите в систему.",
codesCopied: "Коды скопированы в буфер обмена",
codesDownloaded: "Файл с кодами скачан",
passwordResetSuccess: "Пароль успешно изменён!",
invalidRecoveryCode: "Неверный формат резервного кода",
passwordTooShort: "Пароль должен содержать минимум 8 символов",
};
const TOTP_PERIOD = 30;
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38;
@@ -14,96 +79,89 @@ $(() => {
let registeredRecoveryCodes = [];
let totpAnimationFrame = null;
function getTotpProgress() {
const getTotpProgress = () => {
const now = Date.now() / 1000;
const elapsed = now % TOTP_PERIOD;
return elapsed / TOTP_PERIOD;
}
};
function updateTotpTimer() {
const circle = document.getElementById("lock-progress-circle");
const updateTotpTimer = () => {
const circle = $(SELECTORS.lockProgressCircle).get(0);
if (!circle) return;
const progress = getTotpProgress();
const offset = CIRCLE_CIRCUMFERENCE * (1 - progress);
circle.style.strokeDashoffset = offset;
totpAnimationFrame = requestAnimationFrame(updateTotpTimer);
}
};
function startTotpTimer() {
const startTotpTimer = () => {
stopTotpTimer();
updateTotpTimer();
}
};
function stopTotpTimer() {
const stopTotpTimer = () => {
if (totpAnimationFrame) {
cancelAnimationFrame(totpAnimationFrame);
totpAnimationFrame = null;
}
};
const resetCircle = () => {
const circle = $(SELECTORS.lockProgressCircle).get(0);
if (circle) circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE;
};
const savePartialToken = (token, username) => {
sessionStorage.setItem(STORAGE_KEYS.partialToken, token);
sessionStorage.setItem(STORAGE_KEYS.partialUsername, username);
};
const clearPartialToken = () => {
sessionStorage.removeItem(STORAGE_KEYS.partialToken);
sessionStorage.removeItem(STORAGE_KEYS.partialUsername);
};
const showForm = (formId) => {
let newHash = "";
if (formId === SELECTORS.loginForm) newHash = "login";
else if (formId === SELECTORS.registerForm) newHash = "register";
else if (formId === SELECTORS.resetForm) newHash = "reset";
if (newHash && window.location.hash !== "#" + newHash) {
window.history.pushState(null, null, "#" + newHash);
}
function resetCircle() {
const circle = document.getElementById("lock-progress-circle");
if (circle) {
circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE;
}
}
function initLoginState() {
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");
$(
`${SELECTORS.loginForm}, ${SELECTORS.registerForm}, ${SELECTORS.resetForm}`,
).addClass("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")
.addClass("text-gray-400 hover:text-gray-600");
if (formId === "#login-form") {
$("#login-tab")
if (formId === SELECTORS.loginForm) {
$(SELECTORS.loginTab)
.removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
resetLoginState();
} else if (formId === "#register-form") {
$("#register-tab")
} else if (formId === SELECTORS.registerForm) {
$(SELECTORS.registerTab)
.removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
}
}
};
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();
stopTotpTimer();
loginState = {
@@ -112,30 +170,70 @@ $(() => {
username: "",
rememberMe: false,
};
$("#totp-section").addClass("hidden");
$("#login-totp").val("");
$("#credentials-section").removeClass("hidden");
$("#login-submit").text("Войти");
$(SELECTORS.authTabs).removeClass("hide-animated");
$(SELECTORS.totpSection).addClass("hidden");
$(SELECTORS.totpInput).val("");
$(SELECTORS.credentialsSection).removeClass("hidden");
$(SELECTORS.submitLogin).text(TEXTS.login);
resetCircle();
}
};
$("#login-tab").on("click", () => showForm("#login-form"));
$("#register-tab").on("click", () => showForm("#register-form"));
$("#forgot-password-btn").on("click", () => showForm("#reset-password-form"));
$("#back-to-login-btn").on("click", () => showForm("#login-form"));
const checkPasswordMatch = (passwordId, confirmId, errorId) => {
const password = $(passwordId).val();
const confirm = $(confirmId).val();
const $error = $(errorId);
if (confirm && password !== confirm) {
$error.removeClass("hidden");
return false;
}
$error.addClass("hidden");
return true;
};
const saveTokensAndRedirect = (data, rememberMe) => {
const storage = rememberMe ? localStorage : sessionStorage;
const otherStorage = rememberMe ? sessionStorage : localStorage;
storage.setItem("access_token", data.access_token);
if (data.refresh_token)
storage.setItem("refresh_token", data.refresh_token);
otherStorage.removeItem("access_token");
otherStorage.removeItem("refresh_token");
window.location.href = "/";
};
const initLoginState = () => {
const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken);
const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername);
if (savedToken && savedUsername) {
$(SELECTORS.authTabs).addClass("hide-animated");
loginState.partialToken = savedToken;
loginState.username = savedUsername;
loginState.step = "2fa";
$(SELECTORS.usernameLogin).val(savedUsername);
$(SELECTORS.credentialsSection).addClass("hidden");
$(SELECTORS.totpSection).removeClass("hidden");
$(SELECTORS.submitLogin).text(TEXTS.confirm);
startTotpTimer();
setTimeout(() => $(SELECTORS.totpInput).get(0)?.focus(), 100);
}
};
$(SELECTORS.loginTab).on("click", () => showForm(SELECTORS.loginForm));
$(SELECTORS.registerTab).on("click", () => showForm(SELECTORS.registerForm));
$(SELECTORS.forgotBtn).on("click", () => showForm(SELECTORS.resetForm));
$(SELECTORS.backToLoginBtn).on("click", () => showForm(SELECTORS.loginForm));
$(SELECTORS.backToCredentialsBtn).on("click", resetLoginState);
$("body").on("click", ".toggle-password", function () {
const $btn = $(this);
const $input = $btn.siblings("input");
const $input = $(this).siblings("input");
const isPassword = $input.attr("type") === "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();
let strength = 0;
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
@@ -150,91 +248,65 @@ $(() => {
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
{ width: "100%", color: "bg-green-500", text: "Отличный" },
];
const level = levels[strength];
$("#password-strength-bar")
$(SELECTORS.passwordStrengthBar)
.css("width", level.width)
.attr("class", "h-full transition-all duration-300 " + level.color);
$("#password-strength-text").text(level.text);
checkPasswordMatch();
.attr("class", `h-full transition-all duration-300 ${level.color}`);
$(SELECTORS.passwordStrengthText).text(level.text);
checkPasswordMatch(
SELECTORS.registerPassword,
SELECTORS.registerConfirm,
SELECTORS.passwordMatchError,
);
});
function checkPasswordMatch() {
const password = $("#register-password").val();
const confirm = $("#register-password-confirm").val();
if (confirm && password !== confirm) {
$("#password-match-error").removeClass("hidden");
return false;
}
$("#password-match-error").addClass("hidden");
return true;
}
$(SELECTORS.registerConfirm).on("input", () =>
checkPasswordMatch(
SELECTORS.registerPassword,
SELECTORS.registerConfirm,
SELECTORS.passwordMatchError,
),
);
$("#register-password-confirm").on("input", checkPasswordMatch);
function formatRecoveryCode(input) {
let value = input.value.toUpperCase().replace(/[^0-9A-F]/g, "");
$(SELECTORS.resetCode).on("input", function () {
let value = this.value.toUpperCase().replace(/[^0-9A-F]/g, "");
let formatted = "";
for (let i = 0; i < value.length && i < 16; i++) {
if (i > 0 && i % 4 === 0) formatted += "-";
formatted += value[i];
}
input.value = formatted;
}
$("#reset-recovery-code").on("input", function () {
formatRecoveryCode(this);
this.value = formatted;
});
$("#login-totp").on("input", function () {
$(SELECTORS.totpInput).on("input", function () {
this.value = this.value.replace(/\D/g, "").slice(0, 6);
if (this.value.length === 6) {
$("#login-form").trigger("submit");
}
if (this.value.length === 6) $(SELECTORS.loginForm).trigger("submit");
});
$("#back-to-credentials-btn").on("click", function () {
resetLoginState();
});
$("#login-form").on("submit", async function (event) {
$(SELECTORS.loginForm).on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $("#login-submit");
const $submitBtn = $(SELECTORS.submitLogin);
if (loginState.step === "credentials") {
const username = $("#login-username").val();
const password = $("#login-password").val();
const rememberMe = $("#remember-me").prop("checked");
const username = $(SELECTORS.usernameLogin).val();
const password = $(SELECTORS.passwordLogin).val();
const rememberMe = $(SELECTORS.rememberMe).prop("checked");
loginState.username = username;
loginState.rememberMe = rememberMe;
$submitBtn.prop("disabled", true).text("Вход...");
try {
const formData = new URLSearchParams();
formData.append("username", username);
formData.append("password", password);
const formData = new URLSearchParams({ username, password });
const data = await Api.postForm("/api/auth/token", formData);
if (data.requires_2fa && data.partial_token) {
loginState.partialToken = data.partial_token;
loginState.step = "2fa";
savePartialToken(data.partial_token, username);
$("#credentials-section").addClass("hidden");
$("#totp-section").removeClass("hidden");
$(SELECTORS.authTabs).addClass("hide-animated");
$(SELECTORS.credentialsSection).addClass("hidden");
$(SELECTORS.totpSection).removeClass("hidden");
startTotpTimer();
const totpInput = document.getElementById("login-totp");
if (totpInput) totpInput.focus();
$submitBtn.text("Подтвердить");
Utils.showToast("Введите код из приложения аутентификатора", "info");
$(SELECTORS.totpInput).get(0)?.focus();
$submitBtn.text(TEXTS.confirm);
Utils.showToast(TEXTS.enterTotp, "info");
} else if (data.access_token) {
clearPartialToken();
saveTokensAndRedirect(data, rememberMe);
@@ -243,20 +315,15 @@ $(() => {
Utils.showToast(error.message || "Ошибка входа", "error");
} finally {
$submitBtn.prop("disabled", false);
if (loginState.step === "credentials") {
$submitBtn.text("Войти");
}
if (loginState.step === "credentials") $submitBtn.text(TEXTS.login);
}
} else if (loginState.step === "2fa") {
const totpCode = $("#login-totp").val();
const totpCode = $(SELECTORS.totpInput).val();
if (!totpCode || totpCode.length !== 6) {
Utils.showToast("Введите 6-значный код", "error");
return;
}
$submitBtn.prop("disabled", true).text("Проверка...");
$submitBtn.prop("disabled", true).text(TEXTS.checking);
try {
const response = await fetch("/api/auth/2fa/verify", {
method: "POST",
@@ -266,113 +333,114 @@ $(() => {
},
body: JSON.stringify({ code: totpCode }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 401) {
resetLoginState();
throw new Error(
"Время сессии истекло. Пожалуйста, войдите заново.",
);
throw new Error(TEXTS.sessionExpired);
}
throw new Error(errorData.detail || "Неверный код");
throw new Error(errorData.detail || TEXTS.invalidCode);
}
const data = await response.json();
clearPartialToken();
stopTotpTimer();
saveTokensAndRedirect(data, loginState.rememberMe);
} catch (error) {
Utils.showToast(error.message || "Неверный код", "error");
$("#login-totp").val("");
const totpInput = document.getElementById("login-totp");
if (totpInput) totpInput.focus();
Utils.showToast(error.message || TEXTS.invalidCode, "error");
$(SELECTORS.totpInput).val("");
$(SELECTORS.totpInput).get(0)?.focus();
} finally {
$submitBtn.prop("disabled", false).text("Подтвердить");
$submitBtn.prop("disabled", false).text(TEXTS.confirm);
}
}
});
function 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 = "/";
}
$("#register-form").on("submit", async function (event) {
$(SELECTORS.registerForm).on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $("#register-submit");
const pass = $("#register-password").val();
const confirm = $("#register-password-confirm").val();
const $submitBtn = $(SELECTORS.submitRegister);
const pass = $(SELECTORS.registerPassword).val();
const confirm = $(SELECTORS.registerConfirm).val();
if (pass !== confirm) {
Utils.showToast("Пароли не совпадают", "error");
Utils.showToast(TEXTS.passwordsNotMatch, "error");
return;
}
const userData = {
username: $("#register-username").val(),
email: $("#register-email").val(),
full_name: $("#register-fullname").val() || null,
username: $(SELECTORS.registerUsername).val(),
email: $(SELECTORS.registerEmail).val(),
full_name: $(SELECTORS.registerFullname).val() || null,
password: pass,
};
$submitBtn.prop("disabled", true).text("Регистрация...");
$submitBtn.prop("disabled", true).text(TEXTS.registering);
try {
const response = await Api.post("/api/auth/register", userData);
if (response.recovery_codes && response.recovery_codes.codes) {
registeredRecoveryCodes = response.recovery_codes.codes;
showRecoveryCodesModal(registeredRecoveryCodes, userData.username);
} else {
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
Utils.showToast(TEXTS.registrationSuccess, "success");
setTimeout(() => {
showForm("#login-form");
$("#login-username").val(userData.username);
showForm(SELECTORS.loginForm);
$(SELECTORS.usernameLogin).val(userData.username);
}, 1500);
}
} catch (error) {
let msg = error.message;
console.log("Debug error object:", error);
const cleanMsg = (text) => {
if (!text) return "";
if (text.includes("value is not a valid email address")) {
return "Некорректный адрес электронной почты";
}
text = text.replace(/^Value error,\s*/i, "");
return text.charAt(0).toUpperCase() + text.slice(1);
};
let msg = "Ошибка регистрации";
if (error.detail && error.detail.error === "captcha_required") {
Utils.showToast(TEXTS.captchaRequired, "error");
const $capElement = $(SELECTORS.capWidget);
const $parent = $capElement.parent();
$capElement.remove();
$parent.append(
`<cap-widget id="cap" data-cap-api-endpoint="/api/cap/" style="--cap-widget-width: 100%;"></cap-widget>`,
);
return;
}
if (error.detail && Array.isArray(error.detail)) {
msg = error.detail.map((e) => e.msg).join(". ");
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);
}
Utils.showToast(msg || "Ошибка регистрации", "error");
console.log("Resulting msg:", msg);
Utils.showToast(msg, "error");
} finally {
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
$submitBtn
.prop("disabled", false)
.text(TEXTS.registering.replace("...", ""));
}
});
function showRecoveryCodesModal(codes, username) {
const $list = $("#recovery-codes-list");
const showRecoveryCodesModal = (codes, username) => {
const $list = $(SELECTORS.recoveryList);
$list.empty();
codes.forEach((code, index) => {
$list.append(`
<div class="py-1 px-2 bg-white rounded border select-all font-mono">
${index + 1}. ${Utils.escapeHtml(code)}
</div>
`);
$list.append(
`<div class="py-1 px-2 bg-white rounded border select-all font-mono">${index + 1}. ${Utils.escapeHtml(code)}</div>`,
);
});
$(SELECTORS.codesSavedCheckbox).prop("checked", false);
$(SELECTORS.closeRecoveryBtn).prop("disabled", true);
$(SELECTORS.recoveryModal).data("username", username).removeClass("hidden");
};
$("#codes-saved-checkbox").prop("checked", false);
$("#close-recovery-modal-btn").prop("disabled", true);
$("#recovery-codes-modal").data("username", username);
$("#recovery-codes-modal").removeClass("hidden");
}
function renderRecoveryCodesStatus(usedCodes) {
const renderRecoveryCodesStatus = (usedCodes) => {
return usedCodes
.map((used, index) => {
const codeDisplay = "████-████-████-████";
@@ -380,31 +448,25 @@ $(() => {
? "text-gray-300 line-through"
: "text-green-600";
const statusIcon = used ? "✗" : "✓";
return `
<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}">
<span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span>
<span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span>
</div>
`;
return `<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}"><span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span><span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span></div>`;
})
.join("");
}
};
$("#codes-saved-checkbox").on("change", function () {
$("#close-recovery-modal-btn").prop("disabled", !this.checked);
$(SELECTORS.codesSavedCheckbox).on("change", function () {
$(SELECTORS.closeRecoveryBtn).prop("disabled", !this.checked);
});
$("#copy-codes-btn").on("click", function () {
$(SELECTORS.copyCodesBtn).on("click", function () {
const codesText = registeredRecoveryCodes.join("\n");
navigator.clipboard.writeText(codesText).then(() => {
Utils.showToast("Коды скопированы в буфер обмена", "success");
});
navigator.clipboard
.writeText(codesText)
.then(() => Utils.showToast(TEXTS.codesCopied, "success"));
});
$("#download-codes-btn").on("click", function () {
const username = $("#recovery-codes-modal").data("username") || "user";
$(SELECTORS.downloadCodesBtn).on("click", function () {
const username = $(SELECTORS.recoveryModal).data("username") || "user";
const codesText = `Резервные коды для аккаунта: ${username}\nДата: ${new Date().toLocaleString()}\n\n${registeredRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nХраните эти коды в надёжном месте!`;
const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -412,69 +474,54 @@ $(() => {
a.download = `recovery-codes-${username}.txt`;
a.click();
URL.revokeObjectURL(url);
Utils.showToast("Файл с кодами скачан", "success");
Utils.showToast(TEXTS.codesDownloaded, "success");
});
$("#close-recovery-modal-btn").on("click", function () {
const username = $("#recovery-codes-modal").data("username");
$("#recovery-codes-modal").addClass("hidden");
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
showForm("#login-form");
$("#login-username").val(username);
$(SELECTORS.closeRecoveryBtn).on("click", function () {
const username = $(SELECTORS.recoveryModal).data("username");
$(SELECTORS.recoveryModal).addClass("hidden");
Utils.showToast(TEXTS.registrationSuccess, "success");
showForm(SELECTORS.loginForm);
$(SELECTORS.usernameLogin).val(username);
});
function checkResetPasswordMatch() {
const password = $("#reset-new-password").val();
const confirm = $("#reset-confirm-password").val();
if (confirm && password !== confirm) {
$("#reset-password-match-error").removeClass("hidden");
return false;
}
$("#reset-password-match-error").addClass("hidden");
return true;
}
$(SELECTORS.resetConfirmPassword).on("input", () =>
checkPasswordMatch(
SELECTORS.resetNewPassword,
SELECTORS.resetConfirmPassword,
SELECTORS.resetMatchError,
),
);
$("#reset-confirm-password").on("input", checkResetPasswordMatch);
$("#reset-password-form").on("submit", async function (event) {
$(SELECTORS.resetForm).on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $("#reset-submit");
const newPassword = $("#reset-new-password").val();
const confirmPassword = $("#reset-confirm-password").val();
const $submitBtn = $(SELECTORS.submitReset);
const newPassword = $(SELECTORS.resetNewPassword).val();
const confirmPassword = $(SELECTORS.resetConfirmPassword).val();
if (newPassword !== confirmPassword) {
Utils.showToast("Пароли не совпадают", "error");
Utils.showToast(TEXTS.passwordsNotMatch, "error");
return;
}
if (newPassword.length < 8) {
Utils.showToast("Пароль должен содержать минимум 8 символов", "error");
Utils.showToast(TEXTS.passwordTooShort, "error");
return;
}
const data = {
username: $("#reset-username").val(),
recovery_code: $("#reset-recovery-code").val().toUpperCase(),
username: $(SELECTORS.resetUsername).val(),
recovery_code: $(SELECTORS.resetCode).val().toUpperCase(),
new_password: newPassword,
};
if (
!/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test(
data.recovery_code,
)
) {
Utils.showToast("Неверный формат резервного кода", "error");
Utils.showToast(TEXTS.invalidRecoveryCode, "error");
return;
}
$submitBtn.prop("disabled", true).text("Сброс...");
$submitBtn.prop("disabled", true).text(TEXTS.resetting);
try {
const response = await Api.post("/api/auth/password/reset", data);
showPasswordResetResult(response, data.username);
} catch (error) {
Utils.showToast(error.message || "Ошибка сброса пароля", "error");
@@ -482,9 +529,8 @@ $(() => {
}
});
function showPasswordResetResult(response, username) {
const $form = $("#reset-password-form");
const showPasswordResetResult = (response, username) => {
const $form = $(SELECTORS.resetForm);
$form.html(`
<div class="text-center mb-4">
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
@@ -492,22 +538,19 @@ $(() => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-800">Пароль успешно изменён!</h3>
<h3 class="text-lg font-semibold text-gray-800">${TEXTS.passwordResetSuccess}</h3>
</div>
<div class="mb-4">
<p class="text-sm text-gray-600 mb-2 text-center">
Осталось резервных кодов: <strong class="${response.remaining <= 2 ? "text-red-600" : "text-green-600"}">${response.remaining}</strong> из ${response.total}
</p>
${
response.should_regenerate
? `
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
<p class="text-sm text-yellow-800 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
Рекомендуем сгенерировать новые коды в профиле
</p>
@@ -515,12 +558,10 @@ $(() => {
`
: ""
}
<div class="bg-gray-50 rounded-lg p-3 space-y-1 max-h-48 overflow-y-auto">
<p class="text-xs text-gray-500 mb-2 text-center">Статус резервных кодов:</p>
${renderRecoveryCodesStatus(response.used_codes)}
</div>
${
response.generated_at
? `
@@ -531,23 +572,27 @@ $(() => {
: ""
}
</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>
`);
$form.off("submit");
$("#goto-login-after-reset").on("click", function () {
location.reload();
setTimeout(() => {
showForm("#login-form");
$("#login-username").val(username);
showForm(SELECTORS.loginForm);
$(SELECTORS.usernameLogin).val(username);
}, 100);
});
}
};
initLoginState();
handleHash();
const widget = $(SELECTORS.capWidget).get(0);
if (widget && widget.shadowRoot) {
const style = document.createElement("style");
style.textContent = `.credits { right: 20px !important; }`;
$(widget.shadowRoot).append(style);
}
});
+5
View File
@@ -6,6 +6,11 @@ $(document).ready(() => {
let currentSort = "name_asc";
loadAuthors();
const USER_CAN_MANAGE =
typeof window.canManage === "function" && window.canManage();
if (USER_CAN_MANAGE) {
$("#add-author-btn").removeClass("hidden");
}
function loadAuthors() {
showLoadingState();
+298 -1
View File
@@ -34,6 +34,7 @@ $(document).ready(() => {
const pathParts = window.location.pathname.split("/");
const bookId = parseInt(pathParts[pathParts.length - 1]);
let isDraggingOver = false;
let currentBook = null;
let cachedUsers = null;
let selectedLoanUserId = null;
@@ -48,6 +49,28 @@ $(document).ready(() => {
}
loadBookData();
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() {
@@ -75,6 +98,270 @@ $(document).ready(() => {
$("#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() {
Api.get(`/api/books/${bookId}`)
.then((book) => {
@@ -234,6 +521,16 @@ $(document).ready(() => {
function renderBook(book) {
$("#book-title").text(book.title);
$("#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.map((a) => a.name).join(", ") || "Автор неизвестен",
);
@@ -435,7 +732,7 @@ $(document).ready(() => {
}
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;
renderUsersList(cachedUsers);
} catch (error) {
+304 -198
View File
@@ -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 = {
active: {
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) {
return (
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) {
const $dropdown = $("#author-dropdown");
authors.forEach((author) => {
$("<div>")
.addClass(
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors",
)
.attr("data-id", author.id)
.attr("data-name", author.name)
.text(author.name)
.appendTo($dropdown);
const $dropdown = $(SELECTORS.authorDropdown);
const fragment = document.createDocumentFragment();
if (authorIdsFromUrl.includes(String(author.id))) {
selectedAuthors.set(author.id, author.name);
authors.forEach((author) => {
const item = document.createElement("div");
item.className =
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors";
item.dataset.id = author.id;
item.dataset.name = author.name;
item.textContent = author.name;
fragment.appendChild(item);
if (INITIAL_FILTERS.authorIds.has(String(author.id))) {
STATE.selectedAuthors.set(author.id, author.name);
}
});
$dropdown.empty().append(fragment);
}
function initGenres(genres) {
const $list = $("#genres-list");
genres.forEach((genre) => {
const isChecked = genreIdsFromUrl.includes(String(genre.id));
if (isChecked) selectedGenres.set(genre.id, genre.name);
const $list = $(SELECTORS.genresList);
const canManage = USER_CAN_MANAGE;
let html = "";
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="Редактировать жанр">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
</a>`
: "";
$list.append(`
html += `
<li class="mb-1">
<div class="flex items-center">
<label class="custom-checkbox flex items-center flex-1">
<input type="checkbox" data-id="${genre.id}" data-name="${Utils.escapeHtml(genre.name)}" ${isChecked ? "checked" : ""} />
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)}
<input type="checkbox" data-id="${genre.id}" data-name="${safeName}" ${
isChecked ? "checked" : ""
} />
<span class="checkmark"></span> ${safeName}
</label>
${editButton}
</div>
</li>
`);
`;
});
$list.on("change", "input", function () {
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
});
$list.html(html);
$list.on("change", "input", function () {
const id = parseInt($(this).data("id"));
const id = parseInt($(this).data("id"), 10);
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() {
const searchQuery = $("#book-search-input").val().trim();
const params = new URLSearchParams();
params.append("q", searchQuery);
selectedAuthors.forEach((_, id) => params.append("author_ids", id));
selectedGenres.forEach((_, id) => params.append("genre_ids", id));
const searchQuery = $(SELECTORS.bookSearchInput).val().trim();
const $minPages = $(SELECTORS.pagesMin);
const $maxPages = $(SELECTORS.pagesMax);
const minPages = $minPages.length ? $minPages.val() : "";
const maxPages = $maxPages.length ? $maxPages.val() : "";
const apiParams = new URLSearchParams();
const browserParams = new URLSearchParams();
if (searchQuery) {
apiParams.append("q", searchQuery);
browserParams.append("q", searchQuery);
selectedAuthors.forEach((_, id) => browserParams.append("author_id", id));
selectedGenres.forEach((_, id) => browserParams.append("genre_id", id));
}
if (minPages && minPages > 0) {
apiParams.append("min_page_count", minPages);
browserParams.append("min_page_count", minPages);
}
if (maxPages && maxPages < 2000) {
apiParams.append("max_page_count", maxPages);
browserParams.append("max_page_count", maxPages);
}
STATE.selectedAuthors.forEach((_, id) => {
apiParams.append("author_ids", id);
browserParams.append("author_id", id);
});
STATE.selectedGenres.forEach((_, id) => {
apiParams.append("genre_ids", id);
browserParams.append("genre_id", id);
});
apiParams.append("page", STATE.currentPage);
apiParams.append("size", PAGE_SIZE);
const newUrl =
window.location.pathname +
(browserParams.toString() ? `?${browserParams.toString()}` : "");
window.history.replaceState({}, "", newUrl);
params.append("page", currentPage);
params.append("size", pageSize);
showLoadingState();
Api.get(`/api/books/filter?${params.toString()}`)
Api.get(`/api/books/filter?${apiParams.toString()}`)
.then((data) => {
totalBooks = data.total;
renderBooks(data.books);
STATE.totalBooks = data.total || 0;
renderBooks(data.books || []);
renderPagination();
})
.catch((error) => {
console.error(error);
Utils.showToast("Не удалось загрузить книги", "error");
$("#books-container").html(
document.getElementById("empty-state-template").innerHTML,
$(SELECTORS.booksContainer).html(
TEMPLATES.emptyState.content.cloneNode(true),
);
});
}
function renderBooks(books) {
const $container = $("#books-container");
const tpl = document.getElementById("book-card-template");
const emptyTpl = document.getElementById("empty-state-template");
const badgeTpl = document.getElementById("genre-badge-template");
const $container = $(SELECTORS.booksContainer);
$container.empty();
if (books.length === 0) {
$container.append(emptyTpl.content.cloneNode(true));
if (!books.length) {
$container.append(TEMPLATES.emptyState.content.cloneNode(true));
return;
}
books.forEach((book) => {
const clone = tpl.content.cloneNode(true);
const card = clone.querySelector(".book-card");
const fragment = document.createDocumentFragment();
books.forEach((book) => {
const clone = TEMPLATES.bookCard.content.cloneNode(true);
const card = clone.querySelector(".book-card");
card.dataset.id = book.id;
clone.querySelector(".book-title").textContent = book.title;
clone.querySelector(".book-authors").textContent =
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
clone.querySelector(".book-desc").textContent = book.description || "";
const titleEl = clone.querySelector(".book-title");
const authorsEl = clone.querySelector(".book-authors");
const pageCountWrapper = clone.querySelector(".book-page-count");
const pageCountValue =
pageCountWrapper.querySelector(".page-count-value");
const descEl = clone.querySelector(".book-desc");
const statusEl = clone.querySelector(".book-status");
const genresContainer = clone.querySelector(".book-genres");
titleEl.textContent = book.title;
authorsEl.textContent =
(book.authors && book.authors.map((a) => a.name).join(", ")) ||
"Автор неизвестен";
if (book.page_count && book.page_count > 0) {
pageCountValue.textContent = book.page_count;
pageCountWrapper.classList.remove("hidden");
}
descEl.textContent = book.description || "";
const statusConfig = getStatusConfig(book.status);
const statusEl = clone.querySelector(".book-status");
statusEl.textContent = statusConfig.label;
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
const genresContainer = clone.querySelector(".book-genres");
if (Array.isArray(book.genres)) {
book.genres.forEach((g) => {
const badge = badgeTpl.content.cloneNode(true);
const badge = TEMPLATES.genreBadge.content.cloneNode(true);
const span = badge.querySelector("span");
span.textContent = g.name;
genresContainer.appendChild(badge);
});
$container.append(clone);
});
}
function renderPagination() {
$("#pagination-container").empty();
const totalPages = Math.ceil(totalBooks / pageSize);
if (totalPages <= 1) return;
const $pagination = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => {
if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2 text-gray-500">...</span>`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
`);
}
fragment.appendChild(clone);
});
$("#pagination-container").append($pagination);
$("#prev-page").on("click", function () {
if (currentPage > 1) {
currentPage--;
loadBooks();
scrollToTop();
}
});
$("#next-page").on("click", function () {
if (currentPage < totalPages) {
currentPage++;
loadBooks();
scrollToTop();
}
});
$(".page-btn").on("click", function () {
const page = parseInt($(this).data("page"));
if (page !== currentPage) {
currentPage = page;
loadBooks();
scrollToTop();
}
});
$container.append(fragment);
}
function generatePageNumbers(current, total) {
@@ -269,49 +296,81 @@ $(document).ready(() => {
return pages;
}
function renderPagination() {
const totalPages = getTotalPages();
const $container = $(SELECTORS.paginationContainer);
$container.empty();
if (totalPages <= 1) {
return;
}
const pages = generatePageNumbers(STATE.currentPage, totalPages);
let pagesHtml = "";
pages.forEach((page) => {
if (page === "...") {
pagesHtml += `<span class="px-3 py-2 text-gray-500">...</span>`;
} else {
const isActive = page === STATE.currentPage;
pagesHtml += `<button class="page-btn px-3 py-2 rounded-lg transition-colors ${
isActive
? "bg-gray-600 text-white"
: "bg-white border border-gray-300 hover:bg-gray-50"
}" data-page="${page}">${page}</button>`;
}
});
const html = `
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
STATE.currentPage === 1 ? "disabled" : ""
}>&larr;</button>
<div id="page-numbers" class="flex gap-1">${pagesHtml}</div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
STATE.currentPage === totalPages ? "disabled" : ""
}>&rarr;</button>
</div>
`;
$container.html(html);
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
function showLoadingState() {
$("#books-container").html(`
<div class="space-y-4">
${Array(3)
.fill()
.map(
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
</div>
`,
)
.join("")}
</div>
`);
$(SELECTORS.booksContainer).html(LOADING_SKELETON_HTML);
}
function renderChips() {
const $container = $("#selected-authors-container");
const $dropdown = $("#author-dropdown");
function renderSelectedAuthors() {
const $container = $(SELECTORS.selectedAuthorsContainer);
const $dropdown = $(SELECTORS.authorDropdown);
$container.empty();
selectedAuthors.forEach((name, id) => {
$(`<span class="author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
const fragment = document.createDocumentFragment();
STATE.selectedAuthors.forEach((name, id) => {
const wrapper = document.createElement("span");
wrapper.className =
"author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full";
wrapper.innerHTML = `
${Utils.escapeHtml(name)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</span>`).appendTo($container);
`;
fragment.appendChild(wrapper);
});
$container.append(fragment);
$dropdown.find(".author-item").each(function () {
const id = parseInt($(this).data("id"));
if (selectedAuthors.has(id)) {
const id = parseInt($(this).data("id"), 10);
if (STATE.selectedAuthors.has(id)) {
$(this)
.addClass("bg-gray-200 text-gray-900 font-semibold")
.removeClass("hover:bg-gray-100");
@@ -324,11 +383,11 @@ $(document).ready(() => {
}
function initializeAuthorDropdownListeners() {
const $input = $("#author-search-input");
const $dropdown = $("#author-dropdown");
const $container = $("#selected-authors-container");
const $input = $(SELECTORS.authorSearchInput);
const $dropdown = $(SELECTORS.authorDropdown);
const $container = $(SELECTORS.selectedAuthorsContainer);
$input.on("focus", function () {
$input.on("focus", () => {
$dropdown.removeClass("hidden");
});
@@ -344,7 +403,7 @@ $(document).ready(() => {
$(document).on("click", function (e) {
if (
!$(e.target).closest(
"#author-search-input, #author-dropdown, #selected-authors-container",
`${SELECTORS.authorSearchInput}, ${SELECTORS.authorDropdown}, ${SELECTORS.selectedAuthorsContainer}`,
).length
) {
$dropdown.addClass("hidden");
@@ -353,61 +412,108 @@ $(document).ready(() => {
$dropdown.on("click", ".author-item", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
const id = parseInt($(this).data("id"), 10);
const name = $(this).data("name");
if (selectedAuthors.has(id)) {
selectedAuthors.delete(id);
if (STATE.selectedAuthors.has(id)) {
STATE.selectedAuthors.delete(id);
} else {
selectedAuthors.set(id, name);
STATE.selectedAuthors.set(id, name);
}
$input.val("");
$dropdown.find(".author-item").show();
renderChips();
renderSelectedAuthors();
$input[0].focus();
});
$container.on("click", ".remove-author", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
selectedAuthors.delete(id);
renderChips();
const id = parseInt($(this).data("id"), 10);
STATE.selectedAuthors.delete(id);
renderSelectedAuthors();
});
}
$("#books-container").on("click", ".book-card", function () {
window.location.href = `/book/${$(this).data("id")}`;
$(SELECTORS.booksContainer).on("click", ".book-card", function () {
const id = $(this).data("id");
if (id) {
window.location.href = `/book/${id}`;
}
});
$("#apply-filters-btn").on("click", function () {
currentPage = 1;
$(SELECTORS.applyFiltersBtn).on("click", function () {
STATE.currentPage = 1;
loadBooks();
});
$("#reset-filters-btn").on("click", function () {
$("#book-search-input").val("");
selectedAuthors.clear();
selectedGenres.clear();
$("#genres-list input").prop("checked", false);
renderChips();
currentPage = 1;
$(SELECTORS.resetFiltersBtn).on("click", function () {
$(SELECTORS.bookSearchInput).val("");
STATE.selectedAuthors.clear();
STATE.selectedGenres.clear();
$(`${SELECTORS.genresList} input`).prop("checked", false);
const $min = $(SELECTORS.pagesMin);
const $max = $(SELECTORS.pagesMax);
if ($min.length && $max.length) {
const minDefault = $min.attr("min");
const maxDefault = $max.attr("max");
if (minDefault !== undefined) $min.val(minDefault).trigger("input");
if (maxDefault !== undefined) $max.val(maxDefault).trigger("input");
}
renderSelectedAuthors();
STATE.currentPage = 1;
loadBooks();
});
$("#book-search-input").on("keypress", function (e) {
$(SELECTORS.bookSearchInput).on("keypress", function (e) {
if (e.which === 13) {
currentPage = 1;
STATE.currentPage = 1;
loadBooks();
}
});
function showAdminControls() {
if (window.canManage()) {
$("#admin-actions").removeClass("hidden");
$(SELECTORS.paginationContainer).on("click", "#prev-page", function () {
if (STATE.currentPage > 1) {
STATE.currentPage -= 1;
loadBooks();
scrollToTop();
}
});
$(SELECTORS.paginationContainer).on("click", "#next-page", function () {
const totalPages = getTotalPages();
if (STATE.currentPage < totalPages) {
STATE.currentPage += 1;
loadBooks();
scrollToTop();
}
});
$(SELECTORS.paginationContainer).on("click", ".page-btn", function () {
const page = parseInt($(this).data("page"), 10);
if (page && page !== STATE.currentPage) {
STATE.currentPage = page;
loadBooks();
scrollToTop();
}
});
if (USER_CAN_MANAGE) {
$(SELECTORS.adminActions).removeClass("hidden");
}
showAdminControls();
setTimeout(showAdminControls, 100);
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
.then(([authorsData, genresData]) => {
initAuthors(authorsData.authors || []);
initGenres(genresData.genres || []);
initializeAuthorDropdownListeners();
renderSelectedAuthors();
loadBooks();
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки данных", "error");
});
});
@@ -235,18 +235,25 @@ $(document).ready(() => {
const title = $("#book-title").val().trim();
const description = $("#book-description").val().trim();
const pageCount = parseInt($("#book-page-count").val()) || null;
if (!title) {
Utils.showToast("Введите название книги", "error");
return;
}
if (!pageCount) {
Utils.showToast("Введите количество страниц", "error");
return;
}
setLoading(true);
try {
const bookPayload = {
title: title,
description: description || null,
page_count: pageCount,
};
const createdBook = await Api.post("/api/books/", bookPayload);
+4
View File
@@ -23,6 +23,7 @@ $(document).ready(() => {
const $titleInput = $("#book-title");
const $descInput = $("#book-description");
const $statusSelect = $("#book-status");
const $pagesInput = $("#book-page-count");
const $submitBtn = $("#submit-btn");
const $submitText = $("#submit-text");
const $loadingSpinner = $("#loading-spinner");
@@ -69,6 +70,7 @@ $(document).ready(() => {
function populateForm(book) {
$titleInput.val(book.title);
$descInput.val(book.description || "");
$pagesInput.val(book.page_count);
$statusSelect.val(book.status);
updateCounters();
}
@@ -329,6 +331,7 @@ $(document).ready(() => {
const title = $titleInput.val().trim();
const description = $descInput.val().trim();
const pages = parseInt($("#book-page-count").val()) || null;
const status = $statusSelect.val();
if (!title) {
@@ -340,6 +343,7 @@ $(document).ready(() => {
if (title !== originalBook.title) payload.title = title;
if (description !== (originalBook.description || ""))
payload.description = description || null;
if (pages !== originalBook.page_count) payload.page_count = pages;
if (status !== originalBook.status) payload.status = status;
if (Object.keys(payload).length === 0) {
+175
View File
@@ -0,0 +1,175 @@
const NS = "http://www.w3.org/2000/svg";
const $svg = $("#canvas");
const CONFIG = {
holeRadius: 60,
maxRadius: 220,
tilt: 0.4,
ringsCount: 7,
ringSpeed: 0.002,
ringStroke: 5,
particlesCount: 40,
particleSpeedBase: 0.02,
particleFallSpeed: 0.2,
};
function create(tag, attrs) {
const el = document.createElementNS(NS, tag);
for (let k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
const $layerBack = $(create("g", { id: "layer-back" }));
const $layerHole = $(create("g", { id: "layer-hole" }));
const $layerFront = $(create("g", { id: "layer-front" }));
$svg.append($layerBack, $layerHole, $layerFront);
const holeHalo = create("circle", {
cx: 0,
cy: 0,
r: CONFIG.holeRadius + 4,
fill: "#ffffff",
stroke: "none",
});
const holeBody = create("circle", {
cx: 0,
cy: 0,
r: CONFIG.holeRadius,
fill: "#000000",
});
$layerHole.append(holeHalo, holeBody);
class Ring {
constructor(offset) {
this.progress = offset;
const style = {
fill: "none",
stroke: "#000",
"stroke-linecap": "round",
"stroke-width": CONFIG.ringStroke,
};
this.elBack = create("path", style);
this.elFront = create("path", style);
$layerBack.append(this.elBack);
$layerFront.append(this.elFront);
}
update() {
this.progress += CONFIG.ringSpeed;
if (this.progress >= 1) this.progress -= 1;
const t = this.progress;
const currentR =
CONFIG.maxRadius - t * (CONFIG.maxRadius - CONFIG.holeRadius);
const currentRy = currentR * CONFIG.tilt;
const distFromHole = currentR - CONFIG.holeRadius;
const distFromEdge = CONFIG.maxRadius - currentR;
const fadeHole = Math.min(1, distFromHole / 40);
const fadeEdge = Math.min(1, distFromEdge / 40);
const opacity = fadeHole * fadeEdge;
if (opacity <= 0.01) {
this.elBack.setAttribute("opacity", 0);
this.elFront.setAttribute("opacity", 0);
} else {
const dBack = `M ${-currentR} 0 A ${currentR} ${currentRy} 0 0 1 ${currentR} 0`;
const dFront = `M ${-currentR} 0 A ${currentR} ${currentRy} 0 0 0 ${currentR} 0`;
this.elBack.setAttribute("d", dBack);
this.elFront.setAttribute("d", dFront);
this.elBack.setAttribute("opacity", opacity);
this.elFront.setAttribute("opacity", opacity);
const sw =
CONFIG.ringStroke *
(0.6 + 0.4 * (distFromHole / (CONFIG.maxRadius - CONFIG.holeRadius)));
this.elBack.setAttribute("stroke-width", sw);
this.elFront.setAttribute("stroke-width", sw);
}
}
}
class Particle {
constructor() {
this.el = create("circle", { fill: "#000" });
this.reset(true);
$layerFront.append(this.el);
this.inFront = true;
}
reset(randomStart = false) {
this.angle = Math.random() * Math.PI * 2;
this.r = randomStart
? CONFIG.holeRadius +
Math.random() * (CONFIG.maxRadius - CONFIG.holeRadius)
: CONFIG.maxRadius;
this.speed = CONFIG.particleSpeedBase + Math.random() * 0.02;
this.size = 1.5 + Math.random() * 2.5;
}
update() {
const acceleration = CONFIG.maxRadius / this.r;
this.angle += this.speed * acceleration;
this.r -= CONFIG.particleFallSpeed * (acceleration * 0.8);
const x = Math.cos(this.angle) * this.r;
const y = Math.sin(this.angle) * this.r * CONFIG.tilt;
const isNowFront = Math.sin(this.angle) > 0;
if (this.inFront !== isNowFront) {
this.inFront = isNowFront;
if (this.inFront) {
$layerFront.append(this.el);
} else {
$layerBack.append(this.el);
}
}
const distFromHole = this.r - CONFIG.holeRadius;
const distFromEdge = CONFIG.maxRadius - this.r;
const fadeHole = Math.min(1, distFromHole / 30);
const fadeEdge = Math.min(1, distFromEdge / 30);
const opacity = fadeHole * fadeEdge;
this.el.setAttribute("cx", x);
this.el.setAttribute("cy", y);
this.el.setAttribute("r", this.size * Math.min(1, this.r / 100));
this.el.setAttribute("opacity", opacity);
if (this.r <= CONFIG.holeRadius) {
this.reset(false);
}
}
}
const rings = [];
for (let i = 0; i < CONFIG.ringsCount; i++) {
rings.push(new Ring(i / CONFIG.ringsCount));
}
const particles = [];
for (let i = 0; i < CONFIG.particlesCount; i++) {
particles.push(new Particle());
}
function animate() {
rings.forEach((r) => r.update());
particles.forEach((p) => p.update());
requestAnimationFrame(animate);
}
animate();
+75 -7
View File
@@ -19,8 +19,8 @@ const StorageHelper = {
const Utils = {
escapeHtml: (text) => {
if (!text) return "";
return text.replace(
if (text === null || text === undefined) return "";
return String(text).replace(
/[&<>"']/g,
(m) =>
({
@@ -112,11 +112,18 @@ const Api = {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.detail ||
errorData.error_description ||
`Ошибка ${response.status}`,
);
const error = new Error("API Error");
Object.assign(error, errorData);
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();
} catch (error) {
@@ -153,6 +160,67 @@ const Api = {
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 = {
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="flex flex-1 items-center justify-center p-4 bg-gray-100">
<div
class="w-full max-w-4xl bg-white rounded-lg shadow-md overflow-hidden flex flex-col md:flex-row"
+3 -4
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Аналитика{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-7xl">
<div class="mb-8">
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
@@ -10,8 +9,8 @@
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
<select id="period-select" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 transition bg-white">
<option value="7">7 дней</option>
<option value="30" selected>30 дней</option>
<option value="7" selected>7 дней</option>
<option value="30">30 дней</option>
<option value="90">90 дней</option>
<option value="180">180 дней</option>
<option value="365">365 дней</option>
+300 -20
View File
@@ -2,15 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<title id="pageTitle">Loading...</title>
<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>
body {
font-family: Arial, sans-serif;
max-width: 800px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 {
@@ -20,11 +21,13 @@
}
ul {
list-style: none;
display: flex;
padding: 0;
margin: 0;
gap: 20px;
flex-wrap: wrap;
}
li {
margin: 15px 0;
}
li { margin: 10px 0; }
a {
display: inline-block;
padding: 8px 15px;
@@ -33,29 +36,306 @@
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
font-size: 14px;
}
a:hover {
background-color: #2980b9;
a:hover { 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 {
margin: 5px 0;
#erDiagram:active { cursor: grabbing; }
.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>
</head>
<body>
<img src="/favicon.ico" />
<h1>Welcome to {{ app_info.title }}!</h1>
<p>Description: {{ app_info.description }}</p>
<p>Version: {{ app_info.version }}</p>
<p>Current Time: {{ server_time }}</p>
<p>Status: {{ status }}</p>
<h1 id="mainTitle">Загрузка...</h1>
<p>Версия: <span id="appVersion">-</span></p>
<p>Описание: <span id="appDescription">-</span></p>
<p>Статус: <span id="appStatus">-</span></p>
<p class="server-time">Время сервера: <span id="serverTime">-</span></p>
<ul>
<li><a href="/">Home page</a></li>
<li>
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
</li>
<li><a href="/">Главная</a></li>
<li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li>
<li><a href="https://github.com/wowlikon/LiB">Исходный код</a></li>
</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>
</html>
+37 -5
View File
@@ -1,9 +1,8 @@
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-md">
<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"
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">
<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" />
</div>
@@ -98,7 +97,7 @@
</div>
<button type="submit" id="login-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
Войти
</button>
</form>
@@ -184,6 +183,27 @@
</p>
</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"
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>
<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 %}
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
<script src="/static/page/auth.js"></script>
{% endblock %}
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<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 class="flex items-center justify-between mb-4">
+7 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4">
<div
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>
<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">
<input
type="text"
+87 -16
View File
@@ -1,9 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<title>{% block title %}LiB{% endblock %}</title>
<title>{{ title }}</title>
<meta charset="UTF-8" />
<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
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
@@ -18,6 +22,7 @@
class="flex flex-col min-h-screen bg-gray-100"
x-data="{
user: null,
menuOpen: false,
async init() {
document.addEventListener('auth:login', async (e) => {
this.user = e.detail;
@@ -28,31 +33,43 @@
}"
>
<header class="bg-gray-600 text-white p-4 shadow-md">
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
<div class="mx-auto px-3 md:pl-5 md:pr-3 flex justify-between items-center">
<div class="flex items-center">
<button
@click="menuOpen = !menuOpen"
class="md:hidden flex gap-2 items-center hover:opacity-80 transition focus:outline-none"
:aria-expanded="menuOpen"
aria-label="Меню навигации"
>
<img class="invert max-w-10 h-auto" src="/static/logo.svg" />
<h1 class="text-xl font-bold">
<span class="text-gray-300 mr-1"></span>LiB
</h1>
</button>
<a class="hidden md:flex gap-4 items-center max-w-10 h-auto" href="/">
<img class="invert" src="/static/logo.svg" />
<h1 class="text-2xl font-bold">LiB</h1>
</a>
<nav>
</div>
<nav class="hidden md:block">
<ul class="flex space-x-4">
<li>
<a href="/" class="hover:text-gray-200">Главная</a>
</li>
<li>
<a href="/books" class="hover:text-gray-200"
>Книги</a
>
<a href="/books" class="hover:text-gray-200">Книги</a>
</li>
<li>
<a href="/authors" class="hover:text-gray-200"
>Авторы</a
>
<a href="/authors" class="hover:text-gray-200">Авторы</a>
</li>
<li>
<a href="/api" class="hover:text-gray-200">API</a>
</li>
</ul>
</nav>
<div class="relative" x-data="{ open: false }">
<template x-if="!user">
<a
@@ -104,7 +121,7 @@
<div
x-show="open"
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"
>
<div class="px-4 py-3 border-b border-gray-200">
@@ -229,17 +246,71 @@
</template>
</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>
<main class="flex-grow">{% block content %}{% endblock %}</main>
<div
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>
<footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center">
<p>&copy; 2026 LiB Library. Разработано в рамках дипломного проекта.
Код открыт под лицензией <a href="https://github.com/wowlikon/LibraryAPI/blob/main/LICENSE">MIT</a>.
<div class="container mx-auto text-center text-sm md:text-base">
<p>
&copy; 2026 LiB Library. Разработано в рамках дипломного проекта.
<br class="sm:hidden" />
Код открыт под лицензией
<a href="https://github.com/wowlikon/LiB/blob/main/LICENSE" class="underline hover:text-gray-300">MIT</a>.
</p>
</div>
</footer>
+15 -4
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<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 class="flex items-center justify-between mb-4">
@@ -67,8 +66,10 @@
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
>
<div
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4"
id="book-cover-container"
class="relative w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100 group"
>
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
<svg
class="w-20 h-20 text-white opacity-80"
fill="none"
@@ -83,13 +84,14 @@
></path>
</svg>
</div>
</div>
<input type="file" id="cover-file-input" class="hidden" accept="image/*" />
<div
id="book-status-container"
class="relative w-full flex justify-center z-10 mb-4"
></div>
<div id="book-actions-container" class="w-full"></div>
</div>
<div class="flex-1 w-full">
<div
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
@@ -107,6 +109,10 @@
id="book-authors-text"
class="text-lg text-gray-600 font-medium mb-6"
></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">
<h3
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
@@ -297,3 +303,8 @@
{% endblock %} {% block scripts %}
<script src="/static/page/book.js"></script>
{% endblock %}
{% block extra_head %}
{% if img %}
<meta property="og:image" content="{{ request.url.scheme }}://{{ domain }}/static/books/{{ img }}.jpg" />
{% endif %}
{% endblock %}
+111 -2
View File
@@ -1,5 +1,37 @@
{% extends "base.html" %} {% block title %}LiB - Книги{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<style>
.range-double {
height: 0;
pointer-events: none;
appearance: none;
-webkit-appearance: none;
}
.range-double::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 8px;
height: 8px;
border-radius: 100%;
background: #4b5563;
border: 1px solid #ffffff;
box-shadow: 0 0 0 1px #4b5563;
cursor: pointer;
pointer-events: auto;
}
.range-double::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 100%;
background: #4b5563;
border: 1px solid #ffffff;
box-shadow: 0 0 0 1px #4b5563;
cursor: pointer;
pointer-events: auto;
}
.range-double::-moz-range-track {
background: transparent;
}
</style>
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
<aside class="w-full md:w-1/4">
<div
@@ -88,6 +120,49 @@
</svg>
</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">
<h2 class="text-xl font-bold mb-4">Авторы</h2>
<div
@@ -152,6 +227,10 @@
<span class="font-medium">Авторы:</span>
<span class="book-authors"></span>
</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
class="book-desc text-gray-700 text-sm mb-2 line-clamp-3"
></p>
@@ -188,4 +267,34 @@
</template>
{% endblock %} {% block scripts %}
<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 %}
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Создание автора{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<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="mb-8 border-b border-gray-100 pb-4">
+17 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Создание книги{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<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="mb-8 border-b border-gray-100 pb-4">
@@ -69,6 +68,22 @@
>
</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="bg-white p-4 rounded-lg border border-gray-200">
<h2 class="text-sm font-semibold text-gray-700 mb-3">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Создание жанра{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<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="mb-8 border-b border-gray-100 pb-4">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Редактирование автора{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<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="mb-8 border-b border-gray-100 pb-4">
+18 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Редактирование книги{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<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="mb-8 border-b border-gray-100 pb-4">
@@ -78,6 +77,23 @@
</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>
<label
for="book-status"
+2 -3
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Редактирование жанра{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<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="mb-8 border-b border-gray-100 pb-4">
@@ -120,7 +119,7 @@
</button>
<a
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"
>
Отмена
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %}
{% extends "base.html" %}
{% block content %}
<div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-4xl">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Мои книги{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-6xl">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-2xl"
x-data="{ showPasswordModal: false, showDisable2FAModal: false, showRecoveryCodesModal: false, is2FAEnabled: false, recoveryCodesRemaining: null }"
@update-2fa.window="is2FAEnabled = $event.detail"
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}{% block content %}
<div class="flex flex-1 items-center justify-center p-4 min-h-[70vh]">
<div class="w-full max-w-2xl">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="p-8 text-center">
<div class="mb-6 relative">
<svg id="canvas" viewBox="-250 -50 500 100" style="width: 70vmin; height: 25vmin; max-width: 600px; max-height: 600px"></svg>
</div>
<h1 class="text-3xl font-bold text-gray-800 mb-3">
Страница не найдена
</h1>
<p class="text-gray-500 mb-2">
К сожалению, запрашиваемая страница не существует.
</p>
<p class="text-gray-400 text-sm mb-8">
Возможно, она была удалена или вы ввели неверный адрес.
</p>
<div class="bg-gray-100 rounded-lg px-4 py-3 mb-8 inline-block">
<code id="pathh" class="text-gray-600 text-sm">
<span class="text-gray-400">Путь:</span>
{{ request.url.path }}
</code>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<button
onclick="history.back()"
class="inline-flex items-center justify-center px-6 py-3 bg-white text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition duration-200 font-medium shadow-sm hover:shadow-md transform hover:-translate-y-0.5"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Назад
</button>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition duration-200 font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
На главную
</a>
</div>
</div>
<div class="bg-gray-50 px-8 py-6 border-t border-gray-200">
<p class="text-gray-500 text-sm text-center mb-4">Возможно, вы искали:</p>
<div class="flex flex-wrap justify-center gap-3">
<a href="/books" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
Книги
</a>
<a href="/authors" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
Авторы
</a>
<a href="/api" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
API
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/page/unknown.js"></script>
{% endblock %}
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Пользователи{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">
-6
View File
@@ -1,6 +0,0 @@
def main():
print("Hello from libraryapi!")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
import sqlmodel, pgvector
${imports if imports else ""}
# revision identifiers, used by Alembic.
+47 -22
View File
@@ -5,6 +5,7 @@ Revises: b838606ad8d1
Create Date: 2025-12-20 10:36:30.853896
"""
from typing import Sequence, Union
from alembic import op
@@ -13,39 +14,63 @@ import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '02ed6e775351'
down_revision: Union[str, None] = 'b838606ad8d1'
revision: str = "02ed6e775351"
down_revision: Union[str, None] = "b838606ad8d1"
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! ###
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
book_status_enum.create(op.get_bind())
op.create_table('book_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')
book_status_enum = sa.Enum(
"active",
"borrowed",
"reserved",
"restoration",
"written_off",
name="bookstatus",
)
op.create_index(op.f('ix_book_loans_id'), 'book_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')
book_status_enum.create(op.get_bind())
op.create_table(
"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 ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True)
op.drop_column('book', 'status')
op.drop_index(op.f('ix_book_loans_id'), table_name='book_loans')
op.drop_table('book_loans')
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True)
op.drop_column("book", "status")
op.drop_index(op.f("ix_loans_id"), table_name="loans")
op.drop_table("loans")
book_status_enum = sa.Enum(
"active",
"borrowed",
"reserved",
"restoration",
"written_off",
name="bookstatus",
)
book_status_enum.drop(op.get_bind())
# ### 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
Revises: a8e40ab24138
@@ -27,7 +27,7 @@ def upgrade() -> None:
op.add_column(
"users",
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(
@@ -1,10 +1,11 @@
"""role payroll
"""Role payroll
Revision ID: a8e40ab24138
Revises: 02ed6e775351
Create Date: 2025-12-20 13:44:13.807704
"""
from typing import Sequence, Union
from alembic import op
@@ -13,29 +14,49 @@ import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a8e40ab24138'
down_revision: Union[str, None] = '02ed6e775351'
revision: str = "a8e40ab24138"
down_revision: Union[str, None] = "02ed6e775351"
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.alter_column('book', 'status',
existing_type=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
op.alter_column(
"book",
"status",
existing_type=postgresql.ENUM(
"active",
"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))
existing_server_default=sa.text("'active'::bookstatus"),
)
op.add_column("roles", sa.Column("payroll", sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('roles', 'payroll')
op.alter_column('book', 'status',
op.drop_column("roles", "payroll")
op.alter_column(
"book",
"status",
existing_type=sa.String(),
type_=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
type_=postgresql.ENUM(
"active",
"borrowed",
"reserved",
"restoration",
"written_off",
name="bookstatus",
),
existing_nullable=False,
existing_server_default=sa.text("'active'::bookstatus"))
existing_server_default=sa.text("'active'::bookstatus"),
)
# ### end Alembic commands ###
@@ -0,0 +1,33 @@
"""Book preview
Revision ID: abbc38275032
Revises: 6c616cc9d1f0
Create Date: 2026-02-01 14:41:14.611420
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel, pgvector
# revision identifiers, used by Alembic.
revision: str = 'abbc38275032'
down_revision: Union[str, None] = '6c616cc9d1f0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('book', sa.Column('preview_id', sa.Uuid(), nullable=True))
op.create_index(op.f('ix_book_preview_id'), 'book', ['preview_id'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_book_preview_id'), table_name='book')
op.drop_column('book', 'preview_id')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Book page_count
Revision ID: c5dfc16bdc66
Revises: a585fd97b88c
Create Date: 2026-01-23 00:09:14.192263
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = "c5dfc16bdc66"
down_revision: Union[str, None] = "a585fd97b88c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("book", sa.Column("page_count", sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("book", "page_count")
# ### end Alembic commands ###
+6 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "LibraryAPI"
version = "0.5.0"
name = "LiB"
version = "0.9.0"
description = "Это простое API для управления авторами, книгами и их жанрами."
authors = [{ name = "wowlikon" }]
readme = "README.md"
@@ -21,6 +21,10 @@ dependencies = [
"aiofiles>=25.1.0",
"qrcode[pil]>=8.2",
"pyotp>=2.9.0",
"slowapi>=0.1.9",
"limits>=5.6.0",
"ollama>=0.6.1",
"pgvector>=0.4.2",
]
[dependency-groups]
+101
View File
@@ -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
View File
@@ -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 логики этого достаточно.
View File
-27
View File
@@ -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()
View File
-44
View File
@@ -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
-46
View File
@@ -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
-44
View File
@@ -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
-40
View File
@@ -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}
View File
-53
View File
@@ -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()
-167
View File
@@ -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()
-101
View File
@@ -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"
-118
View File
@@ -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"
-50
View File
@@ -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"
-106
View File
@@ -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
Generated
+203 -6
View File
@@ -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" },
]
[[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]]
name = "dill"
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/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/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/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" },
@@ -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/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/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/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" },
@@ -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/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/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/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" },
@@ -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/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/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/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" },
@@ -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" }
[[package]]
name = "libraryapi"
version = "0.5.0"
name = "lib"
version = "0.9.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
@@ -627,13 +635,17 @@ dependencies = [
{ name = "fastapi", extra = ["all"] },
{ name = "jinja2" },
{ name = "json-log-formatter" },
{ name = "limits" },
{ name = "ollama" },
{ name = "passlib", extra = ["argon2"] },
{ name = "pgvector" },
{ name = "psycopg2-binary" },
{ name = "pydantic", extra = ["email"] },
{ name = "pyotp" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "qrcode", extra = ["pil"] },
{ name = "slowapi" },
{ name = "sqlmodel" },
{ name = "toml" },
{ name = "uvicorn", extra = ["standard"] },
@@ -655,13 +667,17 @@ requires-dist = [
{ name = "fastapi", extras = ["all"], specifier = ">=0.115.14" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ 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 = "pgvector", specifier = ">=0.4.2" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.5" },
{ name = "pyotp", specifier = ">=2.9.0" },
{ name = "python-dotenv", specifier = ">=0.21.1" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
{ name = "slowapi", specifier = ">=0.1.9" },
{ name = "sqlmodel", specifier = ">=0.0.31" },
{ name = "toml", specifier = ">=0.10.2" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" },
@@ -676,6 +692,20 @@ dev = [
{ 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]]
name = "mako"
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" },
]
[[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]]
name = "orjson"
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" },
]
[[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]]
name = "pillow"
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" },
]
[[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]]
name = "sqlalchemy"
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/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" },
]