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

This commit is contained in:
2026-01-25 20:11:08 +03:00
parent ec1c32a5bd
commit 09d5739256
44 changed files with 785 additions and 1773 deletions
+5 -1
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей авторов"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
class Author(AuthorBase, table=True):
"""Модель автора в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
books: List["Book"] = Relationship(
back_populates="authors", link_model=AuthorBookLink
)
+4 -1
View File
@@ -17,10 +17,13 @@ if TYPE_CHECKING:
class Book(BookBase, table=True):
"""Модель книги в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
status: BookStatus = Field(
default=BookStatus.ACTIVE,
sa_column=Column(String, nullable=False, default="active"),
description="Статус",
)
authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink
+5 -1
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей жанров"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
class Genre(GenreBase, table=True):
"""Модель жанра в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
books: List["Book"] = Relationship(
back_populates="genres", link_model=GenreBookLink
)
+46 -12
View File
@@ -10,14 +10,29 @@ class AuthorBookLink(SQLModel, table=True):
author_id: int | None = Field(
default=None, foreign_key="author.id", primary_key=True
)
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
book_id: int | None = Field(
default=None,
foreign_key="book.id",
primary_key=True,
description="Идентификатор книги",
)
class GenreBookLink(SQLModel, table=True):
"""Модель связи жанра и книги"""
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
genre_id: int | None = Field(
default=None,
foreign_key="genre.id",
primary_key=True,
description="Идентификатор жанра",
)
book_id: int | None = Field(
default=None,
foreign_key="book.id",
primary_key=True,
description="Идентификатор книги",
)
class UserRoleLink(SQLModel, table=True):
@@ -25,8 +40,18 @@ class UserRoleLink(SQLModel, table=True):
__tablename__ = "user_roles"
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
user_id: int | None = Field(
default=None,
foreign_key="users.id",
primary_key=True,
description="Идентификатор пользователя",
)
role_id: int | None = Field(
default=None,
foreign_key="roles.id",
primary_key=True,
description="Идентификатор роли",
)
class BookUserLink(SQLModel, table=True):
@@ -35,13 +60,22 @@ class BookUserLink(SQLModel, table=True):
Связывает книгу и пользователя с фиксацией времени.
"""
__tablename__ = "book_loans"
__tablename__ = "loans"
id: int | None = Field(default=None, primary_key=True, index=True)
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
book_id: int = Field(foreign_key="book.id")
user_id: int = Field(foreign_key="users.id")
book_id: int = Field(foreign_key="book.id", description="Идентификатор")
user_id: int = Field(
foreign_key="users.id", description="Идентификатор пользователя"
)
borrowed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
due_date: datetime
returned_at: datetime | None = Field(default=None)
borrowed_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="Дата и время выдачи",
)
due_date: datetime = Field(description="Дата и время запланированного возврата")
returned_at: datetime | None = Field(
default=None, description="Дата и время фактического возврата"
)
+5 -1
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей ролей"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
@@ -12,8 +13,11 @@ if TYPE_CHECKING:
class Role(RoleBase, table=True):
"""Модель роли в базе данных"""
__tablename__ = "roles"
id: int | None = Field(default=None, primary_key=True, index=True)
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
+25 -10
View File
@@ -17,17 +17,32 @@ class User(UserBase, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True, index=True)
hashed_password: str = Field(nullable=False)
is_2fa_enabled: bool = Field(default=False)
totp_secret: str | None = Field(default=None, max_length=80)
recovery_code_hashes: str | None = Field(default=None, max_length=1500)
recovery_codes_generated_at: datetime | None = Field(default=None)
is_active: bool = Field(default=True)
is_verified: bool = Field(default=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
id: int | None = Field(
default=None, primary_key=True, index=True, description="Идентификатор"
)
hashed_password: str = Field(nullable=False, description="Argon2id хэш пароля")
is_2fa_enabled: bool = Field(default=False, description="Включен TOTP 2FA")
totp_secret: str | None = Field(
default=None, max_length=80, description="Зашифрованный секрет TOTP"
)
recovery_code_hashes: str | None = Field(
default=None,
max_length=1500,
description="Argon2id хэши одноразовыхкодов восстановления",
)
recovery_codes_generated_at: datetime | None = Field(
default=None, description="Дата и время создания кодов восстановления"
)
is_active: bool = Field(default=True, description="Не является ли заблокированым")
is_verified: bool = Field(default=False, description="Является ли верифицированным")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="Дата и время создания",
)
updated_at: datetime | None = Field(
default=None, sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)}
default=None,
sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)},
description="Дата и время последнего обновления",
)
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
+12 -6
View File
@@ -1,13 +1,15 @@
"""Модуль DTO-моделей авторов"""
from typing import List
from pydantic import ConfigDict
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
class AuthorBase(SQLModel):
"""Базовая модель автора"""
name: str
name: str = Field(description="Псевдоним")
model_config = ConfigDict( # pyright: ignore
json_schema_extra={"example": {"name": "author_name"}}
@@ -16,20 +18,24 @@ class AuthorBase(SQLModel):
class AuthorCreate(AuthorBase):
"""Модель автора для создания"""
pass
class AuthorUpdate(SQLModel):
"""Модель автора для обновления"""
name: str | None = None
name: str | None = Field(None, description="Псевдоним")
class AuthorRead(AuthorBase):
"""Модель автора для чтения"""
id: int
id: int = Field(description="Идентификатор")
class AuthorList(SQLModel):
"""Список авторов"""
authors: List[AuthorRead]
total: int
authors: List[AuthorRead] = Field(description="Список авторов")
total: int = Field(description="Количество авторов")
+11 -11
View File
@@ -11,9 +11,9 @@ from library_service.models.enums import BookStatus
class BookBase(SQLModel):
"""Базовая модель книги"""
title: str
description: str
page_count: int = Field(gt=0)
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(gt=0, description="Количество страниц")
model_config = ConfigDict( # pyright: ignore
json_schema_extra={
@@ -35,21 +35,21 @@ class BookCreate(BookBase):
class BookUpdate(SQLModel):
"""Модель книги для обновления"""
title: str | None = None
description: str | None = None
page_count: int | None = None
status: BookStatus | None = None
title: str | None = Field(None, description="Название")
description: str | None = Field(None, description="Описание")
page_count: int | None = Field(None, description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
class BookRead(BookBase):
"""Модель книги для чтения"""
id: int
status: BookStatus
id: int = Field(description="Идентификатор")
status: BookStatus = Field(description="Статус")
class BookList(SQLModel):
"""Список книг"""
books: List[BookRead]
total: int
books: List[BookRead] = Field(description="Список книг")
total: int = Field(description="Количество книг")
+12 -6
View File
@@ -1,13 +1,15 @@
"""Модуль DTO-моделей жанров"""
from typing import List
from pydantic import ConfigDict
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
class GenreBase(SQLModel):
"""Базовая модель жанра"""
name: str
name: str = Field(description="Название")
model_config = ConfigDict( # pyright: ignore
json_schema_extra={"example": {"name": "genre_name"}}
@@ -16,20 +18,24 @@ class GenreBase(SQLModel):
class GenreCreate(GenreBase):
"""Модель жанра для создания"""
pass
class GenreUpdate(SQLModel):
"""Модель жанра для обновления"""
name: str | None = None
name: str | None = Field(None, description="Название")
class GenreRead(GenreBase):
"""Модель жанра для чтения"""
id: int
id: int = Field(description="Идентификатор")
class GenreList(SQLModel):
"""Списко жанров"""
genres: List[GenreRead]
total: int
genres: List[GenreRead] = Field(description="Список жанров")
total: int = Field(description="Количество жанров")
+24 -12
View File
@@ -1,37 +1,49 @@
"""Модуль DTO-моделей для выдачи книг"""
from typing import List
from datetime import datetime
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
class LoanBase(SQLModel):
"""Базовая модель выдачи"""
book_id: int
user_id: int
due_date: datetime
book_id: int = Field(description="Идентификатор книги")
user_id: int = Field(description="Идентификатор пользователя")
due_date: datetime = Field(description="Дата и время планируемого возврата")
class LoanCreate(LoanBase):
"""Модель для создания записи о выдаче"""
pass
class LoanUpdate(SQLModel):
"""Модель для обновления записи о выдаче"""
user_id: int | None = None
due_date: datetime | None = None
returned_at: datetime | None = None
user_id: int | None = Field(None, description="Идентификатор пользователя")
due_date: datetime | None = Field(
None, description="дата и время планируемого возврата"
)
returned_at: datetime | None = Field(
None, description="Дата и время фактического возврата"
)
class LoanRead(LoanBase):
"""Модель чтения записи о выдаче"""
id: int
borrowed_at: datetime
returned_at: datetime | None = None
id: int = Field(description="Идентификатор")
borrowed_at: datetime = Field(description="Дата и время выдачи")
returned_at: datetime | None = Field(
None, description="Дата и время фактического возврата"
)
class LoanList(SQLModel):
"""Список выдач"""
loans: List[LoanRead]
total: int
loans: List[LoanRead] = Field(description="Список выдач")
total: int = Field(description="Количество выдач")
+64 -52
View File
@@ -18,130 +18,142 @@ from .recovery import RecoveryCodesResponse
class AuthorWithBooks(SQLModel):
"""Модель автора с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
name: str = Field(description="Псевдоним")
books: List[BookRead] = Field(default_factory=list, description="Список книг")
class GenreWithBooks(SQLModel):
"""Модель жанра с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
name: str = Field(description="Название")
books: List[BookRead] = Field(default_factory=list, description="Список книг")
class BookWithAuthors(SQLModel):
"""Модель книги с авторами"""
id: int
title: str
description: str
page_count: int
authors: List[AuthorRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
class BookWithGenres(SQLModel):
"""Модель книги с жанрами"""
id: int
title: str
description: str
page_count: int
status: BookStatus | None = None
genres: List[GenreRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
class BookWithAuthorsAndGenres(SQLModel):
"""Модель с авторами и жанрами"""
id: int
title: str
description: str
page_count: int
status: BookStatus | None = None
authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list)
id: int = Field(description="Идентификатор")
title: str = Field(description="Название")
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
class BookFilteredList(SQLModel):
"""Список книг с фильтрацией"""
books: List[BookWithAuthorsAndGenres]
total: int
books: List[BookWithAuthorsAndGenres] = Field(
description="Список отфильтрованных книг"
)
total: int = Field(description="Количество книг")
class LoanWithBook(LoanRead):
"""Модель выдачи, включающая данные о книге"""
book: BookRead
book: BookRead = Field(description="Книга")
class BookStatusUpdate(SQLModel):
"""Модель для ручного изменения статуса библиотекарем"""
status: str
status: str = Field(description="Статус книги")
class UserCreateByAdmin(UserCreate):
"""Создание пользователя администратором"""
is_active: bool = True
roles: list[str] | None = None
is_active: bool = Field(True, description="Не является ли заблокированным")
roles: list[str] | None = Field(None, description="Роли")
class UserUpdateByAdmin(UserUpdate):
"""Обновление пользователя администратором"""
is_active: bool | None = None
roles: list[str] | None = None
is_active: bool = Field(True, description="Не является ли заблокированным")
roles: list[str] | None = Field(None, description="Роли")
class LoginResponse(SQLModel):
"""Модель для авторизации пользователя"""
access_token: str | None = None
partial_token: str | None = None
refresh_token: str | None = None
token_type: str = "bearer"
requires_2fa: bool = False
access_token: str | None = Field(None, description="Токен доступа")
partial_token: str | None = Field(None, description="Частичный токен")
refresh_token: str | None = Field(None, description="Токен обновления")
token_type: str = Field("bearer", description="Тип токена")
requires_2fa: bool = Field(False, description="Требуется ли TOTP=код")
class RegisterResponse(SQLModel):
"""Модель для регистрации пользователя"""
user: UserRead
recovery_codes: RecoveryCodesResponse
user: UserRead = Field(description="Пользователь")
recovery_codes: RecoveryCodesResponse = Field(description="Коды восстановления")
class PasswordResetResponse(SQLModel):
"""Модель для сброса пароля"""
total: int
remaining: int
used_codes: list[bool]
generated_at: datetime | None
should_regenerate: bool
total: int = Field(description="Общее количество кодов")
remaining: int = Field(description="Количество оставшихся кодов")
used_codes: list[bool] = Field(description="Количество использованых кодов")
generated_at: datetime | None = Field(description="Дата и время генерации")
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
class TOTPSetupResponse(SQLModel):
"""Модель для генерации данных для настройки TOTP"""
secret: str
username: str
issuer: str
size: int
padding: int
bitmap_b64: str
secret: str = Field(description="Секрет TOTP")
username: str = Field(description="Имя пользователя")
issuer: str = Field(description="Запрашивающий сервис")
size: int = Field(description="Размер кода")
padding: int = Field(description="Отступ")
bitmap_b64: str = Field(description="QR-код")
class TOTPVerifyRequest(SQLModel):
"""Модель для проверки TOTP кода"""
code: str = Field(min_length=6, max_length=6, regex=r"^\d{6}$")
code: str = Field(
min_length=6,
max_length=6,
regex=r"^\d{6}$",
description="Шестизначный TOTP-код",
)
class TOTPDisableRequest(SQLModel):
"""Модель для отключения TOTP 2FA"""
password: str
password: str = Field(description="Пароль")
+12 -10
View File
@@ -10,26 +10,28 @@ from sqlmodel import SQLModel, Field
class RecoveryCodesResponse(SQLModel):
"""Ответ при генерации резервных кодов"""
codes: list[str]
generated_at: datetime
codes: list[str] = Field(description="Список кодов восстановления")
generated_at: datetime = Field(description="Дата и время генерации")
class RecoveryCodesStatus(SQLModel):
"""Статус резервных кодов пользователя"""
total: int
remaining: int
used_codes: list[bool]
generated_at: datetime | None
should_regenerate: bool
total: int = Field(description="Общее количество кодов")
remaining: int = Field(description="Количество оставшихся кодов")
used_codes: list[bool] = Field(description="Количество использованых кодов")
generated_at: datetime | None = Field(description="Дата и время генерации")
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
class RecoveryCodeUse(SQLModel):
"""Запрос на сброс пароля через резервный код"""
username: str
recovery_code: str = Field(min_length=19, max_length=19)
new_password: str = Field(min_length=8, max_length=100)
username: str = Field(description="Имя пользователя")
recovery_code: str = Field(
min_length=19, max_length=19, description="Код восстановления"
)
new_password: str = Field(min_length=8, max_length=100, description="Новый пароль")
@field_validator("recovery_code")
@classmethod
+14 -8
View File
@@ -1,32 +1,38 @@
"""Модуль DTO-моделей ролей"""
from typing import List
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
class RoleBase(SQLModel):
"""Базовая модель роли"""
name: str
description: str | None = None
payroll: int = 0
name: str = Field(description="Название")
description: str | None = Field(None, description="Описание")
payroll: int = Field(0, description="Оплата")
class RoleCreate(RoleBase):
"""Модель роли для создания"""
pass
class RoleUpdate(SQLModel):
"""Модель роли для обновления"""
name: str | None = None
name: str | None = Field(None, description="Название")
class RoleRead(RoleBase):
"""Модель роли для чтения"""
id: int
id: int = Field(description="Идентификатор")
class RoleList(SQLModel):
"""Список ролей"""
roles: List[RoleRead]
total: int
roles: List[RoleRead] = Field(description="Список ролей")
total: int = Field(description="Количество ролей")
+10 -10
View File
@@ -1,27 +1,27 @@
"""Модуль DTO-моделей токенов"""
from sqlmodel import SQLModel
from sqlmodel import SQLModel, Field
class Token(SQLModel):
"""Модель токена"""
access_token: str
token_type: str = "bearer"
refresh_token: str | None = None
access_token: str = Field(description="Токен доступа")
token_type: str = Field("bearer", description="Тип токена")
refresh_token: str | None = Field(None, description="Токен обновления")
class PartialToken(SQLModel):
"""Частичный токен — для подтверждения 2FA"""
partial_token: str
token_type: str = "partial"
requires_2fa: bool = True
partial_token: str = Field(description="Частичный токен")
token_type: str = Field("partial", description="Тип токена")
requires_2fa: bool = Field(True, description="Требуется TOTP-код")
class TokenData(SQLModel):
"""Модель содержимого токена"""
username: str | None = None
user_id: int | None = None
is_partial: bool = False
username: str | None = Field(None, description="Имя пользователя")
user_id: int | None = Field(None, description="Идентификатор пользователя")
is_partial: bool = Field(False, description="Является ли токен частичным")
+23 -15
View File
@@ -10,9 +10,17 @@ from sqlmodel import Field, SQLModel
class UserBase(SQLModel):
"""Базовая модель пользователя"""
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
email: EmailStr = Field(index=True, unique=True)
full_name: str | None = Field(default=None, max_length=100)
username: str = Field(
min_length=3,
max_length=50,
index=True,
unique=True,
description="Имя пользователя",
)
email: EmailStr = Field(index=True, unique=True, description="Email")
full_name: str | None = Field(
default=None, max_length=100, description="Полное имя"
)
model_config = ConfigDict(
json_schema_extra={
@@ -28,7 +36,7 @@ class UserBase(SQLModel):
class UserCreate(UserBase):
"""Модель пользователя для создания"""
password: str = Field(min_length=8, max_length=100)
password: str = Field(min_length=8, max_length=100, description="Пароль")
@field_validator("password")
@classmethod
@@ -46,30 +54,30 @@ class UserCreate(UserBase):
class UserLogin(SQLModel):
"""Модель аутентификации для пользователя"""
username: str
password: str
username: str = Field(description="Имя пользователя")
password: str = Field(description="Пароль")
class UserRead(UserBase):
"""Модель пользователя для чтения"""
id: int
is_active: bool
is_verified: bool
is_2fa_enabled: bool
roles: List[str] = []
is_active: bool = Field(description="Не является ли заблокированым")
is_verified: bool = Field(description="Является ли верифицированым")
is_2fa_enabled: bool = Field(description="Включен ли TOTP 2FA")
roles: List[str] = Field([], description="Роли")
class UserUpdate(SQLModel):
"""Модель пользователя для обновления"""
email: EmailStr | None = None
full_name: str | None = None
password: str | None = None
email: EmailStr | None = Field(None, description="Email")
full_name: str | None = Field(None, description="Полное имя")
password: str | None = Field(None, description="Пароль")
class UserList(SQLModel):
"""Список пользователей"""
users: List[UserRead]
total: int
users: List[UserRead] = Field(description="Список пользователей")
total: int = Field(description="Количество пользователей")
+19 -7
View File
@@ -1,4 +1,4 @@
"""Модуль прочих эндпоинтов"""
"""Модуль прочих эндпоинтов и веб-страниц"""
from datetime import datetime
from pathlib import Path
@@ -12,9 +12,12 @@ from sqlmodel import Session, select, func
from library_service.settings import get_app, get_session
from library_service.models.db import Author, Book, Genre, User
from library_service.services import SchemaGenerator
from library_service import models
router = APIRouter(tags=["misc"])
generator = SchemaGenerator(models.db, models.dto)
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
@@ -133,12 +136,6 @@ async def analytics(request: Request):
return templates.TemplateResponse(request, "analytics.html")
@router.get("/api", include_in_schema=False)
async def api(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу с ссылками на документацию API"""
return templates.TemplateResponse(request, "api.html", get_info(app))
@router.get("/favicon.ico", include_in_schema=False)
def redirect_favicon():
"""Редиректит на favicon.svg"""
@@ -153,6 +150,12 @@ async def favicon():
)
@router.get("/api", include_in_schema=False)
async def api(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу с ссылками на документацию API"""
return templates.TemplateResponse(request, "api.html", get_info(app))
@router.get(
"/api/info",
summary="Информация о сервисе",
@@ -163,6 +166,15 @@ async def api_info(app=Depends(lambda: get_app())):
return JSONResponse(content=get_info(app))
@router.get(
"/api/schema",
summary="Информация о таблицах и связях",
description="Возвращает схему базы данных с описаниями полей",
)
async def api_schema():
return generator.generate()
@router.get(
"/api/stats",
summary="Статистика сервиса",
+2
View File
@@ -12,6 +12,7 @@ from .captcha import (
REDEEM_TTL,
prng,
)
from .describe_er import SchemaGenerator
__all__ = [
"limiter",
@@ -26,4 +27,5 @@ __all__ = [
"CHALLENGE_TTL",
"REDEEM_TTL",
"prng",
"SchemaGenerator",
]
+2
View File
@@ -1,3 +1,5 @@
"""Модуль создания и проверки capjs"""
import os
import asyncio
import hashlib
+225
View File
@@ -0,0 +1,225 @@
"""Модуль генерации описания схемы БД"""
import inspect
from typing import List, Dict, Any, Set, Type, Tuple
from pydantic.fields import FieldInfo
from sqlalchemy.inspection import inspect as sa_inspect
from sqlmodel import SQLModel
class SchemaGenerator:
"""Сервис генерации json описания схемы БД"""
def __init__(self, db_module, dto_module=None):
self.db_models = self._get_classes(db_module, is_table=True)
self.dto_models = (
self._get_classes(dto_module, is_table=False) if dto_module else []
)
self.link_table_names = self._identify_link_tables()
self.field_descriptions = self._collect_all_descriptions()
self._table_to_model = {m.__tablename__: m for m in self.db_models}
def _get_classes(
self, module, is_table: bool | None = None
) -> List[Type[SQLModel]]:
if module is None:
return []
classes = []
for name, obj in inspect.getmembers(module):
if (
inspect.isclass(obj)
and issubclass(obj, SQLModel)
and obj is not SQLModel
):
if is_table is True and hasattr(obj, "__table__"):
classes.append(obj)
elif is_table is False and not hasattr(obj, "__table__"):
classes.append(obj)
return classes
def _normalize_model_name(self, name: str) -> str:
suffixes = [
"Create",
"Read",
"Update",
"DTO",
"Base",
"List",
"Detail",
"Response",
"Request",
]
result = name
for suffix in suffixes:
if result.endswith(suffix) and len(result) > len(suffix):
result = result[: -len(suffix)]
return result
def _get_field_descriptions_from_class(self, cls: Type) -> Dict[str, str]:
descriptions = {}
for parent in cls.__mro__:
if parent is SQLModel or parent is object:
continue
fields = getattr(parent, "model_fields", {})
for field_name, field_info in fields.items():
if field_name in descriptions:
continue
desc = getattr(field_info, "description", None) or getattr(
field_info, "title", None
)
if desc:
descriptions[field_name] = desc
return descriptions
def _collect_all_descriptions(self) -> Dict[str, Dict[str, str]]:
result = {}
dto_map = {}
for dto in self.dto_models:
base_name = self._normalize_model_name(dto.__name__)
if base_name not in dto_map:
dto_map[base_name] = {}
for field, desc in self._get_field_descriptions_from_class(dto).items():
if field not in dto_map[base_name]:
dto_map[base_name][field] = desc
for model in self.db_models:
model_name = model.__name__
result[model_name] = {
**dto_map.get(model_name, {}),
**self._get_field_descriptions_from_class(model),
}
return result
def _identify_link_tables(self) -> Set[str]:
link_tables = set()
for model in self.db_models:
try:
for rel in sa_inspect(model).relationships:
if rel.secondary is not None:
link_tables.add(rel.secondary.name)
except Exception:
continue
return link_tables
def _collect_fk_relations(self) -> List[Dict[str, Any]]:
relations = []
processed: Set[Tuple[str, str, str, str]] = set()
for model in self.db_models:
if model.__tablename__ in self.link_table_names:
continue
for col in sa_inspect(model).columns:
for fk in col.foreign_keys:
target_table = fk.column.table.name
if target_table in self.link_table_names:
continue
target_model = self._table_to_model.get(target_table)
if not target_model:
continue
key = (
model.__name__,
col.name,
target_model.__name__,
fk.column.name,
)
if key not in processed:
relations.append(
{
"fromEntity": model.__name__,
"fromField": col.name,
"toEntity": target_model.__name__,
"toField": fk.column.name,
"fromMultiplicity": "N",
"toMultiplicity": "1",
}
)
processed.add(key)
return relations
def _collect_m2m_relations(self) -> List[Dict[str, Any]]:
relations = []
processed: Set[Tuple[str, str]] = set()
for model in self.db_models:
if model.__tablename__ in self.link_table_names:
continue
try:
for rel in sa_inspect(model).relationships:
if rel.direction.name != "MANYTOMANY":
continue
target_model = rel.mapper.class_
if target_model.__tablename__ in self.link_table_names:
continue
pair = tuple(sorted([model.__name__, target_model.__name__]))
if pair not in processed:
relations.append(
{
"fromEntity": pair[0],
"fromField": "id",
"toEntity": pair[1],
"toField": "id",
"fromMultiplicity": "N",
"toMultiplicity": "N",
}
)
processed.add(pair)
except Exception:
continue
return relations
def generate(self) -> Dict[str, Any]:
entities = []
for model in self.db_models:
table_name = model.__tablename__
if table_name in self.link_table_names:
continue
columns = sorted(
sa_inspect(model).columns,
key=lambda c: (
0 if c.primary_key else (1 if c.foreign_keys else 2),
c.name,
),
)
entity_fields = []
descriptions = self.field_descriptions.get(model.__name__, {})
for col in columns:
label = col.name
if col.primary_key:
label += " (PK)"
if col.foreign_keys:
label += " (FK)"
field_obj = {"id": col.name, "label": label}
if col.name in descriptions:
field_obj["tooltip"] = descriptions[col.name]
entity_fields.append(field_obj)
entities.append(
{"id": model.__name__, "title": table_name, "fields": entity_fields}
)
relations = self._collect_fk_relations() + self._collect_m2m_relations()
return {"entities": entities, "relations": relations}
+202 -228
View File
@@ -2,15 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<title id="pageTitle">Loading...</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_info.title }}</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 {
@@ -20,15 +21,13 @@
}
ul {
list-style: none;
list-style-type: none;
display: flex;
padding: 0;
margin: 0;
gap: 20px;
flex-wrap: wrap;
}
li {
margin: 15px 0;
}
li { margin: 10px 0; }
a {
display: inline-block;
padding: 8px 15px;
@@ -37,247 +36,164 @@
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
font-size: 14px;
}
a:hover {
background-color: #2980b9;
}
p {
margin: 5px 0;
}
a:hover { background-color: #2980b9; }
p { margin: 5px 0; }
.status-ok { color: #27ae60; }
.status-error { color: #e74c3c; }
.server-time { color: #7f8c8d; font-size: 12px; }
#erDiagram {
position: relative;
width: 100%;
height: 420px;
width: 100%; height: 700px;
border: 1px solid #ddd;
border-radius: 4px;
border-radius: 8px;
margin-top: 30px;
background: #fafafa;
background: #fcfcfc;
background-image:
linear-gradient(#eee 1px, transparent 1px),
linear-gradient(90deg, #eee 1px, transparent 1px);
background-size: 20px 20px;
background-position: -1px -1px;
overflow: hidden;
cursor: grab;
}
#erDiagram:active { cursor: grabbing; }
.er-table {
position: absolute;
width: 200px;
background: #fff;
border: 1px solid #3498db;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
font-size: 13px;
border: 1px solid #bdc3c7;
border-radius: 5px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
font-size: 12px;
z-index: 10;
display: flex;
flex-direction: column;
}
.er-table-header {
background: #3498db;
color: #fff;
padding: 6px 8px;
color: #ecf0f1;
padding: 8px;
font-weight: bold;
text-align: center;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.er-table-body {
padding: 6px 8px;
line-height: 1.4;
background: #fff;
padding: 4px 0;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.er-field {
padding: 2px 0;
padding: 4px 10px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.er-field:hover {
background-color: #ecf0f1;
color: #2980b9;
}
.relation-label {
font-size: 11px;
background: #fff;
padding: 1px 3px;
font-size: 10px;
font-weight: bold;
background: white;
padding: 2px 4px;
border: 1px solid #bdc3c7;
border-radius: 3px;
border: 1px solid #ccc;
color: #7f8c8d;
z-index: 20;
}
.jtk-connector { z-index: 5; }
.jtk-endpoint { z-index: 5; }
</style>
</head>
<body>
<img src="/favicon.ico" />
<h1>Welcome to {{ app_info.title }}!</h1>
<p>Description: {{ app_info.description }}</p>
<p>Version: {{ app_info.version }}</p>
<p>Current Time: {{ server_time }}</p>
<p>Status: {{ status }}</p>
<h1 id="mainTitle">Загрузка...</h1>
<p>Версия: <span id="appVersion">-</span></p>
<p>Описание: <span id="appDescription">-</span></p>
<p>Статус: <span id="appStatus">-</span></p>
<p class="server-time">Время сервера: <span id="serverTime">-</span></p>
<ul>
<li><a href="/">Home page</a></li>
<li><a href="/">Главная</a></li>
<li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li>
<li>
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
</li>
<li><a href="https://github.com/wowlikon/LiB">Исходный код</a></li>
</ul>
<h2>ER Diagram</h2>
<h2>Интерактивная ER диаграмма</h2>
<div id="erDiagram"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
<script>
const diagramData = {
entities: [
{
id: "User",
title: "users",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "username", label: "username" },
{ id: "email", label: "email" },
{ id: "full_name", label: "full_name" },
{ id: "is_active", label: "is_active" },
{ id: "is_verified", label: "is_verified" },
{ id: "is_2fa_enabled", label: "is_2fa_enabled" }
]
},
{
id: "Role",
title: "roles",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "name", label: "name" },
{ id: "description", label: "description" },
{ id: "payroll", label: "payroll" }
]
},
{
id: "UserRole",
title: "user_roles",
fields: [
{ id: "user_id", label: "user_id (FK)" },
{ id: "role_id", label: "role_id (FK)" }
]
},
{
id: "Author",
title: "authors",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "name", label: "name" }
]
},
{
id: "Book",
title: "books",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "title", label: "title" },
{ id: "description", label: "description" },
{ id: "page_count", label: "page_count" },
{ id: "status", label: "status" }
]
},
{
id: "Genre",
title: "genres",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "name", label: "name" }
]
},
{
id: "Loan",
title: "loans",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "book_id", label: "book_id (FK)" },
{ id: "user_id", label: "user_id (FK)" },
{ id: "borrowed_at", label: "borrowed_at" },
{ id: "due_date", label: "due_date" },
{ id: "returned_at", label: "returned_at" }
]
},
{
id: "AuthorBook",
title: "authors_books",
fields: [
{ id: "author_id", label: "author_id (FK)" },
{ id: "book_id", label: "book_id (FK)" }
]
},
{
id: "GenreBook",
title: "genres_books",
fields: [
{ id: "genre_id", label: "genre_id (FK)" },
{ id: "book_id", label: "book_id (FK)" }
]
}
],
relations: [
{
fromEntity: "Loan",
fromField: "book_id",
toEntity: "Book",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "Loan",
fromField: "user_id",
toEntity: "User",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "AuthorBook",
fromField: "author_id",
toEntity: "Author",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "AuthorBook",
fromField: "book_id",
toEntity: "Book",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "GenreBook",
fromField: "genre_id",
toEntity: "Genre",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "GenreBook",
fromField: "book_id",
toEntity: "Book",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "UserRole",
fromField: "user_id",
toEntity: "User",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "UserRole",
fromField: "role_id",
toEntity: "Role",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
}
]
};
async function fetchInfo() {
try {
const response = await fetch('/api/info');
const data = await response.json();
document.getElementById('pageTitle').textContent = data.app_info.title;
document.getElementById('mainTitle').textContent = `Добро пожаловать в ${data.app_info.title} API!`;
document.getElementById('appVersion').textContent = data.app_info.version;
document.getElementById('appDescription').textContent = data.app_info.description;
const statusEl = document.getElementById('appStatus');
statusEl.textContent = data.status;
statusEl.className = data.status === 'ok' ? 'status-ok' : 'status-error';
document.getElementById('serverTime').textContent = new Date(data.server_time).toLocaleString();
} catch (error) {
console.error('Ошибка загрузки info:', error);
document.getElementById('appStatus').textContent = 'Ошибка соединения';
document.getElementById('appStatus').className = 'status-error';
}
}
async function fetchSchemaAndRender() {
try {
const response = await fetch('/api/schema');
const diagramData = await response.json();
renderDiagram(diagramData);
} catch (error) {
console.error('Ошибка загрузки схемы:', error);
document.getElementById('erDiagram').innerHTML = '<p style="padding:20px;color:#e74c3c;">Ошибка загрузки схемы</p>';
}
}
function renderDiagram(diagramData) {
jsPlumb.ready(function () {
jsPlumb.setContainer("erDiagram");
const instance = jsPlumb.getInstance({
Container: "erDiagram",
Connector: ["Flowchart", { stub: 30, gap: 10, cornerRadius: 5, alwaysRespectStubs: true }],
ConnectionOverlays: [["Arrow", { location: 1, width: 10, length: 10, foldback: 0.8 }]]
});
const container = document.getElementById("erDiagram");
const baseLeft = 40;
const baseTop = 80;
const spacingX = 240;
const tableWidth = 200;
diagramData.entities.forEach((entity, index) => {
const g = new dagre.graphlib.Graph();
g.setGraph({
nodesep: 60, ranksep: 80,
marginx: 20, marginy: 20,
rankdir: 'LR',
});
g.setDefaultEdgeLabel(() => ({}));
const fieldIndexByEntity = {};
diagramData.entities.forEach(entity => {
const idxMap = {};
entity.fields.forEach((field, idx) => { idxMap[field.id] = idx; });
fieldIndexByEntity[entity.id] = idxMap;
});
diagramData.entities.forEach(entity => {
const table = document.createElement("div");
table.className = "er-table";
table.id = "table-" + entity.id;
table.style.top = baseTop + "px";
table.style.left = baseLeft + index * spacingX + "px";
const header = document.createElement("div");
header.className = "er-table-header";
@@ -289,40 +205,98 @@
entity.fields.forEach(field => {
const row = document.createElement("div");
row.className = "er-field";
row.id = "field-" + entity.id + "-" + field.id;
row.textContent = field.label || field.id;
row.id = `field-${entity.id}-${field.id}`;
row.style.display = "flex";
row.style.alignItems = "center";
const labelSpan = document.createElement("span");
labelSpan.textContent = field.label || field.id;
row.appendChild(labelSpan);
if (field.tooltip) {
row.title = field.tooltip;
const tip = document.createElement("span");
tip.textContent = "ⓘ";
tip.title = field.tooltip;
tip.style.marginLeft = "4px";
tip.style.marginRight = "0";
tip.style.fontSize = "10px";
tip.style.cursor = "help";
tip.style.marginLeft = "auto";
row.appendChild(tip);
}
body.appendChild(row);
});
table.appendChild(header);
table.appendChild(body);
container.appendChild(table);
const estimatedHeight = 20 + (entity.fields.length * 26);
g.setNode(entity.id, { width: tableWidth, height: estimatedHeight });
});
const common = {
endpoint: "Dot",
endpointStyle: { radius: 4, fill: "#3498db" },
connector: ["Flowchart", { cornerRadius: 5 }],
paintStyle: { stroke: "#3498db", strokeWidth: 2 },
hoverPaintStyle: { stroke: "#2980b9", strokeWidth: 2 },
anchor: ["Continuous", { faces: ["left", "right"] }]
};
const tableIds = diagramData.entities.map(e => "table-" + e.id);
jsPlumb.draggable(tableIds, { containment: "parent" });
const layoutEdges = [];
const m2oGroups = {};
diagramData.relations.forEach(rel => {
jsPlumb.connect({
source: "field-" + rel.fromEntity + "-" + rel.fromField,
target: "field-" + rel.toEntity + "-" + rel.toField,
overlays: [
["Label", { label: rel.fromMultiplicity || "", location: 0.2, cssClass: "relation-label" }],
["Label", { label: rel.toMultiplicity || "", location: 0.8, cssClass: "relation-label" }]
],
...common
const isManyToOne = (rel.fromMultiplicity === 'N' || rel.fromMultiplicity === '*') && (rel.toMultiplicity === '1');
if (isManyToOne) {
const fi = (fieldIndexByEntity[rel.fromEntity] || {})[rel.fromField] ?? 0;
if (!m2oGroups[rel.fromEntity]) m2oGroups[rel.fromEntity] = [];
m2oGroups[rel.fromEntity].push({ rel, fieldIndex: fi });
} else {
layoutEdges.push({ source: rel.fromEntity, target: rel.toEntity });
}
});
Object.keys(m2oGroups).forEach(fromEntity => {
const arr = m2oGroups[fromEntity];
arr.sort((a, b) => a.fieldIndex - b.fieldIndex);
arr.forEach((item, idx) => {
const rel = item.rel;
if (idx % 2 === 0) { layoutEdges.push({ source: rel.toEntity, target: rel.fromEntity });
} else { layoutEdges.push({ source: rel.fromEntity, target: rel.toEntity }); }
});
});
layoutEdges.forEach(e => g.setEdge(e.source, e.target));
dagre.layout(g);
g.nodes().forEach(function(v) {
const node = g.node(v);
const el = document.getElementById("table-" + v);
el.style.left = (node.x - (tableWidth / 2)) + "px";
el.style.top = (node.y - (node.height / 2)) + "px";
});
diagramData.relations.forEach(rel => {
instance.connect({
source: `field-${rel.fromEntity}-${rel.fromField}`,
target: `field-${rel.toEntity}-${rel.toField}`,
anchor: ["Continuous", { faces: ["left", "right"] }],
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2 },
hoverPaintStyle: { stroke: "#3498db", strokeWidth: 3 },
overlays: [
["Label", { label: rel.fromMultiplicity || "", location: 0.1, cssClass: "relation-label" }],
["Label", { label: rel.toMultiplicity || "", location: 0.9, cssClass: "relation-label" }]
]
});
});
const tableIds = diagramData.entities.map(e => "table-" + e.id);
instance.draggable(tableIds, {containment: "parent", stop: instance.repaintEverything});
});
}
fetchInfo();
setInterval(fetchInfo, 60000);
fetchSchemaAndRender();
</script>
</body>
</html>
+1 -1
View File
@@ -239,7 +239,7 @@
<footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center">
<p>&copy; 2026 LiB Library. Разработано в рамках дипломного проекта.
Код открыт под лицензией <a href="https://github.com/wowlikon/LibraryAPI/blob/main/LICENSE">MIT</a>.
Код открыт под лицензией <a href="https://github.com/wowlikon/LiB/blob/main/LICENSE">MIT</a>.
</p>
</div>
</footer>