diff --git a/.env b/.env index 9b803b3..f8d4afa 100644 --- a/.env +++ b/.env @@ -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" diff --git a/README.md b/README.md index e8f53b5..8746ee6 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,12 @@ 1. Клонируйте репозиторий: ```bash - git clone https://github.com/wowlikon/libraryapi.git + git clone https://github.com/wowlikon/LiB.git ``` 2. Перейдите в каталог проекта: ```bash - cd libraryapi + cd LiB ``` 3. Настройте переменные окружения: @@ -44,7 +44,7 @@ Для создания новых миграций: ```bash - alembic revision --autogenerate -m "Migration name" + uv run alembic revision --autogenerate -m "Migration name" ``` Для запуска тестов: @@ -52,14 +52,9 @@ docker compose up test ``` -Для добавления данных для примера используйте: - ```bash - python data.py - ``` - ### **Роли пользователей** -- **Админ**: Полный доступ ко всем функциям системы +- **admin**: Полный доступ ко всем функциям системы - **librarian**: Управление книгами, авторами, жанрами и выдачами - **member**: Просмотр каталога и управление своими выдачами @@ -166,10 +161,11 @@ #### **Прочее** (`/api`) -| Метод | Эндпоинт | Доступ | Описание | -|-------|----------|-----------|----------------------| -| GET | `/info` | Публичный | Информация о сервисе | -| GET | `/stats` | Публичный | Статистика системы | +| Метод | Эндпоинт | Доступ | Описание | +|-------|-----------|-----------|----------------------| +| GET | `/info` | Публичный | Информация о сервисе | +| GET | `/stats` | Публичный | Статистика системы | +| GET | `/schema` | Публичный | Схема базы данных | ### **Веб-страницы** diff --git a/data.py b/data.py deleted file mode 100644 index 2b0739e..0000000 --- a/data.py +++ /dev/null @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index cea234d..f970ca5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/library_service/models/db/author.py b/library_service/models/db/author.py index 1cad188..c517d87 100644 --- a/library_service/models/db/author.py +++ b/library_service/models/db/author.py @@ -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 ) diff --git a/library_service/models/db/book.py b/library_service/models/db/book.py index 06ea925..97b4e96 100644 --- a/library_service/models/db/book.py +++ b/library_service/models/db/book.py @@ -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 diff --git a/library_service/models/db/genre.py b/library_service/models/db/genre.py index d8a3cfa..04cc218 100644 --- a/library_service/models/db/genre.py +++ b/library_service/models/db/genre.py @@ -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 ) diff --git a/library_service/models/db/links.py b/library_service/models/db/links.py index 683d8cd..7f4b76a 100644 --- a/library_service/models/db/links.py +++ b/library_service/models/db/links.py @@ -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="Дата и время фактического возврата" + ) diff --git a/library_service/models/db/role.py b/library_service/models/db/role.py index bc5e6c2..0685afe 100644 --- a/library_service/models/db/role.py +++ b/library_service/models/db/role.py @@ -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) diff --git a/library_service/models/db/user.py b/library_service/models/db/user.py index 303e027..f904d16 100644 --- a/library_service/models/db/user.py +++ b/library_service/models/db/user.py @@ -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) diff --git a/library_service/models/dto/author.py b/library_service/models/dto/author.py index 877925d..ef7ba85 100644 --- a/library_service/models/dto/author.py +++ b/library_service/models/dto/author.py @@ -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="Количество авторов") diff --git a/library_service/models/dto/book.py b/library_service/models/dto/book.py index e06b485..d1b0e5d 100644 --- a/library_service/models/dto/book.py +++ b/library_service/models/dto/book.py @@ -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="Количество книг") diff --git a/library_service/models/dto/genre.py b/library_service/models/dto/genre.py index 643207a..7249f26 100644 --- a/library_service/models/dto/genre.py +++ b/library_service/models/dto/genre.py @@ -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="Количество жанров") diff --git a/library_service/models/dto/loan.py b/library_service/models/dto/loan.py index c78108b..728bcaf 100644 --- a/library_service/models/dto/loan.py +++ b/library_service/models/dto/loan.py @@ -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="Количество выдач") diff --git a/library_service/models/dto/misc.py b/library_service/models/dto/misc.py index dcbd7c5..12c3e95 100644 --- a/library_service/models/dto/misc.py +++ b/library_service/models/dto/misc.py @@ -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="Пароль") diff --git a/library_service/models/dto/recovery.py b/library_service/models/dto/recovery.py index f9f2397..ed093e6 100644 --- a/library_service/models/dto/recovery.py +++ b/library_service/models/dto/recovery.py @@ -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 diff --git a/library_service/models/dto/role.py b/library_service/models/dto/role.py index 28100f8..7efccec 100644 --- a/library_service/models/dto/role.py +++ b/library_service/models/dto/role.py @@ -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="Количество ролей") diff --git a/library_service/models/dto/token.py b/library_service/models/dto/token.py index fe93edf..7c2cb95 100644 --- a/library_service/models/dto/token.py +++ b/library_service/models/dto/token.py @@ -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="Является ли токен частичным") diff --git a/library_service/models/dto/user.py b/library_service/models/dto/user.py index 2b95dcc..b28501c 100644 --- a/library_service/models/dto/user.py +++ b/library_service/models/dto/user.py @@ -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="Количество пользователей") diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py index f096d6f..0668476 100644 --- a/library_service/routers/misc.py +++ b/library_service/routers/misc.py @@ -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="Статистика сервиса", diff --git a/library_service/services/__init__.py b/library_service/services/__init__.py index 4f5fe7b..934a395 100644 --- a/library_service/services/__init__.py +++ b/library_service/services/__init__.py @@ -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", ] diff --git a/library_service/services/captcha.py b/library_service/services/captcha.py index b9b7b72..3797364 100644 --- a/library_service/services/captcha.py +++ b/library_service/services/captcha.py @@ -1,3 +1,5 @@ +"""Модуль создания и проверки capjs""" + import os import asyncio import hashlib diff --git a/library_service/services/describe_er.py b/library_service/services/describe_er.py new file mode 100644 index 0000000..77142df --- /dev/null +++ b/library_service/services/describe_er.py @@ -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} diff --git a/library_service/templates/api.html b/library_service/templates/api.html index 3a3ad9f..d7374f7 100644 --- a/library_service/templates/api.html +++ b/library_service/templates/api.html @@ -2,15 +2,16 @@ + Loading... - {{ app_info.title }} + + -

Welcome to {{ app_info.title }}!

-

Description: {{ app_info.description }}

-

Version: {{ app_info.version }}

-

Current Time: {{ server_time }}

-

Status: {{ status }}

+

Загрузка...

+

Версия: -

+

Описание: -

+

Статус: -

+

Время сервера: -

- -

ER Diagram

+

Интерактивная ER диаграмма

- - diff --git a/library_service/templates/base.html b/library_service/templates/base.html index 502c17d..a440b67 100644 --- a/library_service/templates/base.html +++ b/library_service/templates/base.html @@ -239,7 +239,7 @@ diff --git a/migrations/versions/02ed6e775351_loans.py b/migrations/versions/02ed6e775351_loans.py index 0dee15c..44859dc 100644 --- a/migrations/versions/02ed6e775351_loans.py +++ b/migrations/versions/02ed6e775351_loans.py @@ -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 ### diff --git a/migrations/versions/a585fd97b88c_recovery_codes_and_totp.py b/migrations/versions/a585fd97b88c_recovery_codes_and_totp.py index b25b961..70b388f 100644 --- a/migrations/versions/a585fd97b88c_recovery_codes_and_totp.py +++ b/migrations/versions/a585fd97b88c_recovery_codes_and_totp.py @@ -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( diff --git a/pyproject.toml b/pyproject.toml index 075b5ba..d5150aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 9a8023c..0000000 --- a/tests/README.md +++ /dev/null @@ -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 логики этого достаточно. diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/mock_app.py b/tests/mock_app.py deleted file mode 100644 index 621a9f6..0000000 --- a/tests/mock_app.py +++ /dev/null @@ -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() diff --git a/tests/mock_routers/__init__.py b/tests/mock_routers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/mock_routers/authors.py b/tests/mock_routers/authors.py deleted file mode 100644 index 1313eb3..0000000 --- a/tests/mock_routers/authors.py +++ /dev/null @@ -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 diff --git a/tests/mock_routers/books.py b/tests/mock_routers/books.py deleted file mode 100644 index 702a32c..0000000 --- a/tests/mock_routers/books.py +++ /dev/null @@ -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 diff --git a/tests/mock_routers/genres.py b/tests/mock_routers/genres.py deleted file mode 100644 index aeccecc..0000000 --- a/tests/mock_routers/genres.py +++ /dev/null @@ -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 diff --git a/tests/mock_routers/relationships.py b/tests/mock_routers/relationships.py deleted file mode 100644 index fa6c1c8..0000000 --- a/tests/mock_routers/relationships.py +++ /dev/null @@ -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} diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/mocks/mock_session.py b/tests/mocks/mock_session.py deleted file mode 100644 index 1d972cd..0000000 --- a/tests/mocks/mock_session.py +++ /dev/null @@ -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() diff --git a/tests/mocks/mock_storage.py b/tests/mocks/mock_storage.py deleted file mode 100644 index 81d8fba..0000000 --- a/tests/mocks/mock_storage.py +++ /dev/null @@ -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() diff --git a/tests/test_authors.py b/tests/test_authors.py deleted file mode 100644 index f6336f3..0000000 --- a/tests/test_authors.py +++ /dev/null @@ -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" diff --git a/tests/test_books.py b/tests/test_books.py deleted file mode 100644 index d6e5720..0000000 --- a/tests/test_books.py +++ /dev/null @@ -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" diff --git a/tests/test_misc.py b/tests/test_misc.py deleted file mode 100644 index f24c78e..0000000 --- a/tests/test_misc.py +++ /dev/null @@ -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(""), "Not HTML" - assert content.endswith(""), "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" diff --git a/tests/test_relationships.py b/tests/test_relationships.py deleted file mode 100644 index 01cd44a..0000000 --- a/tests/test_relationships.py +++ /dev/null @@ -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 diff --git a/uv.lock b/uv.lock index e9de6a6..755397a 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = [