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 @@
+Description: {{ app_info.description }}
-Version: {{ app_info.version }}
-Current Time: {{ server_time }}
-Status: {{ status }}
+Версия: -
+Описание: -
+Статус: -
+Время сервера: -