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