mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Динамическое создание er-диаграммы по моделям
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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="Дата и время фактического возврата"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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="Количество книг")
|
||||
|
||||
@@ -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="Количество жанров")
|
||||
|
||||
@@ -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="Количество выдач")
|
||||
|
||||
@@ -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="Пароль")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="Количество ролей")
|
||||
|
||||
@@ -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="Является ли токен частичным")
|
||||
|
||||
@@ -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="Количество пользователей")
|
||||
|
||||
@@ -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="Статистика сервиса",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Модуль создания и проверки capjs"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import hashlib
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||
<div class="container mx-auto text-center">
|
||||
<p>© 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>
|
||||
|
||||
Reference in New Issue
Block a user