Динамическое создание er-диаграммы по моделям

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