mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Динамическое создание er-диаграммы по моделям
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Postgres
|
# Postgres
|
||||||
POSTGRES_HOST="localhost"
|
POSTGRES_HOST="db"
|
||||||
POSTGRES_PORT="5432"
|
POSTGRES_PORT="5432"
|
||||||
POSTGRES_USER="postgres"
|
POSTGRES_USER="postgres"
|
||||||
POSTGRES_PASSWORD="postgres"
|
POSTGRES_PASSWORD="postgres"
|
||||||
@@ -9,7 +9,7 @@ POSTGRES_DB="lib"
|
|||||||
# DEFAULT_ADMIN_USERNAME="admin"
|
# DEFAULT_ADMIN_USERNAME="admin"
|
||||||
# DEFAULT_ADMIN_EMAIL="admin@example.com"
|
# DEFAULT_ADMIN_EMAIL="admin@example.com"
|
||||||
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
|
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
|
||||||
SECRET_KEY="your-secret-key-change-in-production"
|
# SECRET_KEY="your-secret-key-change-in-production"
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
ALGORITHM="HS256"
|
ALGORITHM="HS256"
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
|
|
||||||
1. Клонируйте репозиторий:
|
1. Клонируйте репозиторий:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/wowlikon/libraryapi.git
|
git clone https://github.com/wowlikon/LiB.git
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Перейдите в каталог проекта:
|
2. Перейдите в каталог проекта:
|
||||||
```bash
|
```bash
|
||||||
cd libraryapi
|
cd LiB
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Настройте переменные окружения:
|
3. Настройте переменные окружения:
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
Для создания новых миграций:
|
Для создания новых миграций:
|
||||||
```bash
|
```bash
|
||||||
alembic revision --autogenerate -m "Migration name"
|
uv run alembic revision --autogenerate -m "Migration name"
|
||||||
```
|
```
|
||||||
|
|
||||||
Для запуска тестов:
|
Для запуска тестов:
|
||||||
@@ -52,14 +52,9 @@
|
|||||||
docker compose up test
|
docker compose up test
|
||||||
```
|
```
|
||||||
|
|
||||||
Для добавления данных для примера используйте:
|
|
||||||
```bash
|
|
||||||
python data.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Роли пользователей**
|
### **Роли пользователей**
|
||||||
|
|
||||||
- **Админ**: Полный доступ ко всем функциям системы
|
- **admin**: Полный доступ ко всем функциям системы
|
||||||
- **librarian**: Управление книгами, авторами, жанрами и выдачами
|
- **librarian**: Управление книгами, авторами, жанрами и выдачами
|
||||||
- **member**: Просмотр каталога и управление своими выдачами
|
- **member**: Просмотр каталога и управление своими выдачами
|
||||||
|
|
||||||
@@ -166,10 +161,11 @@
|
|||||||
|
|
||||||
#### **Прочее** (`/api`)
|
#### **Прочее** (`/api`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|-------|----------|-----------|----------------------|
|
|-------|-----------|-----------|----------------------|
|
||||||
| GET | `/info` | Публичный | Информация о сервисе |
|
| GET | `/info` | Публичный | Информация о сервисе |
|
||||||
| GET | `/stats` | Публичный | Статистика системы |
|
| GET | `/stats` | Публичный | Статистика системы |
|
||||||
|
| GET | `/schema` | Публичный | Схема базы данных |
|
||||||
|
|
||||||
### **Веб-страницы**
|
### **Веб-страницы**
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -40,25 +40,6 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy: # Рекомендуется использовать через реверс-прокси
|
proxy: # Рекомендуется использовать через реверс-прокси
|
||||||
name: proxy
|
name: proxy
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Модуль DB-моделей авторов"""
|
"""Модуль DB-моделей авторов"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from sqlmodel import Field, Relationship
|
from sqlmodel import Field, Relationship
|
||||||
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class Author(AuthorBase, table=True):
|
class Author(AuthorBase, table=True):
|
||||||
"""Модель автора в базе данных"""
|
"""Модель автора в базе данных"""
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
|
||||||
|
id: int | None = Field(
|
||||||
|
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||||
|
)
|
||||||
books: List["Book"] = Relationship(
|
books: List["Book"] = Relationship(
|
||||||
back_populates="authors", link_model=AuthorBookLink
|
back_populates="authors", link_model=AuthorBookLink
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ if TYPE_CHECKING:
|
|||||||
class Book(BookBase, table=True):
|
class Book(BookBase, table=True):
|
||||||
"""Модель книги в базе данных"""
|
"""Модель книги в базе данных"""
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
id: int | None = Field(
|
||||||
|
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||||
|
)
|
||||||
status: BookStatus = Field(
|
status: BookStatus = Field(
|
||||||
default=BookStatus.ACTIVE,
|
default=BookStatus.ACTIVE,
|
||||||
sa_column=Column(String, nullable=False, default="active"),
|
sa_column=Column(String, nullable=False, default="active"),
|
||||||
|
description="Статус",
|
||||||
)
|
)
|
||||||
authors: List["Author"] = Relationship(
|
authors: List["Author"] = Relationship(
|
||||||
back_populates="books", link_model=AuthorBookLink
|
back_populates="books", link_model=AuthorBookLink
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Модуль DB-моделей жанров"""
|
"""Модуль DB-моделей жанров"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from sqlmodel import Field, Relationship
|
from sqlmodel import Field, Relationship
|
||||||
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class Genre(GenreBase, table=True):
|
class Genre(GenreBase, table=True):
|
||||||
"""Модель жанра в базе данных"""
|
"""Модель жанра в базе данных"""
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
|
||||||
|
id: int | None = Field(
|
||||||
|
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||||
|
)
|
||||||
books: List["Book"] = Relationship(
|
books: List["Book"] = Relationship(
|
||||||
back_populates="genres", link_model=GenreBookLink
|
back_populates="genres", link_model=GenreBookLink
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,14 +10,29 @@ class AuthorBookLink(SQLModel, table=True):
|
|||||||
author_id: int | None = Field(
|
author_id: int | None = Field(
|
||||||
default=None, foreign_key="author.id", primary_key=True
|
default=None, foreign_key="author.id", primary_key=True
|
||||||
)
|
)
|
||||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
book_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
foreign_key="book.id",
|
||||||
|
primary_key=True,
|
||||||
|
description="Идентификатор книги",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GenreBookLink(SQLModel, table=True):
|
class GenreBookLink(SQLModel, table=True):
|
||||||
"""Модель связи жанра и книги"""
|
"""Модель связи жанра и книги"""
|
||||||
|
|
||||||
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
|
genre_id: int | None = Field(
|
||||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
default=None,
|
||||||
|
foreign_key="genre.id",
|
||||||
|
primary_key=True,
|
||||||
|
description="Идентификатор жанра",
|
||||||
|
)
|
||||||
|
book_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
foreign_key="book.id",
|
||||||
|
primary_key=True,
|
||||||
|
description="Идентификатор книги",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserRoleLink(SQLModel, table=True):
|
class UserRoleLink(SQLModel, table=True):
|
||||||
@@ -25,8 +40,18 @@ class UserRoleLink(SQLModel, table=True):
|
|||||||
|
|
||||||
__tablename__ = "user_roles"
|
__tablename__ = "user_roles"
|
||||||
|
|
||||||
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
|
user_id: int | None = Field(
|
||||||
role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
|
default=None,
|
||||||
|
foreign_key="users.id",
|
||||||
|
primary_key=True,
|
||||||
|
description="Идентификатор пользователя",
|
||||||
|
)
|
||||||
|
role_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
foreign_key="roles.id",
|
||||||
|
primary_key=True,
|
||||||
|
description="Идентификатор роли",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookUserLink(SQLModel, table=True):
|
class BookUserLink(SQLModel, table=True):
|
||||||
@@ -35,13 +60,22 @@ class BookUserLink(SQLModel, table=True):
|
|||||||
Связывает книгу и пользователя с фиксацией времени.
|
Связывает книгу и пользователя с фиксацией времени.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "book_loans"
|
__tablename__ = "loans"
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
id: int | None = Field(
|
||||||
|
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||||
|
)
|
||||||
|
|
||||||
book_id: int = Field(foreign_key="book.id")
|
book_id: int = Field(foreign_key="book.id", description="Идентификатор")
|
||||||
user_id: int = Field(foreign_key="users.id")
|
user_id: int = Field(
|
||||||
|
foreign_key="users.id", description="Идентификатор пользователя"
|
||||||
|
)
|
||||||
|
|
||||||
borrowed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
borrowed_at: datetime = Field(
|
||||||
due_date: datetime
|
default_factory=lambda: datetime.now(timezone.utc),
|
||||||
returned_at: datetime | None = Field(default=None)
|
description="Дата и время выдачи",
|
||||||
|
)
|
||||||
|
due_date: datetime = Field(description="Дата и время запланированного возврата")
|
||||||
|
returned_at: datetime | None = Field(
|
||||||
|
default=None, description="Дата и время фактического возврата"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Модуль DB-моделей ролей"""
|
"""Модуль DB-моделей ролей"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from sqlmodel import Field, Relationship
|
from sqlmodel import Field, Relationship
|
||||||
@@ -12,8 +13,11 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class Role(RoleBase, table=True):
|
class Role(RoleBase, table=True):
|
||||||
"""Модель роли в базе данных"""
|
"""Модель роли в базе данных"""
|
||||||
|
|
||||||
__tablename__ = "roles"
|
__tablename__ = "roles"
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
id: int | None = Field(
|
||||||
|
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||||
|
)
|
||||||
|
|
||||||
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
|
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
|
||||||
|
|||||||
@@ -17,17 +17,32 @@ class User(UserBase, table=True):
|
|||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
id: int | None = Field(
|
||||||
hashed_password: str = Field(nullable=False)
|
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||||
is_2fa_enabled: bool = Field(default=False)
|
)
|
||||||
totp_secret: str | None = Field(default=None, max_length=80)
|
hashed_password: str = Field(nullable=False, description="Argon2id хэш пароля")
|
||||||
recovery_code_hashes: str | None = Field(default=None, max_length=1500)
|
is_2fa_enabled: bool = Field(default=False, description="Включен TOTP 2FA")
|
||||||
recovery_codes_generated_at: datetime | None = Field(default=None)
|
totp_secret: str | None = Field(
|
||||||
is_active: bool = Field(default=True)
|
default=None, max_length=80, description="Зашифрованный секрет TOTP"
|
||||||
is_verified: bool = Field(default=False)
|
)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
recovery_code_hashes: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=1500,
|
||||||
|
description="Argon2id хэши одноразовыхкодов восстановления",
|
||||||
|
)
|
||||||
|
recovery_codes_generated_at: datetime | None = Field(
|
||||||
|
default=None, description="Дата и время создания кодов восстановления"
|
||||||
|
)
|
||||||
|
is_active: bool = Field(default=True, description="Не является ли заблокированым")
|
||||||
|
is_verified: bool = Field(default=False, description="Является ли верифицированным")
|
||||||
|
created_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(timezone.utc),
|
||||||
|
description="Дата и время создания",
|
||||||
|
)
|
||||||
updated_at: datetime | None = Field(
|
updated_at: datetime | None = Field(
|
||||||
default=None, sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)}
|
default=None,
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)},
|
||||||
|
description="Дата и время последнего обновления",
|
||||||
)
|
)
|
||||||
|
|
||||||
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
|
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"""Модуль DTO-моделей авторов"""
|
"""Модуль DTO-моделей авторов"""
|
||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
||||||
class AuthorBase(SQLModel):
|
class AuthorBase(SQLModel):
|
||||||
"""Базовая модель автора"""
|
"""Базовая модель автора"""
|
||||||
name: str
|
|
||||||
|
name: str = Field(description="Псевдоним")
|
||||||
|
|
||||||
model_config = ConfigDict( # pyright: ignore
|
model_config = ConfigDict( # pyright: ignore
|
||||||
json_schema_extra={"example": {"name": "author_name"}}
|
json_schema_extra={"example": {"name": "author_name"}}
|
||||||
@@ -16,20 +18,24 @@ class AuthorBase(SQLModel):
|
|||||||
|
|
||||||
class AuthorCreate(AuthorBase):
|
class AuthorCreate(AuthorBase):
|
||||||
"""Модель автора для создания"""
|
"""Модель автора для создания"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AuthorUpdate(SQLModel):
|
class AuthorUpdate(SQLModel):
|
||||||
"""Модель автора для обновления"""
|
"""Модель автора для обновления"""
|
||||||
name: str | None = None
|
|
||||||
|
name: str | None = Field(None, description="Псевдоним")
|
||||||
|
|
||||||
|
|
||||||
class AuthorRead(AuthorBase):
|
class AuthorRead(AuthorBase):
|
||||||
"""Модель автора для чтения"""
|
"""Модель автора для чтения"""
|
||||||
id: int
|
|
||||||
|
id: int = Field(description="Идентификатор")
|
||||||
|
|
||||||
|
|
||||||
class AuthorList(SQLModel):
|
class AuthorList(SQLModel):
|
||||||
"""Список авторов"""
|
"""Список авторов"""
|
||||||
authors: List[AuthorRead]
|
|
||||||
total: int
|
authors: List[AuthorRead] = Field(description="Список авторов")
|
||||||
|
total: int = Field(description="Количество авторов")
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ from library_service.models.enums import BookStatus
|
|||||||
class BookBase(SQLModel):
|
class BookBase(SQLModel):
|
||||||
"""Базовая модель книги"""
|
"""Базовая модель книги"""
|
||||||
|
|
||||||
title: str
|
title: str = Field(description="Название")
|
||||||
description: str
|
description: str = Field(description="Описание")
|
||||||
page_count: int = Field(gt=0)
|
page_count: int = Field(gt=0, description="Количество страниц")
|
||||||
|
|
||||||
model_config = ConfigDict( # pyright: ignore
|
model_config = ConfigDict( # pyright: ignore
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
@@ -35,21 +35,21 @@ class BookCreate(BookBase):
|
|||||||
class BookUpdate(SQLModel):
|
class BookUpdate(SQLModel):
|
||||||
"""Модель книги для обновления"""
|
"""Модель книги для обновления"""
|
||||||
|
|
||||||
title: str | None = None
|
title: str | None = Field(None, description="Название")
|
||||||
description: str | None = None
|
description: str | None = Field(None, description="Описание")
|
||||||
page_count: int | None = None
|
page_count: int | None = Field(None, description="Количество страниц")
|
||||||
status: BookStatus | None = None
|
status: BookStatus | None = Field(None, description="Статус")
|
||||||
|
|
||||||
|
|
||||||
class BookRead(BookBase):
|
class BookRead(BookBase):
|
||||||
"""Модель книги для чтения"""
|
"""Модель книги для чтения"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
status: BookStatus
|
status: BookStatus = Field(description="Статус")
|
||||||
|
|
||||||
|
|
||||||
class BookList(SQLModel):
|
class BookList(SQLModel):
|
||||||
"""Список книг"""
|
"""Список книг"""
|
||||||
|
|
||||||
books: List[BookRead]
|
books: List[BookRead] = Field(description="Список книг")
|
||||||
total: int
|
total: int = Field(description="Количество книг")
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"""Модуль DTO-моделей жанров"""
|
"""Модуль DTO-моделей жанров"""
|
||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
||||||
class GenreBase(SQLModel):
|
class GenreBase(SQLModel):
|
||||||
"""Базовая модель жанра"""
|
"""Базовая модель жанра"""
|
||||||
name: str
|
|
||||||
|
name: str = Field(description="Название")
|
||||||
|
|
||||||
model_config = ConfigDict( # pyright: ignore
|
model_config = ConfigDict( # pyright: ignore
|
||||||
json_schema_extra={"example": {"name": "genre_name"}}
|
json_schema_extra={"example": {"name": "genre_name"}}
|
||||||
@@ -16,20 +18,24 @@ class GenreBase(SQLModel):
|
|||||||
|
|
||||||
class GenreCreate(GenreBase):
|
class GenreCreate(GenreBase):
|
||||||
"""Модель жанра для создания"""
|
"""Модель жанра для создания"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class GenreUpdate(SQLModel):
|
class GenreUpdate(SQLModel):
|
||||||
"""Модель жанра для обновления"""
|
"""Модель жанра для обновления"""
|
||||||
name: str | None = None
|
|
||||||
|
name: str | None = Field(None, description="Название")
|
||||||
|
|
||||||
|
|
||||||
class GenreRead(GenreBase):
|
class GenreRead(GenreBase):
|
||||||
"""Модель жанра для чтения"""
|
"""Модель жанра для чтения"""
|
||||||
id: int
|
|
||||||
|
id: int = Field(description="Идентификатор")
|
||||||
|
|
||||||
|
|
||||||
class GenreList(SQLModel):
|
class GenreList(SQLModel):
|
||||||
"""Списко жанров"""
|
"""Списко жанров"""
|
||||||
genres: List[GenreRead]
|
|
||||||
total: int
|
genres: List[GenreRead] = Field(description="Список жанров")
|
||||||
|
total: int = Field(description="Количество жанров")
|
||||||
|
|||||||
@@ -1,37 +1,49 @@
|
|||||||
"""Модуль DTO-моделей для выдачи книг"""
|
"""Модуль DTO-моделей для выдачи книг"""
|
||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
||||||
class LoanBase(SQLModel):
|
class LoanBase(SQLModel):
|
||||||
"""Базовая модель выдачи"""
|
"""Базовая модель выдачи"""
|
||||||
book_id: int
|
|
||||||
user_id: int
|
book_id: int = Field(description="Идентификатор книги")
|
||||||
due_date: datetime
|
user_id: int = Field(description="Идентификатор пользователя")
|
||||||
|
due_date: datetime = Field(description="Дата и время планируемого возврата")
|
||||||
|
|
||||||
|
|
||||||
class LoanCreate(LoanBase):
|
class LoanCreate(LoanBase):
|
||||||
"""Модель для создания записи о выдаче"""
|
"""Модель для создания записи о выдаче"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LoanUpdate(SQLModel):
|
class LoanUpdate(SQLModel):
|
||||||
"""Модель для обновления записи о выдаче"""
|
"""Модель для обновления записи о выдаче"""
|
||||||
user_id: int | None = None
|
|
||||||
due_date: datetime | None = None
|
user_id: int | None = Field(None, description="Идентификатор пользователя")
|
||||||
returned_at: datetime | None = None
|
due_date: datetime | None = Field(
|
||||||
|
None, description="дата и время планируемого возврата"
|
||||||
|
)
|
||||||
|
returned_at: datetime | None = Field(
|
||||||
|
None, description="Дата и время фактического возврата"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoanRead(LoanBase):
|
class LoanRead(LoanBase):
|
||||||
"""Модель чтения записи о выдаче"""
|
"""Модель чтения записи о выдаче"""
|
||||||
id: int
|
|
||||||
borrowed_at: datetime
|
id: int = Field(description="Идентификатор")
|
||||||
returned_at: datetime | None = None
|
borrowed_at: datetime = Field(description="Дата и время выдачи")
|
||||||
|
returned_at: datetime | None = Field(
|
||||||
|
None, description="Дата и время фактического возврата"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoanList(SQLModel):
|
class LoanList(SQLModel):
|
||||||
"""Список выдач"""
|
"""Список выдач"""
|
||||||
loans: List[LoanRead]
|
|
||||||
total: int
|
loans: List[LoanRead] = Field(description="Список выдач")
|
||||||
|
total: int = Field(description="Количество выдач")
|
||||||
|
|||||||
@@ -18,130 +18,142 @@ from .recovery import RecoveryCodesResponse
|
|||||||
class AuthorWithBooks(SQLModel):
|
class AuthorWithBooks(SQLModel):
|
||||||
"""Модель автора с книгами"""
|
"""Модель автора с книгами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
name: str
|
name: str = Field(description="Псевдоним")
|
||||||
books: List[BookRead] = Field(default_factory=list)
|
books: List[BookRead] = Field(default_factory=list, description="Список книг")
|
||||||
|
|
||||||
|
|
||||||
class GenreWithBooks(SQLModel):
|
class GenreWithBooks(SQLModel):
|
||||||
"""Модель жанра с книгами"""
|
"""Модель жанра с книгами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
name: str
|
name: str = Field(description="Название")
|
||||||
books: List[BookRead] = Field(default_factory=list)
|
books: List[BookRead] = Field(default_factory=list, description="Список книг")
|
||||||
|
|
||||||
|
|
||||||
class BookWithAuthors(SQLModel):
|
class BookWithAuthors(SQLModel):
|
||||||
"""Модель книги с авторами"""
|
"""Модель книги с авторами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
title: str
|
title: str = Field(description="Название")
|
||||||
description: str
|
description: str = Field(description="Описание")
|
||||||
page_count: int
|
page_count: int = Field(description="Количество страниц")
|
||||||
authors: List[AuthorRead] = Field(default_factory=list)
|
status: BookStatus | None = Field(None, description="Статус")
|
||||||
|
authors: List[AuthorRead] = Field(
|
||||||
|
default_factory=list, description="Список авторов"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookWithGenres(SQLModel):
|
class BookWithGenres(SQLModel):
|
||||||
"""Модель книги с жанрами"""
|
"""Модель книги с жанрами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
title: str
|
title: str = Field(description="Название")
|
||||||
description: str
|
description: str = Field(description="Описание")
|
||||||
page_count: int
|
page_count: int = Field(description="Количество страниц")
|
||||||
status: BookStatus | None = None
|
status: BookStatus | None = Field(None, description="Статус")
|
||||||
genres: List[GenreRead] = Field(default_factory=list)
|
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
|
||||||
|
|
||||||
|
|
||||||
class BookWithAuthorsAndGenres(SQLModel):
|
class BookWithAuthorsAndGenres(SQLModel):
|
||||||
"""Модель с авторами и жанрами"""
|
"""Модель с авторами и жанрами"""
|
||||||
|
|
||||||
id: int
|
id: int = Field(description="Идентификатор")
|
||||||
title: str
|
title: str = Field(description="Название")
|
||||||
description: str
|
description: str = Field(description="Описание")
|
||||||
page_count: int
|
page_count: int = Field(description="Количество страниц")
|
||||||
status: BookStatus | None = None
|
status: BookStatus | None = Field(None, description="Статус")
|
||||||
authors: List[AuthorRead] = Field(default_factory=list)
|
authors: List[AuthorRead] = Field(
|
||||||
genres: List[GenreRead] = Field(default_factory=list)
|
default_factory=list, description="Список авторов"
|
||||||
|
)
|
||||||
|
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
|
||||||
|
|
||||||
|
|
||||||
class BookFilteredList(SQLModel):
|
class BookFilteredList(SQLModel):
|
||||||
"""Список книг с фильтрацией"""
|
"""Список книг с фильтрацией"""
|
||||||
|
|
||||||
books: List[BookWithAuthorsAndGenres]
|
books: List[BookWithAuthorsAndGenres] = Field(
|
||||||
total: int
|
description="Список отфильтрованных книг"
|
||||||
|
)
|
||||||
|
total: int = Field(description="Количество книг")
|
||||||
|
|
||||||
|
|
||||||
class LoanWithBook(LoanRead):
|
class LoanWithBook(LoanRead):
|
||||||
"""Модель выдачи, включающая данные о книге"""
|
"""Модель выдачи, включающая данные о книге"""
|
||||||
|
|
||||||
book: BookRead
|
book: BookRead = Field(description="Книга")
|
||||||
|
|
||||||
|
|
||||||
class BookStatusUpdate(SQLModel):
|
class BookStatusUpdate(SQLModel):
|
||||||
"""Модель для ручного изменения статуса библиотекарем"""
|
"""Модель для ручного изменения статуса библиотекарем"""
|
||||||
|
|
||||||
status: str
|
status: str = Field(description="Статус книги")
|
||||||
|
|
||||||
|
|
||||||
class UserCreateByAdmin(UserCreate):
|
class UserCreateByAdmin(UserCreate):
|
||||||
"""Создание пользователя администратором"""
|
"""Создание пользователя администратором"""
|
||||||
|
|
||||||
is_active: bool = True
|
is_active: bool = Field(True, description="Не является ли заблокированным")
|
||||||
roles: list[str] | None = None
|
roles: list[str] | None = Field(None, description="Роли")
|
||||||
|
|
||||||
|
|
||||||
class UserUpdateByAdmin(UserUpdate):
|
class UserUpdateByAdmin(UserUpdate):
|
||||||
"""Обновление пользователя администратором"""
|
"""Обновление пользователя администратором"""
|
||||||
|
|
||||||
is_active: bool | None = None
|
is_active: bool = Field(True, description="Не является ли заблокированным")
|
||||||
roles: list[str] | None = None
|
roles: list[str] | None = Field(None, description="Роли")
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(SQLModel):
|
class LoginResponse(SQLModel):
|
||||||
"""Модель для авторизации пользователя"""
|
"""Модель для авторизации пользователя"""
|
||||||
|
|
||||||
access_token: str | None = None
|
access_token: str | None = Field(None, description="Токен доступа")
|
||||||
partial_token: str | None = None
|
partial_token: str | None = Field(None, description="Частичный токен")
|
||||||
refresh_token: str | None = None
|
refresh_token: str | None = Field(None, description="Токен обновления")
|
||||||
token_type: str = "bearer"
|
token_type: str = Field("bearer", description="Тип токена")
|
||||||
requires_2fa: bool = False
|
requires_2fa: bool = Field(False, description="Требуется ли TOTP=код")
|
||||||
|
|
||||||
|
|
||||||
class RegisterResponse(SQLModel):
|
class RegisterResponse(SQLModel):
|
||||||
"""Модель для регистрации пользователя"""
|
"""Модель для регистрации пользователя"""
|
||||||
|
|
||||||
user: UserRead
|
user: UserRead = Field(description="Пользователь")
|
||||||
recovery_codes: RecoveryCodesResponse
|
recovery_codes: RecoveryCodesResponse = Field(description="Коды восстановления")
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetResponse(SQLModel):
|
class PasswordResetResponse(SQLModel):
|
||||||
"""Модель для сброса пароля"""
|
"""Модель для сброса пароля"""
|
||||||
|
|
||||||
total: int
|
total: int = Field(description="Общее количество кодов")
|
||||||
remaining: int
|
remaining: int = Field(description="Количество оставшихся кодов")
|
||||||
used_codes: list[bool]
|
used_codes: list[bool] = Field(description="Количество использованых кодов")
|
||||||
generated_at: datetime | None
|
generated_at: datetime | None = Field(description="Дата и время генерации")
|
||||||
should_regenerate: bool
|
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
|
||||||
|
|
||||||
|
|
||||||
class TOTPSetupResponse(SQLModel):
|
class TOTPSetupResponse(SQLModel):
|
||||||
"""Модель для генерации данных для настройки TOTP"""
|
"""Модель для генерации данных для настройки TOTP"""
|
||||||
|
|
||||||
secret: str
|
secret: str = Field(description="Секрет TOTP")
|
||||||
username: str
|
username: str = Field(description="Имя пользователя")
|
||||||
issuer: str
|
issuer: str = Field(description="Запрашивающий сервис")
|
||||||
size: int
|
size: int = Field(description="Размер кода")
|
||||||
padding: int
|
padding: int = Field(description="Отступ")
|
||||||
bitmap_b64: str
|
bitmap_b64: str = Field(description="QR-код")
|
||||||
|
|
||||||
|
|
||||||
class TOTPVerifyRequest(SQLModel):
|
class TOTPVerifyRequest(SQLModel):
|
||||||
"""Модель для проверки TOTP кода"""
|
"""Модель для проверки TOTP кода"""
|
||||||
|
|
||||||
code: str = Field(min_length=6, max_length=6, regex=r"^\d{6}$")
|
code: str = Field(
|
||||||
|
min_length=6,
|
||||||
|
max_length=6,
|
||||||
|
regex=r"^\d{6}$",
|
||||||
|
description="Шестизначный TOTP-код",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TOTPDisableRequest(SQLModel):
|
class TOTPDisableRequest(SQLModel):
|
||||||
"""Модель для отключения TOTP 2FA"""
|
"""Модель для отключения TOTP 2FA"""
|
||||||
|
|
||||||
password: str
|
password: str = Field(description="Пароль")
|
||||||
|
|||||||
@@ -10,26 +10,28 @@ from sqlmodel import SQLModel, Field
|
|||||||
class RecoveryCodesResponse(SQLModel):
|
class RecoveryCodesResponse(SQLModel):
|
||||||
"""Ответ при генерации резервных кодов"""
|
"""Ответ при генерации резервных кодов"""
|
||||||
|
|
||||||
codes: list[str]
|
codes: list[str] = Field(description="Список кодов восстановления")
|
||||||
generated_at: datetime
|
generated_at: datetime = Field(description="Дата и время генерации")
|
||||||
|
|
||||||
|
|
||||||
class RecoveryCodesStatus(SQLModel):
|
class RecoveryCodesStatus(SQLModel):
|
||||||
"""Статус резервных кодов пользователя"""
|
"""Статус резервных кодов пользователя"""
|
||||||
|
|
||||||
total: int
|
total: int = Field(description="Общее количество кодов")
|
||||||
remaining: int
|
remaining: int = Field(description="Количество оставшихся кодов")
|
||||||
used_codes: list[bool]
|
used_codes: list[bool] = Field(description="Количество использованых кодов")
|
||||||
generated_at: datetime | None
|
generated_at: datetime | None = Field(description="Дата и время генерации")
|
||||||
should_regenerate: bool
|
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
|
||||||
|
|
||||||
|
|
||||||
class RecoveryCodeUse(SQLModel):
|
class RecoveryCodeUse(SQLModel):
|
||||||
"""Запрос на сброс пароля через резервный код"""
|
"""Запрос на сброс пароля через резервный код"""
|
||||||
|
|
||||||
username: str
|
username: str = Field(description="Имя пользователя")
|
||||||
recovery_code: str = Field(min_length=19, max_length=19)
|
recovery_code: str = Field(
|
||||||
new_password: str = Field(min_length=8, max_length=100)
|
min_length=19, max_length=19, description="Код восстановления"
|
||||||
|
)
|
||||||
|
new_password: str = Field(min_length=8, max_length=100, description="Новый пароль")
|
||||||
|
|
||||||
@field_validator("recovery_code")
|
@field_validator("recovery_code")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
"""Модуль DTO-моделей ролей"""
|
"""Модуль DTO-моделей ролей"""
|
||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
||||||
class RoleBase(SQLModel):
|
class RoleBase(SQLModel):
|
||||||
"""Базовая модель роли"""
|
"""Базовая модель роли"""
|
||||||
name: str
|
|
||||||
description: str | None = None
|
name: str = Field(description="Название")
|
||||||
payroll: int = 0
|
description: str | None = Field(None, description="Описание")
|
||||||
|
payroll: int = Field(0, description="Оплата")
|
||||||
|
|
||||||
|
|
||||||
class RoleCreate(RoleBase):
|
class RoleCreate(RoleBase):
|
||||||
"""Модель роли для создания"""
|
"""Модель роли для создания"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class RoleUpdate(SQLModel):
|
class RoleUpdate(SQLModel):
|
||||||
"""Модель роли для обновления"""
|
"""Модель роли для обновления"""
|
||||||
name: str | None = None
|
|
||||||
|
name: str | None = Field(None, description="Название")
|
||||||
|
|
||||||
|
|
||||||
class RoleRead(RoleBase):
|
class RoleRead(RoleBase):
|
||||||
"""Модель роли для чтения"""
|
"""Модель роли для чтения"""
|
||||||
id: int
|
|
||||||
|
id: int = Field(description="Идентификатор")
|
||||||
|
|
||||||
|
|
||||||
class RoleList(SQLModel):
|
class RoleList(SQLModel):
|
||||||
"""Список ролей"""
|
"""Список ролей"""
|
||||||
roles: List[RoleRead]
|
|
||||||
total: int
|
roles: List[RoleRead] = Field(description="Список ролей")
|
||||||
|
total: int = Field(description="Количество ролей")
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
"""Модуль DTO-моделей токенов"""
|
"""Модуль DTO-моделей токенов"""
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
||||||
class Token(SQLModel):
|
class Token(SQLModel):
|
||||||
"""Модель токена"""
|
"""Модель токена"""
|
||||||
|
|
||||||
access_token: str
|
access_token: str = Field(description="Токен доступа")
|
||||||
token_type: str = "bearer"
|
token_type: str = Field("bearer", description="Тип токена")
|
||||||
refresh_token: str | None = None
|
refresh_token: str | None = Field(None, description="Токен обновления")
|
||||||
|
|
||||||
|
|
||||||
class PartialToken(SQLModel):
|
class PartialToken(SQLModel):
|
||||||
"""Частичный токен — для подтверждения 2FA"""
|
"""Частичный токен — для подтверждения 2FA"""
|
||||||
|
|
||||||
partial_token: str
|
partial_token: str = Field(description="Частичный токен")
|
||||||
token_type: str = "partial"
|
token_type: str = Field("partial", description="Тип токена")
|
||||||
requires_2fa: bool = True
|
requires_2fa: bool = Field(True, description="Требуется TOTP-код")
|
||||||
|
|
||||||
|
|
||||||
class TokenData(SQLModel):
|
class TokenData(SQLModel):
|
||||||
"""Модель содержимого токена"""
|
"""Модель содержимого токена"""
|
||||||
|
|
||||||
username: str | None = None
|
username: str | None = Field(None, description="Имя пользователя")
|
||||||
user_id: int | None = None
|
user_id: int | None = Field(None, description="Идентификатор пользователя")
|
||||||
is_partial: bool = False
|
is_partial: bool = Field(False, description="Является ли токен частичным")
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ from sqlmodel import Field, SQLModel
|
|||||||
class UserBase(SQLModel):
|
class UserBase(SQLModel):
|
||||||
"""Базовая модель пользователя"""
|
"""Базовая модель пользователя"""
|
||||||
|
|
||||||
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
|
username: str = Field(
|
||||||
email: EmailStr = Field(index=True, unique=True)
|
min_length=3,
|
||||||
full_name: str | None = Field(default=None, max_length=100)
|
max_length=50,
|
||||||
|
index=True,
|
||||||
|
unique=True,
|
||||||
|
description="Имя пользователя",
|
||||||
|
)
|
||||||
|
email: EmailStr = Field(index=True, unique=True, description="Email")
|
||||||
|
full_name: str | None = Field(
|
||||||
|
default=None, max_length=100, description="Полное имя"
|
||||||
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
@@ -28,7 +36,7 @@ class UserBase(SQLModel):
|
|||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
"""Модель пользователя для создания"""
|
"""Модель пользователя для создания"""
|
||||||
|
|
||||||
password: str = Field(min_length=8, max_length=100)
|
password: str = Field(min_length=8, max_length=100, description="Пароль")
|
||||||
|
|
||||||
@field_validator("password")
|
@field_validator("password")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -46,30 +54,30 @@ class UserCreate(UserBase):
|
|||||||
class UserLogin(SQLModel):
|
class UserLogin(SQLModel):
|
||||||
"""Модель аутентификации для пользователя"""
|
"""Модель аутентификации для пользователя"""
|
||||||
|
|
||||||
username: str
|
username: str = Field(description="Имя пользователя")
|
||||||
password: str
|
password: str = Field(description="Пароль")
|
||||||
|
|
||||||
|
|
||||||
class UserRead(UserBase):
|
class UserRead(UserBase):
|
||||||
"""Модель пользователя для чтения"""
|
"""Модель пользователя для чтения"""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
is_active: bool
|
is_active: bool = Field(description="Не является ли заблокированым")
|
||||||
is_verified: bool
|
is_verified: bool = Field(description="Является ли верифицированым")
|
||||||
is_2fa_enabled: bool
|
is_2fa_enabled: bool = Field(description="Включен ли TOTP 2FA")
|
||||||
roles: List[str] = []
|
roles: List[str] = Field([], description="Роли")
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(SQLModel):
|
class UserUpdate(SQLModel):
|
||||||
"""Модель пользователя для обновления"""
|
"""Модель пользователя для обновления"""
|
||||||
|
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = Field(None, description="Email")
|
||||||
full_name: str | None = None
|
full_name: str | None = Field(None, description="Полное имя")
|
||||||
password: str | None = None
|
password: str | None = Field(None, description="Пароль")
|
||||||
|
|
||||||
|
|
||||||
class UserList(SQLModel):
|
class UserList(SQLModel):
|
||||||
"""Список пользователей"""
|
"""Список пользователей"""
|
||||||
|
|
||||||
users: List[UserRead]
|
users: List[UserRead] = Field(description="Список пользователей")
|
||||||
total: int
|
total: int = Field(description="Количество пользователей")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Модуль прочих эндпоинтов"""
|
"""Модуль прочих эндпоинтов и веб-страниц"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -12,9 +12,12 @@ from sqlmodel import Session, select, func
|
|||||||
|
|
||||||
from library_service.settings import get_app, get_session
|
from library_service.settings import get_app, get_session
|
||||||
from library_service.models.db import Author, Book, Genre, User
|
from library_service.models.db import Author, Book, Genre, User
|
||||||
|
from library_service.services import SchemaGenerator
|
||||||
|
from library_service import models
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["misc"])
|
router = APIRouter(tags=["misc"])
|
||||||
|
generator = SchemaGenerator(models.db, models.dto)
|
||||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
||||||
|
|
||||||
|
|
||||||
@@ -133,12 +136,6 @@ async def analytics(request: Request):
|
|||||||
return templates.TemplateResponse(request, "analytics.html")
|
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))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/favicon.ico", include_in_schema=False)
|
@router.get("/favicon.ico", include_in_schema=False)
|
||||||
def redirect_favicon():
|
def redirect_favicon():
|
||||||
"""Редиректит на favicon.svg"""
|
"""Редиректит на favicon.svg"""
|
||||||
@@ -153,6 +150,12 @@ async def favicon():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api", include_in_schema=False)
|
||||||
|
async def api(request: Request, app=Depends(lambda: get_app())):
|
||||||
|
"""Рендерит страницу с ссылками на документацию API"""
|
||||||
|
return templates.TemplateResponse(request, "api.html", get_info(app))
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/info",
|
"/api/info",
|
||||||
summary="Информация о сервисе",
|
summary="Информация о сервисе",
|
||||||
@@ -163,6 +166,15 @@ async def api_info(app=Depends(lambda: get_app())):
|
|||||||
return JSONResponse(content=get_info(app))
|
return JSONResponse(content=get_info(app))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/schema",
|
||||||
|
summary="Информация о таблицах и связях",
|
||||||
|
description="Возвращает схему базы данных с описаниями полей",
|
||||||
|
)
|
||||||
|
async def api_schema():
|
||||||
|
return generator.generate()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/stats",
|
"/api/stats",
|
||||||
summary="Статистика сервиса",
|
summary="Статистика сервиса",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .captcha import (
|
|||||||
REDEEM_TTL,
|
REDEEM_TTL,
|
||||||
prng,
|
prng,
|
||||||
)
|
)
|
||||||
|
from .describe_er import SchemaGenerator
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"limiter",
|
"limiter",
|
||||||
@@ -26,4 +27,5 @@ __all__ = [
|
|||||||
"CHALLENGE_TTL",
|
"CHALLENGE_TTL",
|
||||||
"REDEEM_TTL",
|
"REDEEM_TTL",
|
||||||
"prng",
|
"prng",
|
||||||
|
"SchemaGenerator",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Модуль создания и проверки capjs"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Модуль генерации описания схемы БД"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import List, Dict, Any, Set, Type, Tuple
|
||||||
|
|
||||||
|
from pydantic.fields import FieldInfo
|
||||||
|
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 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}
|
||||||
|
|
||||||
|
if col.name in descriptions:
|
||||||
|
field_obj["tooltip"] = descriptions[col.name]
|
||||||
|
|
||||||
|
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}
|
||||||
+202
-228
@@ -2,15 +2,16 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<title id="pageTitle">Loading...</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{{ app_info.title }}</title>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
max-width: 800px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
@@ -20,15 +21,13 @@
|
|||||||
}
|
}
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
list-style-type: none;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
li {
|
li { margin: 10px 0; }
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
a {
|
a {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
@@ -37,247 +36,164 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover { background-color: #2980b9; }
|
||||||
background-color: #2980b9;
|
p { margin: 5px 0; }
|
||||||
}
|
.status-ok { color: #27ae60; }
|
||||||
p {
|
.status-error { color: #e74c3c; }
|
||||||
margin: 5px 0;
|
.server-time { color: #7f8c8d; font-size: 12px; }
|
||||||
}
|
|
||||||
#erDiagram {
|
#erDiagram {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%; height: 700px;
|
||||||
height: 420px;
|
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
background: #fafafa;
|
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;
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
#erDiagram:active { cursor: grabbing; }
|
||||||
.er-table {
|
.er-table {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #3498db;
|
border: 1px solid #bdc3c7;
|
||||||
border-radius: 4px;
|
border-radius: 5px;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.er-table-header {
|
.er-table-header {
|
||||||
background: #3498db;
|
background: #3498db;
|
||||||
color: #fff;
|
color: #ecf0f1;
|
||||||
padding: 6px 8px;
|
padding: 8px;
|
||||||
font-weight: bold;
|
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 {
|
.er-table-body {
|
||||||
padding: 6px 8px;
|
background: #fff;
|
||||||
line-height: 1.4;
|
padding: 4px 0;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
}
|
}
|
||||||
.er-field {
|
.er-field {
|
||||||
padding: 2px 0;
|
padding: 4px 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.er-field:hover {
|
||||||
|
background-color: #ecf0f1;
|
||||||
|
color: #2980b9;
|
||||||
}
|
}
|
||||||
.relation-label {
|
.relation-label {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
background: #fff;
|
font-weight: bold;
|
||||||
padding: 1px 3px;
|
background: white;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid #bdc3c7;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid #ccc;
|
color: #7f8c8d;
|
||||||
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
.jtk-connector { z-index: 5; }
|
||||||
|
.jtk-endpoint { z-index: 5; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<img src="/favicon.ico" />
|
<img src="/favicon.ico" />
|
||||||
<h1>Welcome to {{ app_info.title }}!</h1>
|
<h1 id="mainTitle">Загрузка...</h1>
|
||||||
<p>Description: {{ app_info.description }}</p>
|
<p>Версия: <span id="appVersion">-</span></p>
|
||||||
<p>Version: {{ app_info.version }}</p>
|
<p>Описание: <span id="appDescription">-</span></p>
|
||||||
<p>Current Time: {{ server_time }}</p>
|
<p>Статус: <span id="appStatus">-</span></p>
|
||||||
<p>Status: {{ status }}</p>
|
<p class="server-time">Время сервера: <span id="serverTime">-</span></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/">Home page</a></li>
|
<li><a href="/">Главная</a></li>
|
||||||
<li><a href="/docs">Swagger UI</a></li>
|
<li><a href="/docs">Swagger UI</a></li>
|
||||||
<li><a href="/redoc">ReDoc</a></li>
|
<li><a href="/redoc">ReDoc</a></li>
|
||||||
<li>
|
<li><a href="https://github.com/wowlikon/LiB">Исходный код</a></li>
|
||||||
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<h2>Интерактивная ER диаграмма</h2>
|
||||||
<h2>ER Diagram</h2>
|
|
||||||
<div id="erDiagram"></div>
|
<div id="erDiagram"></div>
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
const diagramData = {
|
async function fetchInfo() {
|
||||||
entities: [
|
try {
|
||||||
{
|
const response = await fetch('/api/info');
|
||||||
id: "User",
|
const data = await response.json();
|
||||||
title: "users",
|
|
||||||
fields: [
|
|
||||||
{ id: "id", label: "id (PK)" },
|
|
||||||
{ id: "username", label: "username" },
|
|
||||||
{ id: "email", label: "email" },
|
|
||||||
{ id: "full_name", label: "full_name" },
|
|
||||||
{ id: "is_active", label: "is_active" },
|
|
||||||
{ id: "is_verified", label: "is_verified" },
|
|
||||||
{ id: "is_2fa_enabled", label: "is_2fa_enabled" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Role",
|
|
||||||
title: "roles",
|
|
||||||
fields: [
|
|
||||||
{ id: "id", label: "id (PK)" },
|
|
||||||
{ id: "name", label: "name" },
|
|
||||||
{ id: "description", label: "description" },
|
|
||||||
{ id: "payroll", label: "payroll" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "UserRole",
|
|
||||||
title: "user_roles",
|
|
||||||
fields: [
|
|
||||||
{ id: "user_id", label: "user_id (FK)" },
|
|
||||||
{ id: "role_id", label: "role_id (FK)" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Author",
|
|
||||||
title: "authors",
|
|
||||||
fields: [
|
|
||||||
{ id: "id", label: "id (PK)" },
|
|
||||||
{ id: "name", label: "name" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Book",
|
|
||||||
title: "books",
|
|
||||||
fields: [
|
|
||||||
{ id: "id", label: "id (PK)" },
|
|
||||||
{ id: "title", label: "title" },
|
|
||||||
{ id: "description", label: "description" },
|
|
||||||
{ id: "page_count", label: "page_count" },
|
|
||||||
{ id: "status", label: "status" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Genre",
|
|
||||||
title: "genres",
|
|
||||||
fields: [
|
|
||||||
{ id: "id", label: "id (PK)" },
|
|
||||||
{ id: "name", label: "name" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Loan",
|
|
||||||
title: "loans",
|
|
||||||
fields: [
|
|
||||||
{ id: "id", label: "id (PK)" },
|
|
||||||
{ id: "book_id", label: "book_id (FK)" },
|
|
||||||
{ id: "user_id", label: "user_id (FK)" },
|
|
||||||
{ id: "borrowed_at", label: "borrowed_at" },
|
|
||||||
{ id: "due_date", label: "due_date" },
|
|
||||||
{ id: "returned_at", label: "returned_at" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "AuthorBook",
|
|
||||||
title: "authors_books",
|
|
||||||
fields: [
|
|
||||||
{ id: "author_id", label: "author_id (FK)" },
|
|
||||||
{ id: "book_id", label: "book_id (FK)" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "GenreBook",
|
|
||||||
title: "genres_books",
|
|
||||||
fields: [
|
|
||||||
{ id: "genre_id", label: "genre_id (FK)" },
|
|
||||||
{ id: "book_id", label: "book_id (FK)" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
relations: [
|
|
||||||
{
|
|
||||||
fromEntity: "Loan",
|
|
||||||
fromField: "book_id",
|
|
||||||
toEntity: "Book",
|
|
||||||
toField: "id",
|
|
||||||
fromMultiplicity: "N",
|
|
||||||
toMultiplicity: "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fromEntity: "Loan",
|
|
||||||
fromField: "user_id",
|
|
||||||
toEntity: "User",
|
|
||||||
toField: "id",
|
|
||||||
fromMultiplicity: "N",
|
|
||||||
toMultiplicity: "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fromEntity: "AuthorBook",
|
|
||||||
fromField: "author_id",
|
|
||||||
toEntity: "Author",
|
|
||||||
toField: "id",
|
|
||||||
fromMultiplicity: "N",
|
|
||||||
toMultiplicity: "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fromEntity: "AuthorBook",
|
|
||||||
fromField: "book_id",
|
|
||||||
toEntity: "Book",
|
|
||||||
toField: "id",
|
|
||||||
fromMultiplicity: "N",
|
|
||||||
toMultiplicity: "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fromEntity: "GenreBook",
|
|
||||||
fromField: "genre_id",
|
|
||||||
toEntity: "Genre",
|
|
||||||
toField: "id",
|
|
||||||
fromMultiplicity: "N",
|
|
||||||
toMultiplicity: "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fromEntity: "GenreBook",
|
|
||||||
fromField: "book_id",
|
|
||||||
toEntity: "Book",
|
|
||||||
toField: "id",
|
|
||||||
fromMultiplicity: "N",
|
|
||||||
toMultiplicity: "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fromEntity: "UserRole",
|
|
||||||
fromField: "user_id",
|
|
||||||
toEntity: "User",
|
|
||||||
toField: "id",
|
|
||||||
fromMultiplicity: "N",
|
|
||||||
toMultiplicity: "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fromEntity: "UserRole",
|
|
||||||
fromField: "role_id",
|
|
||||||
toEntity: "Role",
|
|
||||||
toField: "id",
|
|
||||||
fromMultiplicity: "N",
|
|
||||||
toMultiplicity: "1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
|
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 () {
|
jsPlumb.ready(function () {
|
||||||
jsPlumb.setContainer("erDiagram");
|
const instance = jsPlumb.getInstance({
|
||||||
|
Container: "erDiagram",
|
||||||
|
Connector: ["Flowchart", { stub: 30, gap: 10, cornerRadius: 5, alwaysRespectStubs: true }],
|
||||||
|
ConnectionOverlays: [["Arrow", { location: 1, width: 10, length: 10, foldback: 0.8 }]]
|
||||||
|
});
|
||||||
|
|
||||||
const container = document.getElementById("erDiagram");
|
const container = document.getElementById("erDiagram");
|
||||||
const baseLeft = 40;
|
const tableWidth = 200;
|
||||||
const baseTop = 80;
|
|
||||||
const spacingX = 240;
|
|
||||||
|
|
||||||
diagramData.entities.forEach((entity, index) => {
|
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");
|
const table = document.createElement("div");
|
||||||
table.className = "er-table";
|
table.className = "er-table";
|
||||||
table.id = "table-" + entity.id;
|
table.id = "table-" + entity.id;
|
||||||
table.style.top = baseTop + "px";
|
|
||||||
table.style.left = baseLeft + index * spacingX + "px";
|
|
||||||
|
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "er-table-header";
|
header.className = "er-table-header";
|
||||||
@@ -289,40 +205,98 @@
|
|||||||
entity.fields.forEach(field => {
|
entity.fields.forEach(field => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "er-field";
|
row.className = "er-field";
|
||||||
row.id = "field-" + entity.id + "-" + field.id;
|
row.id = `field-${entity.id}-${field.id}`;
|
||||||
row.textContent = field.label || 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);
|
body.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
table.appendChild(header);
|
table.appendChild(header);
|
||||||
table.appendChild(body);
|
table.appendChild(body);
|
||||||
container.appendChild(table);
|
container.appendChild(table);
|
||||||
|
|
||||||
|
const estimatedHeight = 20 + (entity.fields.length * 26);
|
||||||
|
g.setNode(entity.id, { width: tableWidth, height: estimatedHeight });
|
||||||
});
|
});
|
||||||
|
|
||||||
const common = {
|
const layoutEdges = [];
|
||||||
endpoint: "Dot",
|
const m2oGroups = {};
|
||||||
endpointStyle: { radius: 4, fill: "#3498db" },
|
|
||||||
connector: ["Flowchart", { cornerRadius: 5 }],
|
|
||||||
paintStyle: { stroke: "#3498db", strokeWidth: 2 },
|
|
||||||
hoverPaintStyle: { stroke: "#2980b9", strokeWidth: 2 },
|
|
||||||
anchor: ["Continuous", { faces: ["left", "right"] }]
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableIds = diagramData.entities.map(e => "table-" + e.id);
|
|
||||||
jsPlumb.draggable(tableIds, { containment: "parent" });
|
|
||||||
|
|
||||||
diagramData.relations.forEach(rel => {
|
diagramData.relations.forEach(rel => {
|
||||||
jsPlumb.connect({
|
const isManyToOne = (rel.fromMultiplicity === 'N' || rel.fromMultiplicity === '*') && (rel.toMultiplicity === '1');
|
||||||
source: "field-" + rel.fromEntity + "-" + rel.fromField,
|
|
||||||
target: "field-" + rel.toEntity + "-" + rel.toField,
|
if (isManyToOne) {
|
||||||
overlays: [
|
const fi = (fieldIndexByEntity[rel.fromEntity] || {})[rel.fromField] ?? 0;
|
||||||
["Label", { label: rel.fromMultiplicity || "", location: 0.2, cssClass: "relation-label" }],
|
if (!m2oGroups[rel.fromEntity]) m2oGroups[rel.fromEntity] = [];
|
||||||
["Label", { label: rel.toMultiplicity || "", location: 0.8, cssClass: "relation-label" }]
|
m2oGroups[rel.fromEntity].push({ rel, fieldIndex: fi });
|
||||||
],
|
} else {
|
||||||
...common
|
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 => {
|
||||||
|
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: [
|
||||||
|
["Label", { label: rel.fromMultiplicity || "", location: 0.1, cssClass: "relation-label" }],
|
||||||
|
["Label", { label: rel.toMultiplicity || "", location: 0.9, cssClass: "relation-label" }]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableIds = diagramData.entities.map(e => "table-" + e.id);
|
||||||
|
instance.draggable(tableIds, {containment: "parent", stop: instance.repaintEverything});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchInfo();
|
||||||
|
setInterval(fetchInfo, 60000);
|
||||||
|
|
||||||
|
fetchSchemaAndRender();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -239,7 +239,7 @@
|
|||||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||||
<div class="container mx-auto text-center">
|
<div class="container mx-auto text-center">
|
||||||
<p>© 2026 LiB Library. Разработано в рамках дипломного проекта.
|
<p>© 2026 LiB Library. Разработано в рамках дипломного проекта.
|
||||||
Код открыт под лицензией <a href="https://github.com/wowlikon/LibraryAPI/blob/main/LICENSE">MIT</a>.
|
Код открыт под лицензией <a href="https://github.com/wowlikon/LiB/blob/main/LICENSE">MIT</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: b838606ad8d1
|
|||||||
Create Date: 2025-12-20 10:36:30.853896
|
Create Date: 2025-12-20 10:36:30.853896
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -13,39 +14,63 @@ import sqlmodel
|
|||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '02ed6e775351'
|
revision: str = "02ed6e775351"
|
||||||
down_revision: Union[str, None] = 'b838606ad8d1'
|
down_revision: Union[str, None] = "b838606ad8d1"
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
|
book_status_enum = sa.Enum(
|
||||||
book_status_enum.create(op.get_bind())
|
"active",
|
||||||
op.create_table('book_loans',
|
"borrowed",
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
"reserved",
|
||||||
sa.Column('book_id', sa.Integer(), nullable=False),
|
"restoration",
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
"written_off",
|
||||||
sa.Column('borrowed_at', sa.DateTime(), nullable=False),
|
name="bookstatus",
|
||||||
sa.Column('due_date', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('returned_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_book_loans_id'), 'book_loans', ['id'], unique=False)
|
book_status_enum.create(op.get_bind())
|
||||||
op.add_column('book', sa.Column('status', book_status_enum, nullable=False, server_default='active'))
|
op.create_table(
|
||||||
op.drop_index(op.f('ix_roles_name'), table_name='roles')
|
"loans",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("borrowed_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("due_date", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("returned_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["book_id"],
|
||||||
|
["book.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["users.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_loans_id"), "loans", ["id"], unique=False)
|
||||||
|
op.add_column(
|
||||||
|
"book",
|
||||||
|
sa.Column("status", book_status_enum, nullable=False, server_default="active"),
|
||||||
|
)
|
||||||
|
op.drop_index(op.f("ix_roles_name"), table_name="roles")
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True)
|
op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True)
|
||||||
op.drop_column('book', 'status')
|
op.drop_column("book", "status")
|
||||||
op.drop_index(op.f('ix_book_loans_id'), table_name='book_loans')
|
op.drop_index(op.f("ix_loans_id"), table_name="loans")
|
||||||
op.drop_table('book_loans')
|
op.drop_table("loans")
|
||||||
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
|
book_status_enum = sa.Enum(
|
||||||
|
"active",
|
||||||
|
"borrowed",
|
||||||
|
"reserved",
|
||||||
|
"restoration",
|
||||||
|
"written_off",
|
||||||
|
name="bookstatus",
|
||||||
|
)
|
||||||
book_status_enum.drop(op.get_bind())
|
book_status_enum.drop(op.get_bind())
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def upgrade() -> None:
|
|||||||
op.add_column(
|
op.add_column(
|
||||||
"users",
|
"users",
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"totp_secret", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True
|
"totp_secret", sqlmodel.sql.sqltypes.AutoString(length=80), nullable=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
op.add_column(
|
op.add_column(
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "LibraryAPI"
|
name = "LiB"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||||
authors = [{ name = "wowlikon" }]
|
authors = [{ name = "wowlikon" }]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
-169
@@ -1,169 +0,0 @@
|
|||||||
# Тесты без базы данных
|
|
||||||
|
|
||||||
## Обзор изменений
|
|
||||||
|
|
||||||
Все тесты были переработаны для работы без реальной базы данных PostgreSQL. Вместо этого используется in-memory мок-хранилище.
|
|
||||||
|
|
||||||
## Новые компоненты
|
|
||||||
|
|
||||||
### 1. Мок-хранилище ()
|
|
||||||
- Реализует все операции с данными в памяти
|
|
||||||
- Поддерживает CRUD операции для книг, авторов и жанров
|
|
||||||
- Управляет связями между сущностями
|
|
||||||
- Автоматически генерирует ID
|
|
||||||
- Предоставляет метод для очистки данных между тестами
|
|
||||||
|
|
||||||
### 2. Мок-сессия ()
|
|
||||||
- Эмулирует поведение SQLModel Session
|
|
||||||
- Предоставляет совместимый интерфейс для dependency injection
|
|
||||||
|
|
||||||
### 3. Мок-роутеры ()
|
|
||||||
- - упрощенные роутеры для операций с книгами
|
|
||||||
- - упрощенные роутеры для операций с авторами
|
|
||||||
- - упрощенные роутеры для связей между сущностями
|
|
||||||
|
|
||||||
### 4. Мок-приложение ()
|
|
||||||
- FastAPI приложение для тестирования
|
|
||||||
- Использует мок-роутеры вместо реальных
|
|
||||||
- Включает реальный misc роутер (не требует БД)
|
|
||||||
|
|
||||||
## Обновленные тесты
|
|
||||||
|
|
||||||
Все тесты были обновлены:
|
|
||||||
|
|
||||||
###
|
|
||||||
- Переработана фикстура для работы с мок-хранилищем
|
|
||||||
- Добавлен автоматический cleanup между тестами
|
|
||||||
|
|
||||||
###
|
|
||||||
- Использует мок-приложение вместо реального
|
|
||||||
- Все тесты создают необходимые данные явно
|
|
||||||
- Автоматическая очистка данных между тестами
|
|
||||||
|
|
||||||
###
|
|
||||||
- Аналогично
|
|
||||||
- Полная поддержка всех CRUD операций
|
|
||||||
|
|
||||||
###
|
|
||||||
- Поддерживает создание и получение связей автор-книга
|
|
||||||
- Тестирует получение авторов по книге и книг по автору
|
|
||||||
|
|
||||||
## Запуск тестов
|
|
||||||
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 23 items
|
|
||||||
|
|
||||||
tests/test_authors.py::test_empty_list_authors PASSED [ 4%]
|
|
||||||
tests/test_authors.py::test_create_author PASSED [ 8%]
|
|
||||||
tests/test_authors.py::test_list_authors PASSED [ 13%]
|
|
||||||
tests/test_authors.py::test_get_existing_author PASSED [ 17%]
|
|
||||||
tests/test_authors.py::test_get_not_existing_author PASSED [ 21%]
|
|
||||||
tests/test_authors.py::test_update_author PASSED [ 26%]
|
|
||||||
tests/test_authors.py::test_update_not_existing_author PASSED [ 30%]
|
|
||||||
tests/test_authors.py::test_delete_author PASSED [ 34%]
|
|
||||||
tests/test_authors.py::test_not_existing_delete_author PASSED [ 39%]
|
|
||||||
tests/test_books.py::test_empty_list_books PASSED [ 43%]
|
|
||||||
tests/test_books.py::test_create_book PASSED [ 47%]
|
|
||||||
tests/test_books.py::test_list_books PASSED [ 52%]
|
|
||||||
tests/test_books.py::test_get_existing_book PASSED [ 56%]
|
|
||||||
tests/test_books.py::test_get_not_existing_book PASSED [ 60%]
|
|
||||||
tests/test_books.py::test_update_book PASSED [ 65%]
|
|
||||||
tests/test_books.py::test_update_not_existing_book PASSED [ 69%]
|
|
||||||
tests/test_books.py::test_delete_book PASSED [ 73%]
|
|
||||||
tests/test_books.py::test_not_existing_delete_book PASSED [ 78%]
|
|
||||||
tests/test_misc.py::test_main_page PASSED [ 82%]
|
|
||||||
tests/test_misc.py::test_app_info_test PASSED [ 86%]
|
|
||||||
tests/test_relationships.py::test_prepare_data PASSED [ 91%]
|
|
||||||
tests/test_relationships.py::test_get_book_authors PASSED [ 95%]
|
|
||||||
tests/test_relationships.py::test_get_author_books PASSED [100%]
|
|
||||||
|
|
||||||
============================== 23 passed in 1.42s ==============================
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 9 items
|
|
||||||
|
|
||||||
tests/test_books.py::test_empty_list_books PASSED [ 11%]
|
|
||||||
tests/test_books.py::test_create_book PASSED [ 22%]
|
|
||||||
tests/test_books.py::test_list_books PASSED [ 33%]
|
|
||||||
tests/test_books.py::test_get_existing_book PASSED [ 44%]
|
|
||||||
tests/test_books.py::test_get_not_existing_book PASSED [ 55%]
|
|
||||||
tests/test_books.py::test_update_book PASSED [ 66%]
|
|
||||||
tests/test_books.py::test_update_not_existing_book PASSED [ 77%]
|
|
||||||
tests/test_books.py::test_delete_book PASSED [ 88%]
|
|
||||||
tests/test_books.py::test_not_existing_delete_book PASSED [100%]
|
|
||||||
|
|
||||||
============================== 9 passed in 0.99s ===============================
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 9 items
|
|
||||||
|
|
||||||
tests/test_authors.py::test_empty_list_authors PASSED [ 11%]
|
|
||||||
tests/test_authors.py::test_create_author PASSED [ 22%]
|
|
||||||
tests/test_authors.py::test_list_authors PASSED [ 33%]
|
|
||||||
tests/test_authors.py::test_get_existing_author PASSED [ 44%]
|
|
||||||
tests/test_authors.py::test_get_not_existing_author PASSED [ 55%]
|
|
||||||
tests/test_authors.py::test_update_author PASSED [ 66%]
|
|
||||||
tests/test_authors.py::test_update_not_existing_author PASSED [ 77%]
|
|
||||||
tests/test_authors.py::test_delete_author PASSED [ 88%]
|
|
||||||
tests/test_authors.py::test_not_existing_delete_author PASSED [100%]
|
|
||||||
|
|
||||||
============================== 9 passed in 0.96s ===============================
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 3 items
|
|
||||||
|
|
||||||
tests/test_relationships.py::test_prepare_data PASSED [ 33%]
|
|
||||||
tests/test_relationships.py::test_get_book_authors PASSED [ 66%]
|
|
||||||
tests/test_relationships.py::test_get_author_books PASSED [100%]
|
|
||||||
|
|
||||||
============================== 3 passed in 1.09s ===============================
|
|
||||||
============================= test session starts ==============================
|
|
||||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
|
||||||
cachedir: .pytest_cache
|
|
||||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
|
||||||
configfile: pyproject.toml
|
|
||||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
|
||||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
|
||||||
collecting ... collected 2 items
|
|
||||||
|
|
||||||
tests/test_misc.py::test_main_page PASSED [ 50%]
|
|
||||||
tests/test_misc.py::test_app_info_test PASSED [100%]
|
|
||||||
|
|
||||||
============================== 2 passed in 0.93s ===============================
|
|
||||||
|
|
||||||
## Преимущества нового подхода
|
|
||||||
|
|
||||||
1. **Независимость**: Тесты не требуют PostgreSQL или Docker
|
|
||||||
2. **Скорость**: Выполняются значительно быстрее
|
|
||||||
3. **Изоляция**: Каждый тест работает с чистым состоянием
|
|
||||||
4. **Стабильность**: Нет проблем с сетевыми подключениями или состоянием БД
|
|
||||||
5. **CI/CD готовность**: Легко интегрируются в CI пайплайны
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Мок-хранилище упрощено по сравнению с реальной БД
|
|
||||||
- Отсутствуют некоторые возможности SQLModel (сложные запросы, транзакции)
|
|
||||||
- Нет проверки целостности данных на уровне БД
|
|
||||||
|
|
||||||
Однако для юнит-тестирования API логики этого достаточно.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from library_service.routers.misc import router as misc_router
|
|
||||||
from tests.mock_routers import authors, books, genres, relationships
|
|
||||||
|
|
||||||
|
|
||||||
def create_mock_app() -> FastAPI:
|
|
||||||
"""Создание FastAPI app с моками роутеров для тестов"""
|
|
||||||
app = FastAPI(
|
|
||||||
title="Library API Test",
|
|
||||||
description="Library API for testing without database",
|
|
||||||
version="1.0.0",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Подключение мок-роутеров
|
|
||||||
app.include_router(books.router)
|
|
||||||
app.include_router(authors.router)
|
|
||||||
app.include_router(genres.router)
|
|
||||||
app.include_router(relationships.router)
|
|
||||||
|
|
||||||
# Подключение реального misc роутера
|
|
||||||
app.include_router(misc_router)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
mock_app = create_mock_app()
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/authors", tags=["authors"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
def create_author(author: dict):
|
|
||||||
return mock_storage.create_author(author["name"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
def read_authors():
|
|
||||||
authors = mock_storage.get_all_authors()
|
|
||||||
return {"authors": authors, "total": len(authors)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{author_id}")
|
|
||||||
def get_author(author_id: int):
|
|
||||||
author = mock_storage.get_author(author_id)
|
|
||||||
if not author:
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
|
|
||||||
books = mock_storage.get_books_by_author(author_id)
|
|
||||||
author_with_books = author.copy()
|
|
||||||
author_with_books["books"] = books
|
|
||||||
return author_with_books
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{author_id}")
|
|
||||||
def update_author(author_id: int, author: dict):
|
|
||||||
updated_author = mock_storage.update_author(author_id, author.get("name"))
|
|
||||||
if not updated_author:
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
return updated_author
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{author_id}")
|
|
||||||
def delete_author(author_id: int):
|
|
||||||
author = mock_storage.delete_author(author_id)
|
|
||||||
if not author:
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
return author
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/books", tags=["books"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
def create_book(book: dict):
|
|
||||||
return mock_storage.create_book(book["title"], book["description"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
def read_books():
|
|
||||||
books = mock_storage.get_all_books()
|
|
||||||
return {"books": books, "total": len(books)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{book_id}")
|
|
||||||
def get_book(book_id: int):
|
|
||||||
book = mock_storage.get_book(book_id)
|
|
||||||
if not book:
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
|
|
||||||
authors = mock_storage.get_authors_by_book(book_id)
|
|
||||||
book_with_authors = book.copy()
|
|
||||||
book_with_authors["authors"] = authors
|
|
||||||
return book_with_authors
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{book_id}")
|
|
||||||
def update_book(book_id: int, book: dict):
|
|
||||||
updated_book = mock_storage.update_book(
|
|
||||||
book_id, book.get("title"), book.get("description")
|
|
||||||
)
|
|
||||||
if not updated_book:
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
return updated_book
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{book_id}")
|
|
||||||
def delete_book(book_id: int):
|
|
||||||
book = mock_storage.delete_book(book_id)
|
|
||||||
if not book:
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
return book
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
def create_genre(genre: dict):
|
|
||||||
return mock_storage.create_genre(genre["name"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
def read_genres():
|
|
||||||
genres = mock_storage.get_all_genres()
|
|
||||||
return {"genres": genres, "total": len(genres)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{genre_id}")
|
|
||||||
def get_genre(genre_id: int):
|
|
||||||
genre = mock_storage.get_genre(genre_id)
|
|
||||||
if not genre:
|
|
||||||
raise HTTPException(status_code=404, detail="genre not found")
|
|
||||||
|
|
||||||
books = mock_storage.get_books_by_genre(genre_id)
|
|
||||||
genre_with_books = genre.copy()
|
|
||||||
genre_with_books["books"] = books
|
|
||||||
return genre_with_books
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{genre_id}")
|
|
||||||
def update_genre(genre_id: int, genre: dict):
|
|
||||||
updated_genre = mock_storage.update_genre(genre_id, genre.get("name"))
|
|
||||||
if not updated_genre:
|
|
||||||
raise HTTPException(status_code=404, detail="genre not found")
|
|
||||||
return updated_genre
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{genre_id}")
|
|
||||||
def delete_genre(genre_id: int):
|
|
||||||
genre = mock_storage.delete_genre(genre_id)
|
|
||||||
if not genre:
|
|
||||||
raise HTTPException(status_code=404, detail="genre not found")
|
|
||||||
return genre
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
router = APIRouter(tags=["relations"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/relationships/author-book")
|
|
||||||
def add_author_to_book(author_id: int, book_id: int):
|
|
||||||
if not mock_storage.create_author_book_link(author_id, book_id):
|
|
||||||
if not mock_storage.get_author(author_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
if not mock_storage.get_book(book_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
raise HTTPException(status_code=400, detail="Relationship already exists")
|
|
||||||
|
|
||||||
return {"author_id": author_id, "book_id": book_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/authors/{author_id}/books")
|
|
||||||
def get_books_for_author(author_id: int):
|
|
||||||
author = mock_storage.get_author(author_id)
|
|
||||||
if not author:
|
|
||||||
raise HTTPException(status_code=404, detail="Author not found")
|
|
||||||
|
|
||||||
return mock_storage.get_books_by_author(author_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/books/{book_id}/authors")
|
|
||||||
def get_authors_for_book(book_id: int):
|
|
||||||
book = mock_storage.get_book(book_id)
|
|
||||||
if not book:
|
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
|
||||||
|
|
||||||
return mock_storage.get_authors_by_book(book_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/relationships/genre-book")
|
|
||||||
def add_genre_to_book(genre_id: int, book_id: int):
|
|
||||||
return {"genre_id": genre_id, "book_id": book_id}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
from typing import Any, List
|
|
||||||
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
|
|
||||||
class MockSession:
|
|
||||||
"""Mock SQLModel Session that works with MockStorage"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.storage = mock_storage
|
|
||||||
|
|
||||||
def add(self, obj: Any): ...
|
|
||||||
|
|
||||||
def commit(self): ...
|
|
||||||
|
|
||||||
def refresh(self, obj: Any): ...
|
|
||||||
|
|
||||||
def get(self, model_class, pk: int):
|
|
||||||
if hasattr(model_class, "__name__"):
|
|
||||||
model_name = model_class.__name__.lower()
|
|
||||||
else:
|
|
||||||
model_name = str(model_class).lower()
|
|
||||||
|
|
||||||
if "book" in model_name:
|
|
||||||
return self.storage.get_book(pk)
|
|
||||||
elif "author" in model_name:
|
|
||||||
return self.storage.get_author(pk)
|
|
||||||
elif "genre" in model_name:
|
|
||||||
return self.storage.get_genre(pk)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def delete(self, obj: Any): ...
|
|
||||||
|
|
||||||
def exec(self, statement):
|
|
||||||
return MockResult([])
|
|
||||||
|
|
||||||
|
|
||||||
class MockResult:
|
|
||||||
"""Mock result for query operations"""
|
|
||||||
|
|
||||||
def __init__(self, data: List):
|
|
||||||
self.data = data
|
|
||||||
|
|
||||||
def all(self):
|
|
||||||
return self.data
|
|
||||||
|
|
||||||
def first(self):
|
|
||||||
return self.data[0] if self.data else None
|
|
||||||
|
|
||||||
|
|
||||||
def mock_get_session():
|
|
||||||
"""Mock session dependency"""
|
|
||||||
return MockSession()
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
class MockStorage:
|
|
||||||
"""In-memory storage for testing without database"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.books = {}
|
|
||||||
self.authors = {}
|
|
||||||
self.genres = {}
|
|
||||||
self.author_book_links = []
|
|
||||||
self.genre_book_links = []
|
|
||||||
self.book_id_counter = 1
|
|
||||||
self.author_id_counter = 1
|
|
||||||
self.genre_id_counter = 1
|
|
||||||
|
|
||||||
def clear_all(self):
|
|
||||||
"""Очистка всех данных"""
|
|
||||||
self.books.clear()
|
|
||||||
self.authors.clear()
|
|
||||||
self.genres.clear()
|
|
||||||
self.author_book_links.clear()
|
|
||||||
self.genre_book_links.clear()
|
|
||||||
self.book_id_counter = 1
|
|
||||||
self.author_id_counter = 1
|
|
||||||
self.genre_id_counter = 1
|
|
||||||
|
|
||||||
# Book operations
|
|
||||||
def create_book(self, title: str, description: str) -> dict:
|
|
||||||
book_id = self.book_id_counter
|
|
||||||
book = {"id": book_id, "title": title, "description": description}
|
|
||||||
self.books[book_id] = book
|
|
||||||
self.book_id_counter += 1
|
|
||||||
return book
|
|
||||||
|
|
||||||
def get_book(self, book_id: int) -> dict | None:
|
|
||||||
return self.books.get(book_id)
|
|
||||||
|
|
||||||
def get_all_books(self) -> List[dict]:
|
|
||||||
return list(self.books.values())
|
|
||||||
|
|
||||||
def update_book(
|
|
||||||
self,
|
|
||||||
book_id: int,
|
|
||||||
title: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
) -> dict | None:
|
|
||||||
if book_id not in self.books:
|
|
||||||
return None
|
|
||||||
book = self.books[book_id]
|
|
||||||
if title is not None:
|
|
||||||
book["title"] = title
|
|
||||||
if description is not None:
|
|
||||||
book["description"] = description
|
|
||||||
return book
|
|
||||||
|
|
||||||
def delete_book(self, book_id: int) -> dict | None:
|
|
||||||
if book_id not in self.books:
|
|
||||||
return None
|
|
||||||
book = self.books.pop(book_id)
|
|
||||||
self.author_book_links = [
|
|
||||||
link for link in self.author_book_links if link["book_id"] != book_id
|
|
||||||
]
|
|
||||||
self.genre_book_links = [
|
|
||||||
link for link in self.genre_book_links if link["book_id"] != book_id
|
|
||||||
]
|
|
||||||
return book
|
|
||||||
|
|
||||||
# Author operations
|
|
||||||
def create_author(self, name: str) -> dict:
|
|
||||||
author_id = self.author_id_counter
|
|
||||||
author = {"id": author_id, "name": name}
|
|
||||||
self.authors[author_id] = author
|
|
||||||
self.author_id_counter += 1
|
|
||||||
return author
|
|
||||||
|
|
||||||
def get_author(self, author_id: int) -> dict | None:
|
|
||||||
return self.authors.get(author_id)
|
|
||||||
|
|
||||||
def get_all_authors(self) -> List[dict]:
|
|
||||||
return list(self.authors.values())
|
|
||||||
|
|
||||||
def update_author(
|
|
||||||
self, author_id: int, name: str | None = None
|
|
||||||
) -> dict | None:
|
|
||||||
if author_id not in self.authors:
|
|
||||||
return None
|
|
||||||
author = self.authors[author_id]
|
|
||||||
if name is not None:
|
|
||||||
author["name"] = name
|
|
||||||
return author
|
|
||||||
|
|
||||||
def delete_author(self, author_id: int) -> dict | None:
|
|
||||||
if author_id not in self.authors:
|
|
||||||
return None
|
|
||||||
author = self.authors.pop(author_id)
|
|
||||||
self.author_book_links = [
|
|
||||||
link for link in self.author_book_links if link["author_id"] != author_id
|
|
||||||
]
|
|
||||||
return author
|
|
||||||
|
|
||||||
# Genre operations
|
|
||||||
def create_genre(self, name: str) -> dict:
|
|
||||||
genre_id = self.genre_id_counter
|
|
||||||
genre = {"id": genre_id, "name": name}
|
|
||||||
self.genres[genre_id] = genre
|
|
||||||
self.genre_id_counter += 1
|
|
||||||
return genre
|
|
||||||
|
|
||||||
def get_genre(self, genre_id: int) -> dict | None:
|
|
||||||
return self.genres.get(genre)
|
|
||||||
|
|
||||||
def get_all_authors(self) -> List[dict]:
|
|
||||||
return list(self.authors.values())
|
|
||||||
|
|
||||||
def update_genre(self, genre_id: int, name: str | None = None) -> dict | None:
|
|
||||||
if genre_id not in self.genres:
|
|
||||||
return None
|
|
||||||
genre = self.genres[genre_id]
|
|
||||||
if name is not None:
|
|
||||||
genre["name"] = name
|
|
||||||
return genre
|
|
||||||
|
|
||||||
def delete_genre(self, genre_id: int) -> dict | None:
|
|
||||||
if genre_id not in self.genres:
|
|
||||||
return None
|
|
||||||
genre = self.genres.pop(genre_id)
|
|
||||||
self.genre_book_links = [
|
|
||||||
link for link in self.genre_book_links if link["genre_id"] != genre_id
|
|
||||||
]
|
|
||||||
return genre
|
|
||||||
|
|
||||||
# Relationship operations
|
|
||||||
def create_author_book_link(self, author_id: int, book_id: int) -> bool:
|
|
||||||
if author_id not in self.authors or book_id not in self.books:
|
|
||||||
return False
|
|
||||||
for link in self.author_book_links:
|
|
||||||
if link["author_id"] == author_id and link["book_id"] == book_id:
|
|
||||||
return False
|
|
||||||
self.author_book_links.append({"author_id": author_id, "book_id": book_id})
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_authors_by_book(self, book_id: int) -> List[dict]:
|
|
||||||
author_ids = [
|
|
||||||
link["author_id"]
|
|
||||||
for link in self.author_book_links
|
|
||||||
if link["book_id"] == book_id
|
|
||||||
]
|
|
||||||
return [
|
|
||||||
self.authors[author_id]
|
|
||||||
for author_id in author_ids
|
|
||||||
if author_id in self.authors
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_books_by_author(self, author_id: int) -> List[dict]:
|
|
||||||
book_ids = [
|
|
||||||
link["book_id"]
|
|
||||||
for link in self.author_book_links
|
|
||||||
if link["author_id"] == author_id
|
|
||||||
]
|
|
||||||
return [self.books[book_id] for book_id in book_ids if book_id in self.books]
|
|
||||||
|
|
||||||
def get_all_author_book_links(self) -> List[dict]:
|
|
||||||
return list(self.author_book_links)
|
|
||||||
|
|
||||||
|
|
||||||
mock_storage = MockStorage()
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from tests.mock_app import mock_app
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
client = TestClient(mock_app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_database():
|
|
||||||
mock_storage.clear_all()
|
|
||||||
yield
|
|
||||||
mock_storage.clear_all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_list_authors():
|
|
||||||
response = client.get("/authors")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {"authors": [], "total": 0}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_author():
|
|
||||||
response = client.post("/authors", json={"name": "Test Author"})
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {"id": 1, "name": "Test Author"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_authors():
|
|
||||||
client.post("/authors", json={"name": "Test Author"})
|
|
||||||
|
|
||||||
response = client.get("/authors")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"authors": [{"id": 1, "name": "Test Author"}],
|
|
||||||
"total": 1,
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_existing_author():
|
|
||||||
client.post("/authors", json={"name": "Test Author"})
|
|
||||||
|
|
||||||
response = client.get("/authors/1")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"name": "Test Author",
|
|
||||||
"books": [],
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_not_existing_author():
|
|
||||||
response = client.get("/authors/2")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_author():
|
|
||||||
client.post("/authors", json={"name": "Test Author"})
|
|
||||||
|
|
||||||
response = client.get("/authors/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
response = client.put("/authors/1", json={"name": "Updated Author"})
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"name": "Updated Author",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_not_existing_author():
|
|
||||||
response = client.put("/authors/2", json={"name": "Updated Author"})
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_author():
|
|
||||||
client.post("/authors", json={"name": "Test Author"})
|
|
||||||
client.put("/authors/1", json={"name": "Updated Author"})
|
|
||||||
|
|
||||||
response = client.get("/authors/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
response = client.delete("/authors/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"name": "Updated Author",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_not_existing_delete_author():
|
|
||||||
response = client.delete("/authors/2")
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from tests.mock_app import mock_app
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
client = TestClient(mock_app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_database():
|
|
||||||
mock_storage.clear_all()
|
|
||||||
yield
|
|
||||||
mock_storage.clear_all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_list_books():
|
|
||||||
response = client.get("/books")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {"books": [], "total": 0}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_book():
|
|
||||||
response = client.post(
|
|
||||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
|
||||||
)
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"title": "Test Book",
|
|
||||||
"description": "Test Description",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_books():
|
|
||||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/books")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"books": [{"id": 1, "title": "Test Book", "description": "Test Description"}],
|
|
||||||
"total": 1,
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_existing_book():
|
|
||||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/books/1")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"title": "Test Book",
|
|
||||||
"description": "Test Description",
|
|
||||||
"authors": [],
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_not_existing_book():
|
|
||||||
response = client.get("/books/2")
|
|
||||||
print(response.json())
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_book():
|
|
||||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/books/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
response = client.put(
|
|
||||||
"/books/1", json={"title": "Updated Book", "description": "Updated Description"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"title": "Updated Book",
|
|
||||||
"description": "Updated Description",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_not_existing_book():
|
|
||||||
response = client.put(
|
|
||||||
"/books/2", json={"title": "Updated Book", "description": "Updated Description"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_book():
|
|
||||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"})
|
|
||||||
client.put("/books/1", json={"title": "Updated Book", "description": "Updated Description"}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/books/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
response = client.delete("/books/1")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 1,
|
|
||||||
"title": "Updated Book",
|
|
||||||
"description": "Updated Description",
|
|
||||||
}, "Invalid response data"
|
|
||||||
|
|
||||||
|
|
||||||
def test_not_existing_delete_book():
|
|
||||||
response = client.delete("/books/2")
|
|
||||||
assert response.status_code == 404, "Invalid response status"
|
|
||||||
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from tests.mock_app import mock_app
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
client = TestClient(mock_app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_database():
|
|
||||||
mock_storage.clear_all()
|
|
||||||
yield
|
|
||||||
mock_storage.clear_all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_page():
|
|
||||||
response = client.get("/api")
|
|
||||||
try:
|
|
||||||
content = response.content.decode("utf-8")
|
|
||||||
title_idx = content.index("Welcome to ")
|
|
||||||
description_idx = content.index("Description: ")
|
|
||||||
version_idx = content.index("Version: ")
|
|
||||||
time_idx = content.index("Current Time: ")
|
|
||||||
status_idx = content.index("Status: ")
|
|
||||||
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert content.startswith("<!doctype html>"), "Not HTML"
|
|
||||||
assert content.endswith("</html>"), "HTML tag not closed"
|
|
||||||
assert content[title_idx + 1] != "<", "Title not provided"
|
|
||||||
assert content[description_idx + 1] != "<", "Description not provided"
|
|
||||||
assert content[version_idx + 1] != "<", "Version not provided"
|
|
||||||
assert content[time_idx + 1] != "<", "Time not provided"
|
|
||||||
assert content[status_idx + 1] != "<", "Status not provided"
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
assert False, "Unexpected error"
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_info_test():
|
|
||||||
response = client.get("/api/info")
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
assert response.json()["status"] == "ok", "Status not ok"
|
|
||||||
assert response.json()["app_info"]["title"] != "", "Title not provided"
|
|
||||||
assert response.json()["app_info"]["description"] != "", "Description not provided"
|
|
||||||
assert response.json()["app_info"]["version"] != "", "Version not provided"
|
|
||||||
assert (0 < (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds()), "Negative time difference"
|
|
||||||
assert (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds() < 1, "Time difference too large"
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from tests.mock_app import mock_app
|
|
||||||
from tests.mocks.mock_storage import mock_storage
|
|
||||||
|
|
||||||
client = TestClient(mock_app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_database():
|
|
||||||
mock_storage.clear_all()
|
|
||||||
yield
|
|
||||||
mock_storage.clear_all()
|
|
||||||
|
|
||||||
|
|
||||||
def make_authorbook_relationship(author_id, book_id):
|
|
||||||
response = client.post(
|
|
||||||
"/relationships/author-book",
|
|
||||||
params={"author_id": author_id, "book_id": book_id},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
|
|
||||||
def make_genrebook_relationship(genre_id, book_id):
|
|
||||||
response = client.post(
|
|
||||||
"/relationships/genre-book", params={"genre_id": genre_id, "book_id": book_id}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, "Invalid response status"
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_data():
|
|
||||||
assert (client.post("/books", json={"title": "Test Book 1", "description": "Test Description 1"}).status_code == 200)
|
|
||||||
assert (client.post("/books", json={"title": "Test Book 2", "description": "Test Description 2"}).status_code == 200)
|
|
||||||
assert (client.post("/books", json={"title": "Test Book 3", "description": "Test Description 3"}).status_code == 200)
|
|
||||||
|
|
||||||
assert client.post("/authors", json={"name": "Test Author 1"}).status_code == 200
|
|
||||||
assert client.post("/authors", json={"name": "Test Author 2"}).status_code == 200
|
|
||||||
assert client.post("/authors", json={"name": "Test Author 3"}).status_code == 200
|
|
||||||
|
|
||||||
assert client.post("/genres", json={"name": "Test Genre 1"}).status_code == 200
|
|
||||||
assert client.post("/genres", json={"name": "Test Genre 2"}).status_code == 200
|
|
||||||
assert client.post("/genres", json={"name": "Test Genre 3"}).status_code == 200
|
|
||||||
|
|
||||||
make_authorbook_relationship(1, 1)
|
|
||||||
make_authorbook_relationship(2, 1)
|
|
||||||
make_authorbook_relationship(1, 2)
|
|
||||||
make_authorbook_relationship(2, 3)
|
|
||||||
make_authorbook_relationship(3, 3)
|
|
||||||
make_genrebook_relationship(1, 1)
|
|
||||||
make_genrebook_relationship(2, 1)
|
|
||||||
make_genrebook_relationship(1, 2)
|
|
||||||
make_genrebook_relationship(2, 3)
|
|
||||||
make_genrebook_relationship(3, 3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_book_authors():
|
|
||||||
test_prepare_data()
|
|
||||||
|
|
||||||
response1 = client.get("/books/1/authors")
|
|
||||||
assert response1.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response1.json()) == 2, "Invalid number of authors"
|
|
||||||
assert response1.json()[0]["name"] == "Test Author 1"
|
|
||||||
assert response1.json()[1]["name"] == "Test Author 2"
|
|
||||||
assert response1.json()[0]["id"] == 1
|
|
||||||
assert response1.json()[1]["id"] == 2
|
|
||||||
|
|
||||||
response2 = client.get("/books/2/authors")
|
|
||||||
assert response2.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response2.json()) == 1, "Invalid number of authors"
|
|
||||||
assert response2.json()[0]["name"] == "Test Author 1"
|
|
||||||
assert response2.json()[0]["id"] == 1
|
|
||||||
|
|
||||||
response3 = client.get("/books/3/authors")
|
|
||||||
assert response3.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response3.json()) == 2, "Invalid number of authors"
|
|
||||||
assert response3.json()[0]["name"] == "Test Author 2"
|
|
||||||
assert response3.json()[1]["name"] == "Test Author 3"
|
|
||||||
assert response3.json()[0]["id"] == 2
|
|
||||||
assert response3.json()[1]["id"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_author_books():
|
|
||||||
test_prepare_data()
|
|
||||||
|
|
||||||
response1 = client.get("/authors/1/books")
|
|
||||||
assert response1.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response1.json()) == 2, "Invalid number of books"
|
|
||||||
assert response1.json()[0]["title"] == "Test Book 1"
|
|
||||||
assert response1.json()[1]["title"] == "Test Book 2"
|
|
||||||
assert response1.json()[0]["id"] == 1
|
|
||||||
assert response1.json()[1]["id"] == 2
|
|
||||||
|
|
||||||
response2 = client.get("/authors/2/books")
|
|
||||||
assert response2.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response2.json()) == 2, "Invalid number of books"
|
|
||||||
assert response2.json()[0]["title"] == "Test Book 1"
|
|
||||||
assert response2.json()[1]["title"] == "Test Book 3"
|
|
||||||
assert response2.json()[0]["id"] == 1
|
|
||||||
assert response2.json()[1]["id"] == 3
|
|
||||||
|
|
||||||
response3 = client.get("/authors/3/books")
|
|
||||||
assert response3.status_code == 200, "Invalid response status"
|
|
||||||
assert len(response3.json()) == 1, "Invalid number of books"
|
|
||||||
assert response3.json()[0]["title"] == "Test Book 3"
|
|
||||||
assert response3.json()[0]["id"] == 3
|
|
||||||
@@ -630,7 +630,7 @@ source = { registry = "https://pypi.org/simple" }
|
|||||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32b80685b26316b604218e4ac77487ea82719c3c28bc6/json_log_formatter-1.1.1.tar.gz", hash = "sha256:0815e3b4469e5c79cf3f6dc8a0613ba6601f4a7464f85ba03655cfa6e3e17d10", size = 5896, upload-time = "2025-02-27T22:56:15.643Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32b80685b26316b604218e4ac77487ea82719c3c28bc6/json_log_formatter-1.1.1.tar.gz", hash = "sha256:0815e3b4469e5c79cf3f6dc8a0613ba6601f4a7464f85ba03655cfa6e3e17d10", size = 5896, upload-time = "2025-02-27T22:56:15.643Z" }
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libraryapi"
|
name = "lib"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user