mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-03 20:31:09 +00:00
Динамическое создание er-диаграммы по моделям
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Postgres
|
||||
POSTGRES_HOST="localhost"
|
||||
POSTGRES_HOST="db"
|
||||
POSTGRES_PORT="5432"
|
||||
POSTGRES_USER="postgres"
|
||||
POSTGRES_PASSWORD="postgres"
|
||||
@@ -9,7 +9,7 @@ POSTGRES_DB="lib"
|
||||
# DEFAULT_ADMIN_USERNAME="admin"
|
||||
# DEFAULT_ADMIN_EMAIL="admin@example.com"
|
||||
# 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
|
||||
ALGORITHM="HS256"
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
|
||||
1. Клонируйте репозиторий:
|
||||
```bash
|
||||
git clone https://github.com/wowlikon/libraryapi.git
|
||||
git clone https://github.com/wowlikon/LiB.git
|
||||
```
|
||||
|
||||
2. Перейдите в каталог проекта:
|
||||
```bash
|
||||
cd libraryapi
|
||||
cd LiB
|
||||
```
|
||||
|
||||
3. Настройте переменные окружения:
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
Для создания новых миграций:
|
||||
```bash
|
||||
alembic revision --autogenerate -m "Migration name"
|
||||
uv run alembic revision --autogenerate -m "Migration name"
|
||||
```
|
||||
|
||||
Для запуска тестов:
|
||||
@@ -52,14 +52,9 @@
|
||||
docker compose up test
|
||||
```
|
||||
|
||||
Для добавления данных для примера используйте:
|
||||
```bash
|
||||
python data.py
|
||||
```
|
||||
|
||||
### **Роли пользователей**
|
||||
|
||||
- **Админ**: Полный доступ ко всем функциям системы
|
||||
- **admin**: Полный доступ ко всем функциям системы
|
||||
- **librarian**: Управление книгами, авторами, жанрами и выдачами
|
||||
- **member**: Просмотр каталога и управление своими выдачами
|
||||
|
||||
@@ -166,10 +161,11 @@
|
||||
|
||||
#### **Прочее** (`/api`)
|
||||
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|-------|----------|-----------|----------------------|
|
||||
| GET | `/info` | Публичный | Информация о сервисе |
|
||||
| GET | `/stats` | Публичный | Статистика системы |
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|-------|-----------|-----------|----------------------|
|
||||
| GET | `/info` | Публичный | Информация о сервисе |
|
||||
| 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:
|
||||
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:
|
||||
proxy: # Рекомендуется использовать через реверс-прокси
|
||||
name: proxy
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Модуль DB-моделей авторов"""
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
|
||||
|
||||
class Author(AuthorBase, table=True):
|
||||
"""Модель автора в базе данных"""
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
books: List["Book"] = Relationship(
|
||||
back_populates="authors", link_model=AuthorBookLink
|
||||
)
|
||||
|
||||
@@ -17,10 +17,13 @@ if TYPE_CHECKING:
|
||||
class Book(BookBase, table=True):
|
||||
"""Модель книги в базе данных"""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
status: BookStatus = Field(
|
||||
default=BookStatus.ACTIVE,
|
||||
sa_column=Column(String, nullable=False, default="active"),
|
||||
description="Статус",
|
||||
)
|
||||
authors: List["Author"] = Relationship(
|
||||
back_populates="books", link_model=AuthorBookLink
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Модуль DB-моделей жанров"""
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
|
||||
|
||||
class Genre(GenreBase, table=True):
|
||||
"""Модель жанра в базе данных"""
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
books: List["Book"] = Relationship(
|
||||
back_populates="genres", link_model=GenreBookLink
|
||||
)
|
||||
|
||||
@@ -10,14 +10,29 @@ class AuthorBookLink(SQLModel, table=True):
|
||||
author_id: int | None = Field(
|
||||
default=None, foreign_key="author.id", primary_key=True
|
||||
)
|
||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
||||
book_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="book.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор книги",
|
||||
)
|
||||
|
||||
|
||||
class GenreBookLink(SQLModel, table=True):
|
||||
"""Модель связи жанра и книги"""
|
||||
|
||||
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
|
||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
||||
genre_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="genre.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор жанра",
|
||||
)
|
||||
book_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="book.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор книги",
|
||||
)
|
||||
|
||||
|
||||
class UserRoleLink(SQLModel, table=True):
|
||||
@@ -25,8 +40,18 @@ class UserRoleLink(SQLModel, table=True):
|
||||
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
|
||||
role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
|
||||
user_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="users.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор пользователя",
|
||||
)
|
||||
role_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="roles.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор роли",
|
||||
)
|
||||
|
||||
|
||||
class BookUserLink(SQLModel, table=True):
|
||||
@@ -35,13 +60,22 @@ class BookUserLink(SQLModel, table=True):
|
||||
Связывает книгу и пользователя с фиксацией времени.
|
||||
"""
|
||||
|
||||
__tablename__ = "book_loans"
|
||||
__tablename__ = "loans"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
|
||||
book_id: int = Field(foreign_key="book.id")
|
||||
user_id: int = Field(foreign_key="users.id")
|
||||
book_id: int = Field(foreign_key="book.id", description="Идентификатор")
|
||||
user_id: int = Field(
|
||||
foreign_key="users.id", description="Идентификатор пользователя"
|
||||
)
|
||||
|
||||
borrowed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
due_date: datetime
|
||||
returned_at: datetime | None = Field(default=None)
|
||||
borrowed_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
description="Дата и время выдачи",
|
||||
)
|
||||
due_date: datetime = Field(description="Дата и время запланированного возврата")
|
||||
returned_at: datetime | None = Field(
|
||||
default=None, description="Дата и время фактического возврата"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Модуль DB-моделей ролей"""
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
@@ -12,8 +13,11 @@ if TYPE_CHECKING:
|
||||
|
||||
class Role(RoleBase, table=True):
|
||||
"""Модель роли в базе данных"""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
|
||||
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
|
||||
|
||||
@@ -17,17 +17,32 @@ class User(UserBase, table=True):
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
hashed_password: str = Field(nullable=False)
|
||||
is_2fa_enabled: bool = Field(default=False)
|
||||
totp_secret: str | None = Field(default=None, max_length=80)
|
||||
recovery_code_hashes: str | None = Field(default=None, max_length=1500)
|
||||
recovery_codes_generated_at: datetime | None = Field(default=None)
|
||||
is_active: bool = Field(default=True)
|
||||
is_verified: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
hashed_password: str = Field(nullable=False, description="Argon2id хэш пароля")
|
||||
is_2fa_enabled: bool = Field(default=False, description="Включен TOTP 2FA")
|
||||
totp_secret: str | None = Field(
|
||||
default=None, max_length=80, description="Зашифрованный секрет TOTP"
|
||||
)
|
||||
recovery_code_hashes: str | None = Field(
|
||||
default=None,
|
||||
max_length=1500,
|
||||
description="Argon2id хэши одноразовыхкодов восстановления",
|
||||
)
|
||||
recovery_codes_generated_at: datetime | None = Field(
|
||||
default=None, description="Дата и время создания кодов восстановления"
|
||||
)
|
||||
is_active: bool = Field(default=True, description="Не является ли заблокированым")
|
||||
is_verified: bool = Field(default=False, description="Является ли верифицированным")
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
description="Дата и время создания",
|
||||
)
|
||||
updated_at: datetime | None = Field(
|
||||
default=None, sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)}
|
||||
default=None,
|
||||
sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)},
|
||||
description="Дата и время последнего обновления",
|
||||
)
|
||||
|
||||
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Модуль DTO-моделей авторов"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class AuthorBase(SQLModel):
|
||||
"""Базовая модель автора"""
|
||||
name: str
|
||||
|
||||
name: str = Field(description="Псевдоним")
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={"example": {"name": "author_name"}}
|
||||
@@ -16,20 +18,24 @@ class AuthorBase(SQLModel):
|
||||
|
||||
class AuthorCreate(AuthorBase):
|
||||
"""Модель автора для создания"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AuthorUpdate(SQLModel):
|
||||
"""Модель автора для обновления"""
|
||||
name: str | None = None
|
||||
|
||||
name: str | None = Field(None, description="Псевдоним")
|
||||
|
||||
|
||||
class AuthorRead(AuthorBase):
|
||||
"""Модель автора для чтения"""
|
||||
id: int
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
|
||||
|
||||
class AuthorList(SQLModel):
|
||||
"""Список авторов"""
|
||||
authors: List[AuthorRead]
|
||||
total: int
|
||||
|
||||
authors: List[AuthorRead] = Field(description="Список авторов")
|
||||
total: int = Field(description="Количество авторов")
|
||||
|
||||
@@ -11,9 +11,9 @@ from library_service.models.enums import BookStatus
|
||||
class BookBase(SQLModel):
|
||||
"""Базовая модель книги"""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
page_count: int = Field(gt=0)
|
||||
title: str = Field(description="Название")
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(gt=0, description="Количество страниц")
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={
|
||||
@@ -35,21 +35,21 @@ class BookCreate(BookBase):
|
||||
class BookUpdate(SQLModel):
|
||||
"""Модель книги для обновления"""
|
||||
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
page_count: int | None = None
|
||||
status: BookStatus | None = None
|
||||
title: str | None = Field(None, description="Название")
|
||||
description: str | None = Field(None, description="Описание")
|
||||
page_count: int | None = Field(None, description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
|
||||
|
||||
class BookRead(BookBase):
|
||||
"""Модель книги для чтения"""
|
||||
|
||||
id: int
|
||||
status: BookStatus
|
||||
id: int = Field(description="Идентификатор")
|
||||
status: BookStatus = Field(description="Статус")
|
||||
|
||||
|
||||
class BookList(SQLModel):
|
||||
"""Список книг"""
|
||||
|
||||
books: List[BookRead]
|
||||
total: int
|
||||
books: List[BookRead] = Field(description="Список книг")
|
||||
total: int = Field(description="Количество книг")
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Модуль DTO-моделей жанров"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class GenreBase(SQLModel):
|
||||
"""Базовая модель жанра"""
|
||||
name: str
|
||||
|
||||
name: str = Field(description="Название")
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={"example": {"name": "genre_name"}}
|
||||
@@ -16,20 +18,24 @@ class GenreBase(SQLModel):
|
||||
|
||||
class GenreCreate(GenreBase):
|
||||
"""Модель жанра для создания"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GenreUpdate(SQLModel):
|
||||
"""Модель жанра для обновления"""
|
||||
name: str | None = None
|
||||
|
||||
name: str | None = Field(None, description="Название")
|
||||
|
||||
|
||||
class GenreRead(GenreBase):
|
||||
"""Модель жанра для чтения"""
|
||||
id: int
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
|
||||
|
||||
class GenreList(SQLModel):
|
||||
"""Списко жанров"""
|
||||
genres: List[GenreRead]
|
||||
total: int
|
||||
|
||||
genres: List[GenreRead] = Field(description="Список жанров")
|
||||
total: int = Field(description="Количество жанров")
|
||||
|
||||
@@ -1,37 +1,49 @@
|
||||
"""Модуль DTO-моделей для выдачи книг"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class LoanBase(SQLModel):
|
||||
"""Базовая модель выдачи"""
|
||||
book_id: int
|
||||
user_id: int
|
||||
due_date: datetime
|
||||
|
||||
book_id: int = Field(description="Идентификатор книги")
|
||||
user_id: int = Field(description="Идентификатор пользователя")
|
||||
due_date: datetime = Field(description="Дата и время планируемого возврата")
|
||||
|
||||
|
||||
class LoanCreate(LoanBase):
|
||||
"""Модель для создания записи о выдаче"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LoanUpdate(SQLModel):
|
||||
"""Модель для обновления записи о выдаче"""
|
||||
user_id: int | None = None
|
||||
due_date: datetime | None = None
|
||||
returned_at: datetime | None = None
|
||||
|
||||
user_id: int | None = Field(None, description="Идентификатор пользователя")
|
||||
due_date: datetime | None = Field(
|
||||
None, description="дата и время планируемого возврата"
|
||||
)
|
||||
returned_at: datetime | None = Field(
|
||||
None, description="Дата и время фактического возврата"
|
||||
)
|
||||
|
||||
|
||||
class LoanRead(LoanBase):
|
||||
"""Модель чтения записи о выдаче"""
|
||||
id: int
|
||||
borrowed_at: datetime
|
||||
returned_at: datetime | None = None
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
borrowed_at: datetime = Field(description="Дата и время выдачи")
|
||||
returned_at: datetime | None = Field(
|
||||
None, description="Дата и время фактического возврата"
|
||||
)
|
||||
|
||||
|
||||
class LoanList(SQLModel):
|
||||
"""Список выдач"""
|
||||
loans: List[LoanRead]
|
||||
total: int
|
||||
|
||||
loans: List[LoanRead] = Field(description="Список выдач")
|
||||
total: int = Field(description="Количество выдач")
|
||||
|
||||
@@ -18,130 +18,142 @@ from .recovery import RecoveryCodesResponse
|
||||
class AuthorWithBooks(SQLModel):
|
||||
"""Модель автора с книгами"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
id: int = Field(description="Идентификатор")
|
||||
name: str = Field(description="Псевдоним")
|
||||
books: List[BookRead] = Field(default_factory=list, description="Список книг")
|
||||
|
||||
|
||||
class GenreWithBooks(SQLModel):
|
||||
"""Модель жанра с книгами"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
id: int = Field(description="Идентификатор")
|
||||
name: str = Field(description="Название")
|
||||
books: List[BookRead] = Field(default_factory=list, description="Список книг")
|
||||
|
||||
|
||||
class BookWithAuthors(SQLModel):
|
||||
"""Модель книги с авторами"""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
page_count: int
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
id: int = Field(description="Идентификатор")
|
||||
title: str = Field(description="Название")
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
authors: List[AuthorRead] = Field(
|
||||
default_factory=list, description="Список авторов"
|
||||
)
|
||||
|
||||
|
||||
class BookWithGenres(SQLModel):
|
||||
"""Модель книги с жанрами"""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
page_count: int
|
||||
status: BookStatus | None = None
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
id: int = Field(description="Идентификатор")
|
||||
title: str = Field(description="Название")
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
|
||||
|
||||
|
||||
class BookWithAuthorsAndGenres(SQLModel):
|
||||
"""Модель с авторами и жанрами"""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
page_count: int
|
||||
status: BookStatus | None = None
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
id: int = Field(description="Идентификатор")
|
||||
title: str = Field(description="Название")
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
authors: List[AuthorRead] = Field(
|
||||
default_factory=list, description="Список авторов"
|
||||
)
|
||||
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
|
||||
|
||||
|
||||
class BookFilteredList(SQLModel):
|
||||
"""Список книг с фильтрацией"""
|
||||
|
||||
books: List[BookWithAuthorsAndGenres]
|
||||
total: int
|
||||
books: List[BookWithAuthorsAndGenres] = Field(
|
||||
description="Список отфильтрованных книг"
|
||||
)
|
||||
total: int = Field(description="Количество книг")
|
||||
|
||||
|
||||
class LoanWithBook(LoanRead):
|
||||
"""Модель выдачи, включающая данные о книге"""
|
||||
|
||||
book: BookRead
|
||||
book: BookRead = Field(description="Книга")
|
||||
|
||||
|
||||
class BookStatusUpdate(SQLModel):
|
||||
"""Модель для ручного изменения статуса библиотекарем"""
|
||||
|
||||
status: str
|
||||
status: str = Field(description="Статус книги")
|
||||
|
||||
|
||||
class UserCreateByAdmin(UserCreate):
|
||||
"""Создание пользователя администратором"""
|
||||
|
||||
is_active: bool = True
|
||||
roles: list[str] | None = None
|
||||
is_active: bool = Field(True, description="Не является ли заблокированным")
|
||||
roles: list[str] | None = Field(None, description="Роли")
|
||||
|
||||
|
||||
class UserUpdateByAdmin(UserUpdate):
|
||||
"""Обновление пользователя администратором"""
|
||||
|
||||
is_active: bool | None = None
|
||||
roles: list[str] | None = None
|
||||
is_active: bool = Field(True, description="Не является ли заблокированным")
|
||||
roles: list[str] | None = Field(None, description="Роли")
|
||||
|
||||
|
||||
class LoginResponse(SQLModel):
|
||||
"""Модель для авторизации пользователя"""
|
||||
|
||||
access_token: str | None = None
|
||||
partial_token: str | None = None
|
||||
refresh_token: str | None = None
|
||||
token_type: str = "bearer"
|
||||
requires_2fa: bool = False
|
||||
access_token: str | None = Field(None, description="Токен доступа")
|
||||
partial_token: str | None = Field(None, description="Частичный токен")
|
||||
refresh_token: str | None = Field(None, description="Токен обновления")
|
||||
token_type: str = Field("bearer", description="Тип токена")
|
||||
requires_2fa: bool = Field(False, description="Требуется ли TOTP=код")
|
||||
|
||||
|
||||
class RegisterResponse(SQLModel):
|
||||
"""Модель для регистрации пользователя"""
|
||||
|
||||
user: UserRead
|
||||
recovery_codes: RecoveryCodesResponse
|
||||
user: UserRead = Field(description="Пользователь")
|
||||
recovery_codes: RecoveryCodesResponse = Field(description="Коды восстановления")
|
||||
|
||||
|
||||
class PasswordResetResponse(SQLModel):
|
||||
"""Модель для сброса пароля"""
|
||||
|
||||
total: int
|
||||
remaining: int
|
||||
used_codes: list[bool]
|
||||
generated_at: datetime | None
|
||||
should_regenerate: bool
|
||||
total: int = Field(description="Общее количество кодов")
|
||||
remaining: int = Field(description="Количество оставшихся кодов")
|
||||
used_codes: list[bool] = Field(description="Количество использованых кодов")
|
||||
generated_at: datetime | None = Field(description="Дата и время генерации")
|
||||
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
|
||||
|
||||
|
||||
class TOTPSetupResponse(SQLModel):
|
||||
"""Модель для генерации данных для настройки TOTP"""
|
||||
|
||||
secret: str
|
||||
username: str
|
||||
issuer: str
|
||||
size: int
|
||||
padding: int
|
||||
bitmap_b64: str
|
||||
secret: str = Field(description="Секрет TOTP")
|
||||
username: str = Field(description="Имя пользователя")
|
||||
issuer: str = Field(description="Запрашивающий сервис")
|
||||
size: int = Field(description="Размер кода")
|
||||
padding: int = Field(description="Отступ")
|
||||
bitmap_b64: str = Field(description="QR-код")
|
||||
|
||||
|
||||
class TOTPVerifyRequest(SQLModel):
|
||||
"""Модель для проверки TOTP кода"""
|
||||
|
||||
code: str = Field(min_length=6, max_length=6, regex=r"^\d{6}$")
|
||||
code: str = Field(
|
||||
min_length=6,
|
||||
max_length=6,
|
||||
regex=r"^\d{6}$",
|
||||
description="Шестизначный TOTP-код",
|
||||
)
|
||||
|
||||
|
||||
class TOTPDisableRequest(SQLModel):
|
||||
"""Модель для отключения TOTP 2FA"""
|
||||
|
||||
password: str
|
||||
password: str = Field(description="Пароль")
|
||||
|
||||
@@ -10,26 +10,28 @@ from sqlmodel import SQLModel, Field
|
||||
class RecoveryCodesResponse(SQLModel):
|
||||
"""Ответ при генерации резервных кодов"""
|
||||
|
||||
codes: list[str]
|
||||
generated_at: datetime
|
||||
codes: list[str] = Field(description="Список кодов восстановления")
|
||||
generated_at: datetime = Field(description="Дата и время генерации")
|
||||
|
||||
|
||||
class RecoveryCodesStatus(SQLModel):
|
||||
"""Статус резервных кодов пользователя"""
|
||||
|
||||
total: int
|
||||
remaining: int
|
||||
used_codes: list[bool]
|
||||
generated_at: datetime | None
|
||||
should_regenerate: bool
|
||||
total: int = Field(description="Общее количество кодов")
|
||||
remaining: int = Field(description="Количество оставшихся кодов")
|
||||
used_codes: list[bool] = Field(description="Количество использованых кодов")
|
||||
generated_at: datetime | None = Field(description="Дата и время генерации")
|
||||
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
|
||||
|
||||
|
||||
class RecoveryCodeUse(SQLModel):
|
||||
"""Запрос на сброс пароля через резервный код"""
|
||||
|
||||
username: str
|
||||
recovery_code: str = Field(min_length=19, max_length=19)
|
||||
new_password: str = Field(min_length=8, max_length=100)
|
||||
username: str = Field(description="Имя пользователя")
|
||||
recovery_code: str = Field(
|
||||
min_length=19, max_length=19, description="Код восстановления"
|
||||
)
|
||||
new_password: str = Field(min_length=8, max_length=100, description="Новый пароль")
|
||||
|
||||
@field_validator("recovery_code")
|
||||
@classmethod
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
"""Модуль DTO-моделей ролей"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class RoleBase(SQLModel):
|
||||
"""Базовая модель роли"""
|
||||
name: str
|
||||
description: str | None = None
|
||||
payroll: int = 0
|
||||
|
||||
name: str = Field(description="Название")
|
||||
description: str | None = Field(None, description="Описание")
|
||||
payroll: int = Field(0, description="Оплата")
|
||||
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
"""Модель роли для создания"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RoleUpdate(SQLModel):
|
||||
"""Модель роли для обновления"""
|
||||
name: str | None = None
|
||||
|
||||
name: str | None = Field(None, description="Название")
|
||||
|
||||
|
||||
class RoleRead(RoleBase):
|
||||
"""Модель роли для чтения"""
|
||||
id: int
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
|
||||
|
||||
class RoleList(SQLModel):
|
||||
"""Список ролей"""
|
||||
roles: List[RoleRead]
|
||||
total: int
|
||||
|
||||
roles: List[RoleRead] = Field(description="Список ролей")
|
||||
total: int = Field(description="Количество ролей")
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
"""Модуль DTO-моделей токенов"""
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class Token(SQLModel):
|
||||
"""Модель токена"""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
refresh_token: str | None = None
|
||||
access_token: str = Field(description="Токен доступа")
|
||||
token_type: str = Field("bearer", description="Тип токена")
|
||||
refresh_token: str | None = Field(None, description="Токен обновления")
|
||||
|
||||
|
||||
class PartialToken(SQLModel):
|
||||
"""Частичный токен — для подтверждения 2FA"""
|
||||
|
||||
partial_token: str
|
||||
token_type: str = "partial"
|
||||
requires_2fa: bool = True
|
||||
partial_token: str = Field(description="Частичный токен")
|
||||
token_type: str = Field("partial", description="Тип токена")
|
||||
requires_2fa: bool = Field(True, description="Требуется TOTP-код")
|
||||
|
||||
|
||||
class TokenData(SQLModel):
|
||||
"""Модель содержимого токена"""
|
||||
|
||||
username: str | None = None
|
||||
user_id: int | None = None
|
||||
is_partial: bool = False
|
||||
username: str | None = Field(None, description="Имя пользователя")
|
||||
user_id: int | None = Field(None, description="Идентификатор пользователя")
|
||||
is_partial: bool = Field(False, description="Является ли токен частичным")
|
||||
|
||||
@@ -10,9 +10,17 @@ from sqlmodel import Field, SQLModel
|
||||
class UserBase(SQLModel):
|
||||
"""Базовая модель пользователя"""
|
||||
|
||||
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
|
||||
email: EmailStr = Field(index=True, unique=True)
|
||||
full_name: str | None = Field(default=None, max_length=100)
|
||||
username: str = Field(
|
||||
min_length=3,
|
||||
max_length=50,
|
||||
index=True,
|
||||
unique=True,
|
||||
description="Имя пользователя",
|
||||
)
|
||||
email: EmailStr = Field(index=True, unique=True, description="Email")
|
||||
full_name: str | None = Field(
|
||||
default=None, max_length=100, description="Полное имя"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
@@ -28,7 +36,7 @@ class UserBase(SQLModel):
|
||||
class UserCreate(UserBase):
|
||||
"""Модель пользователя для создания"""
|
||||
|
||||
password: str = Field(min_length=8, max_length=100)
|
||||
password: str = Field(min_length=8, max_length=100, description="Пароль")
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
@@ -46,30 +54,30 @@ class UserCreate(UserBase):
|
||||
class UserLogin(SQLModel):
|
||||
"""Модель аутентификации для пользователя"""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
username: str = Field(description="Имя пользователя")
|
||||
password: str = Field(description="Пароль")
|
||||
|
||||
|
||||
class UserRead(UserBase):
|
||||
"""Модель пользователя для чтения"""
|
||||
|
||||
id: int
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
is_2fa_enabled: bool
|
||||
roles: List[str] = []
|
||||
is_active: bool = Field(description="Не является ли заблокированым")
|
||||
is_verified: bool = Field(description="Является ли верифицированым")
|
||||
is_2fa_enabled: bool = Field(description="Включен ли TOTP 2FA")
|
||||
roles: List[str] = Field([], description="Роли")
|
||||
|
||||
|
||||
class UserUpdate(SQLModel):
|
||||
"""Модель пользователя для обновления"""
|
||||
|
||||
email: EmailStr | None = None
|
||||
full_name: str | None = None
|
||||
password: str | None = None
|
||||
email: EmailStr | None = Field(None, description="Email")
|
||||
full_name: str | None = Field(None, description="Полное имя")
|
||||
password: str | None = Field(None, description="Пароль")
|
||||
|
||||
|
||||
class UserList(SQLModel):
|
||||
"""Список пользователей"""
|
||||
|
||||
users: List[UserRead]
|
||||
total: int
|
||||
users: List[UserRead] = Field(description="Список пользователей")
|
||||
total: int = Field(description="Количество пользователей")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Модуль прочих эндпоинтов"""
|
||||
"""Модуль прочих эндпоинтов и веб-страниц"""
|
||||
|
||||
from datetime import datetime
|
||||
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.models.db import Author, Book, Genre, User
|
||||
from library_service.services import SchemaGenerator
|
||||
from library_service import models
|
||||
|
||||
|
||||
router = APIRouter(tags=["misc"])
|
||||
generator = SchemaGenerator(models.db, models.dto)
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
||||
|
||||
|
||||
@@ -133,12 +136,6 @@ async def analytics(request: Request):
|
||||
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)
|
||||
def redirect_favicon():
|
||||
"""Редиректит на 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(
|
||||
"/api/info",
|
||||
summary="Информация о сервисе",
|
||||
@@ -163,6 +166,15 @@ async def api_info(app=Depends(lambda: get_app())):
|
||||
return JSONResponse(content=get_info(app))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/schema",
|
||||
summary="Информация о таблицах и связях",
|
||||
description="Возвращает схему базы данных с описаниями полей",
|
||||
)
|
||||
async def api_schema():
|
||||
return generator.generate()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/stats",
|
||||
summary="Статистика сервиса",
|
||||
|
||||
@@ -12,6 +12,7 @@ from .captcha import (
|
||||
REDEEM_TTL,
|
||||
prng,
|
||||
)
|
||||
from .describe_er import SchemaGenerator
|
||||
|
||||
__all__ = [
|
||||
"limiter",
|
||||
@@ -26,4 +27,5 @@ __all__ = [
|
||||
"CHALLENGE_TTL",
|
||||
"REDEEM_TTL",
|
||||
"prng",
|
||||
"SchemaGenerator",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Модуль создания и проверки capjs"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
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">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title id="pageTitle">Loading...</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_info.title }}</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
@@ -20,15 +21,13 @@
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
li {
|
||||
margin: 15px 0;
|
||||
}
|
||||
li { margin: 10px 0; }
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 8px 15px;
|
||||
@@ -37,247 +36,164 @@
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
a:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
a:hover { background-color: #2980b9; }
|
||||
p { margin: 5px 0; }
|
||||
.status-ok { color: #27ae60; }
|
||||
.status-error { color: #e74c3c; }
|
||||
.server-time { color: #7f8c8d; font-size: 12px; }
|
||||
#erDiagram {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
width: 100%; height: 700px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
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;
|
||||
cursor: grab;
|
||||
}
|
||||
#erDiagram:active { cursor: grabbing; }
|
||||
.er-table {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
background: #fff;
|
||||
border: 1px solid #3498db;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
font-size: 13px;
|
||||
border: 1px solid #bdc3c7;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.er-table-header {
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
color: #ecf0f1;
|
||||
padding: 8px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.er-table-body {
|
||||
padding: 6px 8px;
|
||||
line-height: 1.4;
|
||||
background: #fff;
|
||||
padding: 4px 0;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.er-field {
|
||||
padding: 2px 0;
|
||||
padding: 4px 10px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
.er-field:hover {
|
||||
background-color: #ecf0f1;
|
||||
color: #2980b9;
|
||||
}
|
||||
.relation-label {
|
||||
font-size: 11px;
|
||||
background: #fff;
|
||||
padding: 1px 3px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: white;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #bdc3c7;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #ccc;
|
||||
color: #7f8c8d;
|
||||
z-index: 20;
|
||||
}
|
||||
.jtk-connector { z-index: 5; }
|
||||
.jtk-endpoint { z-index: 5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="/favicon.ico" />
|
||||
<h1>Welcome to {{ app_info.title }}!</h1>
|
||||
<p>Description: {{ app_info.description }}</p>
|
||||
<p>Version: {{ app_info.version }}</p>
|
||||
<p>Current Time: {{ server_time }}</p>
|
||||
<p>Status: {{ status }}</p>
|
||||
<h1 id="mainTitle">Загрузка...</h1>
|
||||
<p>Версия: <span id="appVersion">-</span></p>
|
||||
<p>Описание: <span id="appDescription">-</span></p>
|
||||
<p>Статус: <span id="appStatus">-</span></p>
|
||||
<p class="server-time">Время сервера: <span id="serverTime">-</span></p>
|
||||
<ul>
|
||||
<li><a href="/">Home page</a></li>
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="/docs">Swagger UI</a></li>
|
||||
<li><a href="/redoc">ReDoc</a></li>
|
||||
<li>
|
||||
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
|
||||
</li>
|
||||
<li><a href="https://github.com/wowlikon/LiB">Исходный код</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>ER Diagram</h2>
|
||||
<h2>Интерактивная ER диаграмма</h2>
|
||||
<div id="erDiagram"></div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
|
||||
<script>
|
||||
const diagramData = {
|
||||
entities: [
|
||||
{
|
||||
id: "User",
|
||||
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"
|
||||
}
|
||||
]
|
||||
};
|
||||
async function fetchInfo() {
|
||||
try {
|
||||
const response = await fetch('/api/info');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('pageTitle').textContent = data.app_info.title;
|
||||
document.getElementById('mainTitle').textContent = `Добро пожаловать в ${data.app_info.title} API!`;
|
||||
document.getElementById('appVersion').textContent = data.app_info.version;
|
||||
document.getElementById('appDescription').textContent = data.app_info.description;
|
||||
|
||||
const statusEl = document.getElementById('appStatus');
|
||||
statusEl.textContent = data.status;
|
||||
statusEl.className = data.status === 'ok' ? 'status-ok' : 'status-error';
|
||||
|
||||
document.getElementById('serverTime').textContent = new Date(data.server_time).toLocaleString();
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки info:', error);
|
||||
document.getElementById('appStatus').textContent = 'Ошибка соединения';
|
||||
document.getElementById('appStatus').className = 'status-error';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSchemaAndRender() {
|
||||
try {
|
||||
const response = await fetch('/api/schema');
|
||||
const diagramData = await response.json();
|
||||
renderDiagram(diagramData);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки схемы:', error);
|
||||
document.getElementById('erDiagram').innerHTML = '<p style="padding:20px;color:#e74c3c;">Ошибка загрузки схемы</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiagram(diagramData) {
|
||||
jsPlumb.ready(function () {
|
||||
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 baseLeft = 40;
|
||||
const baseTop = 80;
|
||||
const spacingX = 240;
|
||||
const tableWidth = 200;
|
||||
|
||||
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");
|
||||
table.className = "er-table";
|
||||
table.id = "table-" + entity.id;
|
||||
table.style.top = baseTop + "px";
|
||||
table.style.left = baseLeft + index * spacingX + "px";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "er-table-header";
|
||||
@@ -289,40 +205,98 @@
|
||||
entity.fields.forEach(field => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "er-field";
|
||||
row.id = "field-" + entity.id + "-" + field.id;
|
||||
row.textContent = field.label || field.id;
|
||||
row.id = `field-${entity.id}-${field.id}`;
|
||||
row.style.display = "flex";
|
||||
row.style.alignItems = "center";
|
||||
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.textContent = field.label || field.id;
|
||||
row.appendChild(labelSpan);
|
||||
|
||||
if (field.tooltip) {
|
||||
row.title = field.tooltip;
|
||||
|
||||
const tip = document.createElement("span");
|
||||
tip.textContent = "ⓘ";
|
||||
tip.title = field.tooltip;
|
||||
tip.style.marginLeft = "4px";
|
||||
tip.style.marginRight = "0";
|
||||
tip.style.fontSize = "10px";
|
||||
tip.style.cursor = "help";
|
||||
tip.style.marginLeft = "auto";
|
||||
row.appendChild(tip);
|
||||
}
|
||||
|
||||
body.appendChild(row);
|
||||
});
|
||||
|
||||
table.appendChild(header);
|
||||
table.appendChild(body);
|
||||
container.appendChild(table);
|
||||
|
||||
const estimatedHeight = 20 + (entity.fields.length * 26);
|
||||
g.setNode(entity.id, { width: tableWidth, height: estimatedHeight });
|
||||
});
|
||||
|
||||
const common = {
|
||||
endpoint: "Dot",
|
||||
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" });
|
||||
const layoutEdges = [];
|
||||
const m2oGroups = {};
|
||||
|
||||
diagramData.relations.forEach(rel => {
|
||||
jsPlumb.connect({
|
||||
source: "field-" + rel.fromEntity + "-" + rel.fromField,
|
||||
target: "field-" + rel.toEntity + "-" + rel.toField,
|
||||
overlays: [
|
||||
["Label", { label: rel.fromMultiplicity || "", location: 0.2, cssClass: "relation-label" }],
|
||||
["Label", { label: rel.toMultiplicity || "", location: 0.8, cssClass: "relation-label" }]
|
||||
],
|
||||
...common
|
||||
const isManyToOne = (rel.fromMultiplicity === 'N' || rel.fromMultiplicity === '*') && (rel.toMultiplicity === '1');
|
||||
|
||||
if (isManyToOne) {
|
||||
const fi = (fieldIndexByEntity[rel.fromEntity] || {})[rel.fromField] ?? 0;
|
||||
if (!m2oGroups[rel.fromEntity]) m2oGroups[rel.fromEntity] = [];
|
||||
m2oGroups[rel.fromEntity].push({ rel, fieldIndex: fi });
|
||||
} else {
|
||||
layoutEdges.push({ source: rel.fromEntity, target: rel.toEntity });
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(m2oGroups).forEach(fromEntity => {
|
||||
const arr = m2oGroups[fromEntity];
|
||||
arr.sort((a, b) => a.fieldIndex - b.fieldIndex);
|
||||
|
||||
arr.forEach((item, idx) => {
|
||||
const rel = item.rel;
|
||||
if (idx % 2 === 0) { layoutEdges.push({ source: rel.toEntity, target: rel.fromEntity });
|
||||
} else { layoutEdges.push({ source: rel.fromEntity, target: rel.toEntity }); }
|
||||
});
|
||||
});
|
||||
|
||||
layoutEdges.forEach(e => g.setEdge(e.source, e.target));
|
||||
dagre.layout(g);
|
||||
|
||||
g.nodes().forEach(function(v) {
|
||||
const node = g.node(v);
|
||||
const el = document.getElementById("table-" + v);
|
||||
el.style.left = (node.x - (tableWidth / 2)) + "px";
|
||||
el.style.top = (node.y - (node.height / 2)) + "px";
|
||||
});
|
||||
|
||||
diagramData.relations.forEach(rel => {
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||
<div class="container mx-auto text-center">
|
||||
<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>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -5,6 +5,7 @@ Revises: b838606ad8d1
|
||||
Create Date: 2025-12-20 10:36:30.853896
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
@@ -13,39 +14,63 @@ import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '02ed6e775351'
|
||||
down_revision: Union[str, None] = 'b838606ad8d1'
|
||||
revision: str = "02ed6e775351"
|
||||
down_revision: Union[str, None] = "b838606ad8d1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
|
||||
book_status_enum.create(op.get_bind())
|
||||
op.create_table('book_loans',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('book_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('borrowed_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('due_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('returned_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
book_status_enum = sa.Enum(
|
||||
"active",
|
||||
"borrowed",
|
||||
"reserved",
|
||||
"restoration",
|
||||
"written_off",
|
||||
name="bookstatus",
|
||||
)
|
||||
op.create_index(op.f('ix_book_loans_id'), 'book_loans', ['id'], unique=False)
|
||||
op.add_column('book', sa.Column('status', book_status_enum, nullable=False, server_default='active'))
|
||||
op.drop_index(op.f('ix_roles_name'), table_name='roles')
|
||||
book_status_enum.create(op.get_bind())
|
||||
op.create_table(
|
||||
"loans",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("borrowed_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("due_date", sa.DateTime(), nullable=False),
|
||||
sa.Column("returned_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["book_id"],
|
||||
["book.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_loans_id"), "loans", ["id"], unique=False)
|
||||
op.add_column(
|
||||
"book",
|
||||
sa.Column("status", book_status_enum, nullable=False, server_default="active"),
|
||||
)
|
||||
op.drop_index(op.f("ix_roles_name"), table_name="roles")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True)
|
||||
op.drop_column('book', 'status')
|
||||
op.drop_index(op.f('ix_book_loans_id'), table_name='book_loans')
|
||||
op.drop_table('book_loans')
|
||||
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
|
||||
op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True)
|
||||
op.drop_column("book", "status")
|
||||
op.drop_index(op.f("ix_loans_id"), table_name="loans")
|
||||
op.drop_table("loans")
|
||||
book_status_enum = sa.Enum(
|
||||
"active",
|
||||
"borrowed",
|
||||
"reserved",
|
||||
"restoration",
|
||||
"written_off",
|
||||
name="bookstatus",
|
||||
)
|
||||
book_status_enum.drop(op.get_bind())
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -27,7 +27,7 @@ def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"totp_secret", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True
|
||||
"totp_secret", sqlmodel.sql.sqltypes.AutoString(length=80), nullable=True
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "LibraryAPI"
|
||||
version = "0.6.0"
|
||||
name = "LiB"
|
||||
version = "0.7.0"
|
||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||
authors = [{ name = "wowlikon" }]
|
||||
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" }
|
||||
|
||||
[[package]]
|
||||
name = "libraryapi"
|
||||
name = "lib"
|
||||
version = "0.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user