Добавление авторизации и фронтэнда

This commit is contained in:
2025-12-18 18:52:09 +03:00
parent 2c24f66de0
commit 756e941f99
55 changed files with 2314 additions and 577 deletions
+210
View File
@@ -0,0 +1,210 @@
"""Модуль авторизации и аутентификации"""
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlmodel import Session, select
from library_service.models.db import Role, User
from library_service.models.dto import TokenData
from library_service.settings import get_session
# Конфигурация из переменных окружения
ALGORITHM = os.getenv("ALGORITHM", "HS256")
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# Хэширование паролей
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
# OAuth2 схема
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверка пароль по его хешу."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Хэширование пароля."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""Создание JWT access токена."""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""Создание JWT refresh токена."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> TokenData:
"""Декодирование и проверка JWT токенов."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
user_id: int = payload.get("user_id")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return TokenData(username=username, user_id=user_id)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def authenticate_user(session: Session, username: str, password: str) -> User | None:
"""Аутентификация пользователя по имени пользователя и паролю."""
statement = select(User).where(User.username == username)
user = session.exec(statement).first()
if not user or not verify_password(password, user.hashed_password):
return None
return user
def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
session: Session = Depends(get_session),
) -> User:
"""Получить текущего авторизованного пользователя."""
token_data = decode_token(token)
user = session.get(User, token_data.user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Получить текущего активного пользователя."""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return current_user
def require_role(role_name: str):
"""Dependency, требующая выполнения определенной роли."""
def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
user_roles = [role.name for role in current_user.roles]
if role_name not in user_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{role_name}' required",
)
return current_user
return role_checker
# Создание dependencies
RequireAuth = Annotated[User, Depends(get_current_active_user)]
RequireAdmin = Annotated[User, Depends(require_role("admin"))]
RequireModerator = Annotated[User, Depends(require_role("moderator"))]
def seed_roles(session: Session) -> dict[str, Role]:
"""Создаёт роли по умолчанию, если их нет."""
default_roles = [
{"name": "admin", "description": "Администратор системы"},
{"name": "moderator", "description": "Модератор"},
{"name": "user", "description": "Обычный пользователь"},
]
roles = {}
for role_data in default_roles:
existing = session.exec(
select(Role).where(Role.name == role_data["name"])
).first()
if existing:
roles[role_data["name"]] = existing
else:
role = Role(**role_data)
session.add(role)
session.commit()
session.refresh(role)
roles[role_data["name"]] = role
print(f"[+] Created role: {role_data['name']}")
return roles
def seed_admin(session: Session, admin_role: Role) -> User | None:
"""Создаёт администратора по умолчанию, если нет ни одного."""
existing_admins = session.exec(
select(User).join(User.roles).where(Role.name == "admin")
).all()
if existing_admins:
print(f"[*] Admin already exists: {existing_admins[0].username}")
return None
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
if not admin_password:
import secrets
admin_password = secrets.token_urlsafe(16)
print(f"[!] Generated admin password: {admin_password}")
print("[!] Please save this password and set DEFAULT_ADMIN_PASSWORD env var")
admin_user = User(
username=admin_username,
email=admin_email,
full_name="Системный администратор",
hashed_password=get_password_hash(admin_password),
is_active=True,
is_verified=True,
)
admin_user.roles.append(admin_role)
session.add(admin_user)
session.commit()
session.refresh(admin_user)
print(f"[+] Created admin user: {admin_username}")
return admin_user
def run_seeds(session: Session) -> None:
"""Запускаем создание ролей и администратора."""
roles = seed_roles(session)
seed_admin(session, roles["admin"])
-1
View File
@@ -1 +0,0 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><rect x="10" y="10" width="80" height="80" rx="4" ry="4" fill="#fff" stroke="#000" stroke-width="2"/><rect x="20" y="15" width="60" height="70" rx="10" ry="10"/><rect x="20" y="15" width="60" height="66" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="62" rx="10" ry="10"/><rect x="20" y="15" width="60" height="60" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="56" rx="10" ry="10"/><rect x="20" y="15" width="60" height="54" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="50" rx="10" ry="10"/><rect x="20" y="15" width="60" height="48" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="44" rx="10" ry="10"/><rect x="22" y="21" width="2" height="58" rx="10" ry="10" stroke="#000" stroke-width="4"/><rect x="22" y="55" width="4" height="26" rx="2" ry="15"/><text x="50" y="40" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" stroke="#fff" stroke-width=".5" fill="none" font-size="20">『LiB』</text></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+5 -4
View File
@@ -1,12 +1,12 @@
"""Основной модуль"""
from contextlib import asynccontextmanager
from alembic import command
from alembic.config import Config
from contextlib import asynccontextmanager
from fastapi import FastAPI
from toml import load
from .settings import engine, get_app
from .routers import api_router
from .routers.misc import get_info
from .settings import engine, get_app
app = get_app()
alembic_cfg = Config("alembic.ini")
@@ -14,6 +14,7 @@ alembic_cfg = Config("alembic.ini")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Жизененый цикл сервиса"""
print("[+] Initializing...")
# Настройка базы данных
+2 -1
View File
@@ -1,2 +1,3 @@
from .dto import *
"""Модуль моделей"""
from .db import *
from .dto import *
+7 -10
View File
@@ -1,25 +1,22 @@
"""Модуль моделей для базы данных"""
from .author import Author
from .book import Book
from .genre import Genre
from .role import Role
from .user import User
from .links import (
AuthorBookLink,
GenreBookLink,
AuthorWithBooks,
BookWithAuthors,
GenreWithBooks,
BookWithGenres,
BookWithAuthorsAndGenres,
UserRoleLink
)
__all__ = [
"Author",
"Book",
"Genre",
"Role",
"User",
"AuthorBookLink",
"AuthorWithBooks",
"BookWithAuthors",
"GenreBookLink",
"GenreWithBooks",
"BookWithGenres",
"BookWithAuthorsAndGenres",
"UserRoleLink",
]
+9 -5
View File
@@ -1,14 +1,18 @@
from typing import List, Optional, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
from ..dto.author import AuthorBase
from .links import AuthorBookLink
"""Модуль DB-моделей авторов"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
from library_service.models.dto.author import AuthorBase
from library_service.models.db.links import AuthorBookLink
if TYPE_CHECKING:
from .book import Book
class Author(AuthorBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True, index=True)
"""Модель автора в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
books: List["Book"] = Relationship(
back_populates="authors", link_model=AuthorBookLink
)
+9 -5
View File
@@ -1,7 +1,10 @@
from typing import List, Optional, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
from ..dto.book import BookBase
from .links import AuthorBookLink, GenreBookLink
"""Модуль DB-моделей книг"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
from library_service.models.dto.book import BookBase
from library_service.models.db.links import AuthorBookLink, GenreBookLink
if TYPE_CHECKING:
from .author import Author
@@ -9,7 +12,8 @@ if TYPE_CHECKING:
class Book(BookBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True, index=True)
"""Модель книги в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink
)
+9 -5
View File
@@ -1,14 +1,18 @@
from typing import List, Optional, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
from ..dto.genre import GenreBase
from .links import GenreBookLink
"""Модуль DB-моделей жанров"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
from library_service.models.dto.genre import GenreBase
from library_service.models.db.links import GenreBookLink
if TYPE_CHECKING:
from .book import Book
class Genre(GenreBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True, index=True)
"""Модель жанра в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
books: List["Book"] = Relationship(
back_populates="genres", link_model=GenreBookLink
)
+8 -23
View File
@@ -1,12 +1,9 @@
"""Модуль связей между сущностями в БД"""
from sqlmodel import SQLModel, Field
from typing import List
from library_service.models.dto.author import AuthorRead
from library_service.models.dto.book import BookRead
from library_service.models.dto.genre import GenreRead
class AuthorBookLink(SQLModel, table=True):
"""Модель связи автора и книги"""
author_id: int | None = Field(
default=None, foreign_key="author.id", primary_key=True
)
@@ -14,26 +11,14 @@ class AuthorBookLink(SQLModel, table=True):
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)
class AuthorWithBooks(AuthorRead):
books: List[BookRead] = Field(default_factory=list)
class UserRoleLink(SQLModel, table=True):
"""Модель связи роли и пользователя"""
__tablename__ = "user_roles"
class BookWithAuthors(BookRead):
authors: List[AuthorRead] = Field(default_factory=list)
class BookWithGenres(BookRead):
genres: List[GenreRead] = Field(default_factory=list)
class GenreWithBooks(GenreRead):
books: List[BookRead] = Field(default_factory=list)
class BookWithAuthorsAndGenres(BookRead):
authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list)
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)
+20
View File
@@ -0,0 +1,20 @@
"""Модуль DB-моделей ролей"""
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
from library_service.models.dto.role import RoleBase
from library_service.models.db.links import UserRoleLink
if TYPE_CHECKING:
from .user import User
class Role(RoleBase, table=True):
"""Модель роли в базе данных"""
__tablename__ = "roles"
id: int | None = Field(default=None, primary_key=True, index=True)
# Связи
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
+28
View File
@@ -0,0 +1,28 @@
"""Модуль DB-моделей пользователей"""
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
from library_service.models.dto.user import UserBase
from library_service.models.db.links import UserRoleLink
if TYPE_CHECKING:
from .role import Role
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_active: bool = Field(default=True)
is_verified: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime | None = Field(
default=None, sa_column_kwargs={"onupdate": datetime.utcnow}
)
# Связи
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
+22 -4
View File
@@ -1,7 +1,12 @@
from .author import AuthorBase, AuthorCreate, AuthorUpdate, AuthorRead, AuthorList
from .book import BookBase, BookCreate, BookUpdate, BookRead, BookList
from .genre import GenreBase, GenreCreate, GenreUpdate, GenreRead, GenreList
"""Модуль DTO-моделей"""
from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
from .user import UserBase, UserCreate, UserLogin, UserRead, UserUpdate
from .token import Token, TokenData
from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres,
BookWithAuthorsAndGenres, BookFilteredList)
__all__ = [
"AuthorBase",
@@ -14,9 +19,22 @@ __all__ = [
"BookUpdate",
"BookRead",
"BookList",
"BookFilteredList",
"GenreBase",
"GenreCreate",
"GenreUpdate",
"GenreRead",
"GenreList",
"RoleBase",
"RoleCreate",
"RoleUpdate",
"RoleRead",
"RoleList",
"Token",
"TokenData",
"UserBase",
"UserCreate",
"UserRead",
"UserUpdate",
"UserLogin",
]
+10 -3
View File
@@ -1,9 +1,12 @@
from sqlmodel import SQLModel
"""Модуль DTO-моделей авторов"""
from typing import List
from pydantic import ConfigDict
from typing import Optional, List
from sqlmodel import SQLModel
class AuthorBase(SQLModel):
"""Базовая модель автора"""
name: str
model_config = ConfigDict( # pyright: ignore
@@ -12,17 +15,21 @@ class AuthorBase(SQLModel):
class AuthorCreate(AuthorBase):
"""Модель автора для создания"""
pass
class AuthorUpdate(SQLModel):
name: Optional[str] = None
"""Модель автора для обновления"""
name: str | None = None
class AuthorRead(AuthorBase):
"""Модель автора для чтения"""
id: int
class AuthorList(SQLModel):
"""Список авторов"""
authors: List[AuthorRead]
total: int
+14 -4
View File
@@ -1,9 +1,15 @@
from sqlmodel import SQLModel
"""Модуль DTO-моделей книг"""
from typing import List, TYPE_CHECKING
from pydantic import ConfigDict
from typing import Optional, List
from sqlmodel import SQLModel
if TYPE_CHECKING:
from .combined import BookWithAuthorsAndGenres
class BookBase(SQLModel):
"""Базовая модель книги"""
title: str
description: str
@@ -15,18 +21,22 @@ class BookBase(SQLModel):
class BookCreate(BookBase):
"""Модель книги для создания"""
pass
class BookUpdate(SQLModel):
title: Optional[str] = None
description: Optional[str] = None
"""Модель книги для обновления"""
title: str | None = None
description: str | None = None
class BookRead(BookBase):
"""Модель книги для чтения"""
id: int
class BookList(SQLModel):
"""Список книг"""
books: List[BookRead]
total: int
+53
View File
@@ -0,0 +1,53 @@
"""Модуль объединёных объектов"""
from typing import List
from sqlmodel import SQLModel, Field
from .author import AuthorRead
from .genre import GenreRead
from .book import BookRead
class AuthorWithBooks(SQLModel):
"""Модель автора с книгами"""
id: int
name: str
bio: str
books: List[BookRead] = Field(default_factory=list)
class GenreWithBooks(SQLModel):
"""Модель жанра с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
class BookWithAuthors(SQLModel):
"""Модель книги с авторами"""
id: int
title: str
description: str
authors: List[AuthorRead] = Field(default_factory=list)
class BookWithGenres(SQLModel):
"""Модель книги с жанрами"""
id: int
title: str
description: str
genres: List[GenreRead] = Field(default_factory=list)
class BookWithAuthorsAndGenres(SQLModel):
"""Модель с авторами и жанрами"""
id: int
title: str
description: str
authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list)
class BookFilteredList(SQLModel):
"""Список книг с фильтрацией"""
books: List[BookWithAuthorsAndGenres]
total: int
+10 -3
View File
@@ -1,9 +1,12 @@
from sqlmodel import SQLModel
"""Модуль DTO-моделей жанров"""
from typing import List
from pydantic import ConfigDict
from typing import Optional, List
from sqlmodel import SQLModel
class GenreBase(SQLModel):
"""Базовая модель жанра"""
name: str
model_config = ConfigDict( # pyright: ignore
@@ -12,17 +15,21 @@ class GenreBase(SQLModel):
class GenreCreate(GenreBase):
"""Модель жанра для создания"""
pass
class GenreUpdate(SQLModel):
name: Optional[str] = None
"""Модель жанра для обновления"""
name: str | None = None
class GenreRead(GenreBase):
"""Модель жанра для чтения"""
id: int
class GenreList(SQLModel):
"""Списко жанров"""
genres: List[GenreRead]
total: int
+31
View File
@@ -0,0 +1,31 @@
"""Модуль DTO-моделей ролей"""
from typing import List
from sqlmodel import SQLModel
class RoleBase(SQLModel):
"""Базовая модель роли"""
name: str
description: str | None = None
class RoleCreate(RoleBase):
"""Модель роли для создания"""
pass
class RoleUpdate(SQLModel):
"""Модель роли для обновления"""
name: str | None = None
class RoleRead(RoleBase):
"""Модель роли для чтения"""
id: int
class RoleList(SQLModel):
"""Список ролей"""
roles: List[RoleRead]
total: int
+15
View File
@@ -0,0 +1,15 @@
"""Модуль DTO-моделей токенов"""
from sqlmodel import SQLModel
class Token(SQLModel):
"""Модель токена"""
access_token: str
token_type: str = "bearer"
refresh_token: str | None = None
class TokenData(SQLModel):
"""Модель содержимого токена"""
username: str | None = None
user_id: int | None = None
+61
View File
@@ -0,0 +1,61 @@
"""Модуль DTO-моделей пользователей"""
import re
from typing import List
from pydantic import ConfigDict, EmailStr, field_validator
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)
model_config = ConfigDict(
json_schema_extra={
"example": {
"username": "johndoe",
"email": "john@example.com",
"full_name": "John Doe",
}
}
)
class UserCreate(UserBase):
"""Модель пользователя для создания"""
password: str = Field(min_length=8, max_length=100)
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
"""Валидация пароля"""
if not re.search(r"[A-Z]", v):
raise ValueError("Пароль должен содержать символы в верхнем регистре")
if not re.search(r"[a-z]", v):
raise ValueError("Пароль должен содержать символы в нижнем регистре")
if not re.search(r"\d", v):
raise ValueError("пароль должен содержать цифры")
return v
class UserLogin(SQLModel):
"""Модель аутентификации для пользователя"""
username: str
password: str
class UserRead(UserBase):
"""Модель пользователя для чтения"""
id: int
is_active: bool
is_verified: bool
roles: List[str] = []
class UserUpdate(SQLModel):
"""Модель пользователя для обновления"""
email: EmailStr | None = None
full_name: str | None = None
password: str | None = None
+3
View File
@@ -1,5 +1,7 @@
"""Модуль объединения роутеров"""
from fastapi import APIRouter
from .auth import router as auth_router
from .authors import router as authors_router
from .books import router as books_router
from .genres import router as genres_router
@@ -9,6 +11,7 @@ from .misc import router as misc_router
api_router = APIRouter()
# Подключение всех маршрутов
api_router.include_router(auth_router)
api_router.include_router(authors_router)
api_router.include_router(books_router)
api_router.include_router(genres_router)
+156
View File
@@ -0,0 +1,156 @@
"""Модуль работы с авторизацией и аутентификацией пользователей"""
from datetime import timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from library_service.models.db import Role, User
from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate
from library_service.settings import get_session
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin,
RequireAuth, authenticate_user, get_password_hash,
create_access_token, create_refresh_token)
router = APIRouter(prefix="/auth", tags=["authentication"])
@router.post(
"/register",
response_model=UserRead,
status_code=status.HTTP_201_CREATED,
summary="Регистрация нового пользователя",
description="Создает нового пользователя в системе",
)
def register(user_data: UserCreate, session: Session = Depends(get_session)):
"""Эндпоинт регистрации пользователя"""
# Проверка если username существует
existing_user = session.exec(
select(User).where(User.username == user_data.username)
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
# Проверка если email существует
existing_email = session.exec(
select(User).where(User.email == user_data.email)
).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
)
# Создание пользователя
db_user = User(
**user_data.model_dump(exclude={"password"}),
hashed_password=get_password_hash(user_data.password)
)
# Назначение роли по умолчанию
default_role = session.exec(select(Role).where(Role.name == "user")).first()
if default_role:
db_user.roles.append(default_role)
session.add(db_user)
session.commit()
session.refresh(db_user)
return UserRead(**db_user.model_dump(), roles=[role.name for role in db_user.roles])
@router.post(
"/token",
response_model=Token,
summary="Получение токена",
description="Аутентификация и получение JWT токена",
)
def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: Session = Depends(get_session),
):
"""Эндпоинт аутентификации и получения JWT токена"""
user = authenticate_user(session, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username, "user_id": user.id},
expires_delta=access_token_expires,
)
refresh_token = create_refresh_token(
data={"sub": user.username, "user_id": user.id}
)
return Token(
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
)
@router.get(
"/me",
response_model=UserRead,
summary="Текущий пользователь",
description="Получить информацию о текущем авторизованном пользователе",
)
def read_users_me(current_user: RequireAuth):
"""Эндпоинт получения информации о себе"""
return UserRead(
**current_user.model_dump(), roles=[role.name for role in current_user.roles]
)
@router.put(
"/me",
response_model=UserRead,
summary="Обновить профиль",
description="Обновить информацию текущего пользователя",
)
def update_user_me(
user_update: UserUpdate,
current_user: RequireAuth,
session: Session = Depends(get_session),
):
"""Эндпоинт обновления пользователя"""
if user_update.email:
current_user.email = user_update.email
if user_update.full_name:
current_user.full_name = user_update.full_name
if user_update.password:
current_user.hashed_password = get_password_hash(user_update.password)
session.add(current_user)
session.commit()
session.refresh(current_user)
return UserRead(
**current_user.model_dump(), roles=[role.name for role in current_user.roles]
)
@router.get(
"/users",
response_model=list[UserRead],
summary="Список пользователей",
description="Получить список всех пользователей (только для админов)",
)
def read_users(
admin: RequireAdmin,
session: Session = Depends(get_session),
skip: int = 0,
limit: int = 100,
):
"""Эндпоинт получения списка всех пользователей"""
users = session.exec(select(User).offset(skip).limit(limit)).all()
return [
UserRead(**user.model_dump(), roles=[role.name for role in user.roles])
for user in users
]
+18 -16
View File
@@ -1,28 +1,28 @@
from fastapi import APIRouter, Path, Depends, HTTPException
"""Модуль работы с авторами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select
from library_service.auth import RequireAuth
from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks
from library_service.models.dto import (
AuthorCreate,
AuthorUpdate,
AuthorRead,
AuthorList,
BookRead,
)
from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import (BookRead, AuthorWithBooks,
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
router = APIRouter(prefix="/authors", tags=["authors"])
# Create an author
@router.post(
"/",
response_model=AuthorRead,
summary="Создать автора",
description="Добавляет автора в систему",
)
def create_author(author: AuthorCreate, session: Session = Depends(get_session)):
def create_author(
current_user: RequireAuth,
author: AuthorCreate,
session: Session = Depends(get_session),
):
"""Эндпоинт создания автора"""
db_author = Author(**author.model_dump())
session.add(db_author)
session.commit()
@@ -30,7 +30,6 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
return AuthorRead(**db_author.model_dump())
# Read authors
@router.get(
"/",
response_model=AuthorList,
@@ -38,6 +37,7 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
description="Возвращает список всех авторов в системе",
)
def read_authors(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка авторов"""
authors = session.exec(select(Author)).all()
return AuthorList(
authors=[AuthorRead(**author.model_dump()) for author in authors],
@@ -45,7 +45,6 @@ def read_authors(session: Session = Depends(get_session)):
)
# Read an author with their books
@router.get(
"/{author_id}",
response_model=AuthorWithBooks,
@@ -56,6 +55,7 @@ def get_author(
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Эндпоинт чтения конкретного автора"""
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
@@ -72,7 +72,6 @@ def get_author(
return AuthorWithBooks(**author_data)
# Update an author
@router.put(
"/{author_id}",
response_model=AuthorRead,
@@ -80,10 +79,12 @@ def get_author(
description="Обновляет информацию об авторе в системе",
)
def update_author(
current_user: RequireAuth,
author: AuthorUpdate,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Эндпоинт обновления автора"""
db_author = session.get(Author, author_id)
if not db_author:
raise HTTPException(status_code=404, detail="Author not found")
@@ -97,7 +98,6 @@ def update_author(
return AuthorRead(**db_author.model_dump())
# Delete an author
@router.delete(
"/{author_id}",
response_model=AuthorRead,
@@ -105,9 +105,11 @@ def update_author(
description="Удаляет автора из системы",
)
def delete_author(
current_user: RequireAuth,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Эндпоинт удаления автора"""
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
+70 -17
View File
@@ -1,29 +1,32 @@
from fastapi import APIRouter, Path, Depends, HTTPException
from sqlmodel import Session, select
"""Модуль работы с книгами"""
from typing import List
from library_service.models.db.links import BookWithAuthorsAndGenres
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlmodel import Session, select, col, func
from library_service.auth import RequireAuth
from library_service.settings import get_session
from library_service.models.db import Author, Book, BookWithAuthors, AuthorBookLink
from library_service.models.dto import (
AuthorRead,
BookList,
BookRead,
BookCreate,
BookUpdate,
from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate
from library_service.models.dto.combined import (
BookWithAuthorsAndGenres,
BookFilteredList
)
router = APIRouter(prefix="/books", tags=["books"])
# Create a book
@router.post(
"/",
response_model=Book,
summary="Создать книгу",
description="Добавляет книгу в систему",
)
def create_book(book: BookCreate, session: Session = Depends(get_session)):
def create_book(
current_user: RequireAuth, book: BookCreate, session: Session = Depends(get_session)
):
"""Эндпоинт создания книги"""
db_book = Book(**book.model_dump())
session.add(db_book)
session.commit()
@@ -31,7 +34,6 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
return BookRead(**db_book.model_dump())
# Read books
@router.get(
"/",
response_model=BookList,
@@ -39,13 +41,13 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
description="Возвращает список всех книг в системе",
)
def read_books(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка книг"""
books = session.exec(select(Book)).all()
return BookList(
books=[BookRead(**book.model_dump()) for book in books], total=len(books)
)
# Read a book with their authors and genres
@router.get(
"/{book_id}",
response_model=BookWithAuthorsAndGenres,
@@ -56,6 +58,7 @@ def get_book(
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Эндпоинт чтения конкретной книги"""
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
@@ -76,10 +79,9 @@ def get_book(
book_data["authors"] = author_reads
book_data["genres"] = genre_reads
return BookWithAuthors(**book_data)
return BookWithAuthorsAndGenres(**book_data)
# Update a book
@router.put(
"/{book_id}",
response_model=Book,
@@ -87,10 +89,12 @@ def get_book(
description="Обновляет информацию о книге в системе",
)
def update_book(
current_user: RequireAuth,
book: BookUpdate,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Эндпоинт обновления книги"""
db_book = session.get(Book, book_id)
if not db_book:
raise HTTPException(status_code=404, detail="Book not found")
@@ -102,7 +106,6 @@ def update_book(
return db_book
# Delete a book
@router.delete(
"/{book_id}",
response_model=BookRead,
@@ -110,9 +113,11 @@ def update_book(
description="Удаляет книгу их системы",
)
def delete_book(
current_user: RequireAuth,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Эндпоинт удаления книги"""
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
@@ -122,3 +127,51 @@ def delete_book(
session.delete(book)
session.commit()
return book_read
@router.get(
"/filter",
response_model=BookFilteredList,
summary="Фильтрация книг",
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией"
)
def filter_books(
session: Session = Depends(get_session),
q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"),
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
page: int = Query(1, gt=0, description="Номер страницы"),
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
):
"""Эндпоинт получения отфильтрованного списка книг"""
statement = select(Book).distinct()
if q:
statement = statement.where(
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
)
if author_ids:
statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids))
if genre_ids:
statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids))
total_statement = select(func.count()).select_from(statement.subquery())
total = session.exec(total_statement).one()
offset = (page - 1) * size
statement = statement.offset(offset).limit(size)
results = session.exec(statement).all()
books_with_data = []
for db_book in results:
books_with_data.append(
BookWithAuthorsAndGenres(
**db_book.model_dump(),
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
genres=[GenreRead(**g.model_dump()) for g in db_book.genres]
)
)
return BookFilteredList(books=books_with_data, total=total)
+22 -16
View File
@@ -1,28 +1,28 @@
from fastapi import APIRouter, Path, Depends, HTTPException
"""Модуль работы с жанрами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select
from library_service.auth import RequireAuth
from library_service.models.db import Book, Genre, GenreBookLink
from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks
from library_service.settings import get_session
from library_service.models.db import Genre, GenreBookLink, Book, GenreWithBooks
from library_service.models.dto import (
GenreCreate,
GenreUpdate,
GenreRead,
GenreList,
BookRead,
)
router = APIRouter(prefix="/genres", tags=["genres"])
# Create a genre
# Создание жанра
@router.post(
"/",
response_model=GenreRead,
summary="Создать жанр",
description="Добавляет жанр книг в систему",
)
def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
def create_genre(
current_user: RequireAuth,
genre: GenreCreate,
session: Session = Depends(get_session),
):
"""Эндпоинт создания жанра"""
db_genre = Genre(**genre.model_dump())
session.add(db_genre)
session.commit()
@@ -30,7 +30,7 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
return GenreRead(**db_genre.model_dump())
# Read genres
# Чтение жанров
@router.get(
"/",
response_model=GenreList,
@@ -38,13 +38,14 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
description="Возвращает список всех жанров в системе",
)
def read_genres(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка жанров"""
genres = session.exec(select(Genre)).all()
return GenreList(
genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres)
)
# Read a genre with their books
# Чтение жанра с его книгами
@router.get(
"/{genre_id}",
response_model=GenreWithBooks,
@@ -55,6 +56,7 @@ def get_genre(
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Эндпоинт чтения конкретного жанра"""
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
@@ -71,7 +73,7 @@ def get_genre(
return GenreWithBooks(**genre_data)
# Update a genre
# Обновление жанра
@router.put(
"/{genre_id}",
response_model=GenreRead,
@@ -79,10 +81,12 @@ def get_genre(
description="Обновляет информацию о жанре в системе",
)
def update_genre(
current_user: RequireAuth,
genre: GenreUpdate,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Эндпоинт обновления жанра"""
db_genre = session.get(Genre, genre_id)
if not db_genre:
raise HTTPException(status_code=404, detail="Genre not found")
@@ -96,7 +100,7 @@ def update_genre(
return GenreRead(**db_genre.model_dump())
# Delete a genre
# Удаление жанра
@router.delete(
"/{genre_id}",
response_model=GenreRead,
@@ -104,9 +108,11 @@ def update_genre(
description="Удаляет автора из системы",
)
def delete_genre(
current_user: RequireAuth,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Эндпоинт удаления жанра"""
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
+34 -13
View File
@@ -1,21 +1,22 @@
from fastapi import APIRouter, Path, Request
from fastapi.params import Depends
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
"""Модуль прочих эндпоинтов"""
from datetime import datetime
from pathlib import Path
from typing import Dict
from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from library_service.settings import get_app
# Загрузка шаблонов
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
router = APIRouter(tags=["misc"])
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
# Форматированная информация о приложении
def get_info(app) -> Dict:
"""Форматированная информация о приложении"""
return {
"status": "ok",
"app_info": {
@@ -27,29 +28,49 @@ def get_info(app) -> Dict:
}
# Эндпоинт главной страницы
@router.get("/", include_in_schema=False)
async def root(request: Request, app=Depends(get_app)):
"""Эндпоинт главной страницы"""
return templates.TemplateResponse(request, "index.html", get_info(app))
# Редирект иконки вкладки
@router.get("/api", include_in_schema=False)
async def root(request: Request, app=Depends(get_app)):
"""Страница с сылками на документацию API"""
return templates.TemplateResponse(request, "api.html", get_info(app))
@router.get("/favicon.ico", include_in_schema=False)
def redirect_favicon():
"""Редирект иконки вкладки"""
return RedirectResponse("/favicon.svg")
# Эндпоинт иконки вкладки
@router.get("/favicon.svg", include_in_schema=False)
async def favicon():
return FileResponse("library_service/favicon.svg", media_type="image/svg+xml")
"""Эндпоинт иконки вкладки"""
return FileResponse(
"library_service/static/favicon.svg", media_type="image/svg+xml"
)
@router.get("/static/{path:path}", include_in_schema=False)
async def serve_static(path: str):
"""Статические файлы"""
static_dir = Path(__file__).parent.parent / "static"
file_path = static_dir / path
if not file_path.is_file() or not file_path.is_relative_to(static_dir):
return JSONResponse(status_code=404, content={"error": "File not found"})
return FileResponse(file_path)
# Эндпоинт информации об API
@router.get(
"/api/info",
summary="Информация о сервисе",
description="Возвращает информацию о системе",
)
async def api_info(app=Depends(get_app)):
"""Эндпоинт информации об API"""
return JSONResponse(content=get_info(app))
+121 -119
View File
@@ -1,15 +1,82 @@
"""Модуль работы со связями"""
from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List, Dict
from library_service.settings import get_session
from library_service.models.db import Author, Book, Genre, AuthorBookLink, GenreBookLink
from library_service.auth import RequireAuth
from library_service.models.db import Author, AuthorBookLink, Book, Genre, GenreBookLink
from library_service.models.dto import AuthorRead, BookRead, GenreRead
from library_service.settings import get_session
router = APIRouter(tags=["relations"])
# Add author to book
def check_entity_exists(session, model, entity_id, entity_name):
"""Проверка существования связи между сущностями в БД"""
entity = session.get(model, entity_id)
if not entity:
raise HTTPException(status_code=404, detail=f"{entity_name} not found")
return entity
def add_relationship(session, link_model, id1, field1, id2, field2, detail):
"""Создание связи между сущностями в БД"""
existing_link = session.exec(
select(link_model)
.where(getattr(link_model, field1) == id1)
.where(getattr(link_model, field2) == id2)
).first()
if existing_link:
raise HTTPException(status_code=400, detail=detail)
link = link_model(**{field1: id1, field2: id2})
session.add(link)
session.commit()
session.refresh(link)
return link
def remove_relationship(session, link_model, id1, field1, id2, field2):
"""Удаление связи между сущностями в БД"""
link = session.exec(
select(link_model)
.where(getattr(link_model, field1) == id1)
.where(getattr(link_model, field2) == id2)
).first()
if not link:
raise HTTPException(status_code=404, detail="Relationship not found")
session.delete(link)
session.commit()
return {"message": "Relationship removed successfully"}
def get_related(
session,
main_model,
main_id,
main_name,
related_model,
link_model,
link_main_field,
link_related_field,
read_model
):
"""Получение связанных в БД сущностей"""
check_entity_exists(session, main_model, main_id, main_name)
related = session.exec(
select(related_model).join(link_model)
.where(getattr(link_model, link_main_field) == main_id)
).all()
return [read_model(**obj.model_dump()) for obj in related]
@router.post(
"/relationships/author-book",
response_model=AuthorBookLink,
@@ -17,33 +84,19 @@ router = APIRouter(tags=["relations"])
description="Добавляет связь между автором и книгой в систему",
)
def add_author_to_book(
author_id: int, book_id: int, session: Session = Depends(get_session)
current_user: RequireAuth,
author_id: int,
book_id: int,
session: Session = Depends(get_session),
):
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
"""Эндпоинт добавления автора к книге"""
check_entity_exists(session, Author, author_id, "Author")
check_entity_exists(session, Book, book_id, "Book")
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
existing_link = session.exec(
select(AuthorBookLink)
.where(AuthorBookLink.author_id == author_id)
.where(AuthorBookLink.book_id == book_id)
).first()
if existing_link:
raise HTTPException(status_code=400, detail="Relationship already exists")
link = AuthorBookLink(author_id=author_id, book_id=book_id)
session.add(link)
session.commit()
session.refresh(link)
return link
return add_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id", "Relationship already exists")
# Remove author from book
@router.delete(
"/relationships/author-book",
response_model=Dict[str, str],
@@ -51,23 +104,16 @@ def add_author_to_book(
description="Удаляет связь между автором и книгой в системе",
)
def remove_author_from_book(
author_id: int, book_id: int, session: Session = Depends(get_session)
current_user: RequireAuth,
author_id: int,
book_id: int,
session: Session = Depends(get_session),
):
link = session.exec(
select(AuthorBookLink)
.where(AuthorBookLink.author_id == author_id)
.where(AuthorBookLink.book_id == book_id)
).first()
if not link:
raise HTTPException(status_code=404, detail="Relationship not found")
session.delete(link)
session.commit()
return {"message": "Relationship removed successfully"}
"""Эндпоинт удаления автора из книги"""
return remove_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id")
# Get author's books
@router.get(
"/authors/{author_id}/books/",
response_model=List[BookRead],
@@ -75,18 +121,12 @@ def remove_author_from_book(
description="Возвращает все книги в системе, написанные автором",
)
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
books = session.exec(
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
).all()
return [BookRead(**book.model_dump()) for book in books]
"""Эндпоинт получения книг, написанных автором"""
return get_related(session,
Author, author_id, "Author", Book,
AuthorBookLink, "author_id", "book_id", BookRead)
# Get book's authors
@router.get(
"/books/{book_id}/authors/",
response_model=List[AuthorRead],
@@ -94,18 +134,12 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
description="Возвращает всех авторов книги в системе",
)
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
authors = session.exec(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
).all()
return [AuthorRead(**author.model_dump()) for author in authors]
"""Эндпоинт получения авторов книги"""
return get_related(session,
Book, book_id, "Book", Author,
AuthorBookLink, "book_id", "author_id", AuthorRead)
# Add genre to book
@router.post(
"/relationships/genre-book",
response_model=GenreBookLink,
@@ -113,33 +147,19 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
description="Добавляет связь между книгой и жанром в систему",
)
def add_genre_to_book(
genre_id: int, book_id: int, session: Session = Depends(get_session)
current_user: RequireAuth,
genre_id: int,
book_id: int,
session: Session = Depends(get_session),
):
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
"""Эндпоинт добавления жанра к книге"""
check_entity_exists(session, Genre, genre_id, "Genre")
check_entity_exists(session, Book, book_id, "Book")
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
existing_link = session.exec(
select(GenreBookLink)
.where(GenreBookLink.genre_id == genre_id)
.where(GenreBookLink.book_id == book_id)
).first()
if existing_link:
raise HTTPException(status_code=400, detail="Relationship already exists")
link = GenreBookLink(genre_id=genre_id, book_id=book_id)
session.add(link)
session.commit()
session.refresh(link)
return link
return add_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
# Remove author from book
@router.delete(
"/relationships/genre-book",
response_model=Dict[str, str],
@@ -147,55 +167,37 @@ def add_genre_to_book(
description="Удаляет связь между жанром и книгой в системе",
)
def remove_genre_from_book(
genre_id: int, book_id: int, session: Session = Depends(get_session)
current_user: RequireAuth,
genre_id: int,
book_id: int,
session: Session = Depends(get_session),
):
link = session.exec(
select(GenreBookLink)
.where(GenreBookLink.genre_id == genre_id)
.where(GenreBookLink.book_id == book_id)
).first()
if not link:
raise HTTPException(status_code=404, detail="Relationship not found")
session.delete(link)
session.commit()
return {"message": "Relationship removed successfully"}
"""Эндпоинт удаления жанра из книги"""
return remove_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id")
# Get genre's books
@router.get(
"/genres/{author_id}/books/",
"/genres/{genre_id}/books/",
response_model=List[BookRead],
summary="Получить книги, написанные в жанре",
description="Возвращает все книги в системе в этом жанре",
)
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
books = session.exec(
select(Book).join(GenreBookLink).where(GenreBookLink.author_id == genre_id)
).all()
return [BookRead(**book.model_dump()) for book in books]
"""Эндпоинт получения книг с жанром"""
return get_related(session,
Genre, genre_id, "Genre", Book,
GenreBookLink, "genre_id", "book_id", BookRead)
# Get book's genres
@router.get(
"/books/{book_id}/genres/",
response_model=List[GenreRead],
summary="Получить жанры книги",
description="Возвращает все жанры книги в системе",
)
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
genres = session.exec(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
).all()
return [GenreRead(**author.model_dump()) for genre in genres]
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
"""Эндпоинт получения жанров книги"""
return get_related(session,
Book, book_id, "Book", Genre,
GenreBookLink, "book_id", "genre_id", GenreRead)
+18 -11
View File
@@ -1,59 +1,66 @@
"""Модуль настроек проекта"""
import os
from dotenv import load_dotenv
from fastapi import FastAPI
from sqlmodel import create_engine, SQLModel, Session
from sqlmodel import Session, create_engine
from toml import load
load_dotenv()
with open("pyproject.toml") as f:
with open("pyproject.toml", 'r', encoding='utf-8') as f:
config = load(f)
# Dependency to get the FastAPI application instance
def get_app() -> FastAPI:
"""Dependency, для получение экземплярра FastAPI application"""
return FastAPI(
title=config["tool"]["poetry"]["name"],
description=config["tool"]["poetry"]["description"],
version=config["tool"]["poetry"]["version"],
openapi_tags=[
{
"name": "authentication",
"description": "Авторизация пользователя."
},
{
"name": "authors",
"description": "Operations with authors.",
"description": "Действия с авторами.",
},
{
"name": "books",
"description": "Operations with books.",
"description": "Действия с книгами.",
},
{
"name": "genres",
"description": "Operations with genres.",
"description": "Действия с жанрами.",
},
{
"name": "relations",
"description": "Operations with relations.",
"description": "Действия с связями.",
},
{
"name": "misc",
"description": "Miscellaneous operations.",
"description": "Прочие.",
},
],
)
HOST = os.getenv("POSTGRES_HOST")
PORT = os.getenv("POSTGRES_PORT")
USER = os.getenv("POSTGRES_USER")
PASSWORD = os.getenv("POSTGRES_PASSWORD")
DATABASE = os.getenv("POSTGRES_DB")
HOST = os.getenv("POSTGRES_SERVER")
if not USER or not PASSWORD or not DATABASE or not HOST:
raise ValueError("Missing environment variables")
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:5432/{DATABASE}"
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
engine = create_engine(POSTGRES_DATABASE_URL, echo=True, future=True)
# Dependency to get a database session
def get_session():
"""Dependency, для получение сессии БД"""
with Session(engine) as session:
yield session
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.877014 7.49988C0.877014 3.84219 3.84216 0.877045 7.49985 0.877045C11.1575 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1575 14.1227 7.49985 14.1227C3.84216 14.1227 0.877014 11.1575 0.877014 7.49988ZM7.49985 1.82704C4.36683 1.82704 1.82701 4.36686 1.82701 7.49988C1.82701 8.97196 2.38774 10.3131 3.30727 11.3213C4.19074 9.94119 5.73818 9.02499 7.50023 9.02499C9.26206 9.02499 10.8093 9.94097 11.6929 11.3208C12.6121 10.3127 13.1727 8.97172 13.1727 7.49988C13.1727 4.36686 10.6328 1.82704 7.49985 1.82704ZM10.9818 11.9787C10.2839 10.7795 8.9857 9.97499 7.50023 9.97499C6.01458 9.97499 4.71624 10.7797 4.01845 11.9791C4.97952 12.7272 6.18765 13.1727 7.49985 13.1727C8.81227 13.1727 10.0206 12.727 10.9818 11.9787ZM5.14999 6.50487C5.14999 5.207 6.20212 4.15487 7.49999 4.15487C8.79786 4.15487 9.84999 5.207 9.84999 6.50487C9.84999 7.80274 8.79786 8.85487 7.49999 8.85487C6.20212 8.85487 5.14999 7.80274 5.14999 6.50487ZM7.49999 5.10487C6.72679 5.10487 6.09999 5.73167 6.09999 6.50487C6.09999 7.27807 6.72679 7.90487 7.49999 7.90487C8.27319 7.90487 8.89999 7.27807 8.89999 6.50487C8.89999 5.73167 8.27319 5.10487 7.49999 5.10487Z"
fill="#000000"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><rect
x="10"
y="10"
width="80"
height="80"
rx="4"
ry="4"
fill="#fff"
stroke="#000"
stroke-width="2"
/><rect x="20" y="15" width="60" height="70" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="66"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="62" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="60"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="56" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="54"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="50" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="48"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="44" rx="10" ry="10" /><rect
x="22"
y="21"
width="2"
height="58"
rx="10"
ry="10"
stroke="#000"
stroke-width="4"
/><rect x="22" y="55" width="4" height="26" rx="2" ry="15" /><text
x="50"
y="40"
text-anchor="middle"
dominant-baseline="middle"
alignment-baseline="middle"
stroke="#fff"
stroke-width=".5"
fill="none"
font-size="20"
>『LiB』</text></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+59
View File
@@ -0,0 +1,59 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="15" width="60" height="70" rx="10" ry="10" /><rect
x="20"
y="15"
width="60"
height="66"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="62" rx="10" ry="10" />
<rect
x="20"
y="15"
width="60"
height="60"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="56" rx="10" ry="10" />
<rect
x="20"
y="15"
width="60"
height="54"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="50" rx="10" ry="10" />
<rect
x="20"
y="15"
width="60"
height="48"
rx="10"
ry="10"
fill="#fff"
/><rect x="20" y="15" width="60" height="44" rx="10" ry="10" />
<rect
x="22"
y="21"
width="2"
height="58"
rx="10"
ry="10"
stroke="#000"
stroke-width="4"
/><rect x="22" y="55" width="4" height="26" rx="2" ry="15" />
<text
x="50"
y="40"
text-anchor="middle"
dominant-baseline="middle"
alignment-baseline="middle"
stroke="#fff"
stroke-width=".5"
fill="none"
font-size="20"
>『LiB』</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
+135
View File
@@ -0,0 +1,135 @@
// Load authors and genres asynchronously
Promise.all([
fetch("/authors").then((response) => response.json()),
fetch("/genres").then((response) => response.json()),
])
.then(([authorsData, genresData]) => {
// Populate authors dropdown
const dropdown = document.getElementById("author-dropdown");
authorsData.authors.forEach((author) => {
const div = document.createElement("div");
div.className = "p-2 hover:bg-gray-100 cursor-pointer";
div.setAttribute("data-value", author.name);
div.textContent = author.name;
dropdown.appendChild(div);
});
// Populate genres list
const list = document.getElementById("genres-list");
genresData.genres.forEach((genre) => {
const li = document.createElement("li");
li.className = "mb-1";
li.innerHTML = `
<label class="custom-checkbox flex items-center">
<input type="checkbox" />
<span class="checkmark"></span>
${genre.name}
</label>
`;
list.appendChild(li);
});
initializeAuthorDropdown();
})
.catch((error) => console.error("Error loading data:", error));
function initializeAuthorDropdown() {
const authorSearchInput = document.getElementById("author-search-input");
const authorDropdown = document.getElementById("author-dropdown");
const selectedAuthorsContainer = document.getElementById(
"selected-authors-container",
);
const dropdownItems = authorDropdown.querySelectorAll("[data-value]");
let selectedAuthors = new Set();
// Function to update highlights in dropdown
const updateDropdownHighlights = () => {
dropdownItems.forEach((item) => {
const value = item.dataset.value;
if (selectedAuthors.has(value)) {
item.classList.add("bg-gray-200");
} else {
item.classList.remove("bg-gray-200");
}
});
};
// Function to render selected authors
const renderSelectedAuthors = () => {
Array.from(selectedAuthorsContainer.children).forEach((child) => {
if (child.id !== "author-search-input") {
child.remove();
}
});
selectedAuthors.forEach((author) => {
const authorChip = document.createElement("span");
authorChip.className =
"flex items-center bg-gray-200 text-gray-800 text-sm font-medium px-2.5 py-0.5 rounded-full";
authorChip.innerHTML = `
${author}
<button type="button" class="ml-1 inline-flex items-center p-0.5 text-sm text-gray-400 bg-transparent rounded-sm hover:bg-gray-200 hover:text-gray-900" data-author="${author}">
<svg class="w-2 h-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Remove author</span>
</button>
`;
selectedAuthorsContainer.insertBefore(authorChip, authorSearchInput);
});
updateDropdownHighlights();
};
// Handle input focus to show dropdown
authorSearchInput.addEventListener("focus", () => {
authorDropdown.classList.remove("hidden");
});
// Handle input for filtering
authorSearchInput.addEventListener("input", () => {
const query = authorSearchInput.value.toLowerCase();
dropdownItems.forEach((item) => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(query) ? "block" : "none";
});
authorDropdown.classList.remove("hidden");
});
// Handle clicks outside to hide dropdown
document.addEventListener("click", (event) => {
if (
!selectedAuthorsContainer.contains(event.target) &&
!authorDropdown.contains(event.target)
) {
authorDropdown.classList.add("hidden");
}
});
// Handle author selection from dropdown
authorDropdown.addEventListener("click", (event) => {
const selectedValue = event.target.dataset.value;
if (selectedValue) {
if (selectedAuthors.has(selectedValue)) {
selectedAuthors.delete(selectedValue);
} else {
selectedAuthors.add(selectedValue);
}
authorSearchInput.value = "";
renderSelectedAuthors();
authorSearchInput.focus();
}
});
// Handle removing selected author chip
selectedAuthorsContainer.addEventListener("click", (event) => {
if (event.target.closest("button")) {
const authorToRemove = event.target.closest("button").dataset.author;
selectedAuthors.delete(authorToRemove);
renderSelectedAuthors();
authorSearchInput.focus();
}
});
// Initial render and highlights (without auto-focus)
renderSelectedAuthors();
}
+77
View File
@@ -0,0 +1,77 @@
@font-face {
font-family: "Novem";
src: url("novem.regular.ttf") format("truetype");
}
@font-face {
font-family: "Dited";
src: url("dited.regular.ttf") format("truetype");
}
h1 {
font-family: "Novem", sans-serif;
letter-spacing: 10px;
}
nav ul li a {
font-family: "Dited", sans-serif;
letter-spacing: 2.5px;
font-size: large;
}
/* Custom checkbox styles */
.custom-checkbox {
display: inline-block;
position: relative;
cursor: pointer;
font-size: 14px;
user-select: none;
}
.custom-checkbox input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
height: 18px;
width: 18px;
background-color: #fff;
border: 2px solid #d1d5db; /* gray-300 */
border-radius: 4px;
transition: all 0.2s ease;
display: inline-block;
margin-right: 8px;
}
.custom-checkbox:hover input ~ .checkmark {
border-color: #6b7280; /* gray-500 */
}
.custom-checkbox input:checked ~ .checkmark {
background-color: #6b7280; /* gray-500 */
border-color: #6b7280;
}
.checkmark:after {
content: "";
position: absolute;
display: none;
}
.custom-checkbox input:checked ~ .checkmark:after {
display: block;
}
.custom-checkbox .checkmark:after {
left: 6.5px;
top: 6px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_info.title }}</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 15px 0;
}
a {
display: inline-block;
padding: 8px 15px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
}
a:hover {
background-color: #2980b9;
}
p {
margin: 5px 0;
}
</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>
<ul>
<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>
</ul>
</body>
</html>
+130 -52
View File
@@ -3,58 +3,136 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_info.title }}</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 15px 0;
}
a {
display: inline-block;
padding: 8px 15px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
}
a:hover {
background-color: #2980b9;
}
p {
margin: 5px 0;
}
</style>
<title>LiB</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="static/styles.css" />
</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>
<ul>
<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>
</ul>
<body class="flex flex-col min-h-screen bg-gray-100">
<!-- Header -->
<header class="bg-gray-500 text-white p-4 shadow-md">
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
<img class="invert" src="static/logo.svg" />
<h1 class="text-2xl font-bold">LiB</h1>
</a>
<nav>
<ul class="flex space-x-4">
<li>
<a href="/" class="hover:text-gray-200">Home</a>
</li>
<li>
<a href="#" class="hover:text-gray-200">Products</a>
</li>
<li>
<a href="#" class="hover:text-gray-200">About</a>
</li>
<li>
<a href="/api" class="hover:text-gray-200">API</a>
</li>
</ul>
</nav>
<img class="max-w-6 h-auto invert" src="static/avatar.svg" />
</div>
</header>
<!-- Main -->
<div class="flex flex-1 mt-4 p-4">
<aside
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96"
>
<h2 class="text-xl font-semibold mb-4">Фильтры</h2>
<!-- Authors -->
<div class="mb-4">
<h3 class="font-medium mb-2">Авторы</h3>
<div class="relative">
<div
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white"
id="selected-authors-container"
>
<input
type="text"
id="author-search-input"
class="flex-grow outline-none bg-transparent"
placeholder="Начните вводить..."
/>
</div>
<div
id="author-dropdown"
class="absolute z-10 w-full bg-white border border-gray-300 rounded-md mt-1 hidden max-h-60 overflow-y-auto"
></div>
</div>
</div>
<!-- Genres -->
<div class="mb-4">
<h3 class="font-medium mb-2">Жанры</h3>
<ul id="genres-list"></ul>
</div>
<!-- Apply -->
<button
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200"
>
Применить фильтры
</button>
</aside>
<!-- Main Area -->
<main class="flex-1">
<!-- Book Card 1 -->
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start"
>
<div>
<h3 class="text-lg font-bold mb-1">Product Title 1</h3>
<p class="text-gray-700 text-sm">
A short description of the product, highlighting its
key features and benefits.
</p>
</div>
<span class="text-lg font-semibold text-gray-600"
>$29.99</span
>
</div>
<!-- Book Card 2 -->
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start"
>
<div>
<h3 class="text-lg font-bold mb-1">Product Title 2</h3>
<p class="text-gray-700 text-sm">
Another great product with amazing features. You'll
love it!
</p>
</div>
<span class="text-lg font-semibold text-blue-600"
>$49.99</span
>
</div>
<!-- Book Card 3 -->
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start"
>
<div>
<h3 class="text-lg font-bold mb-1">Product Title 3</h3>
<p class="text-gray-700 text-sm">
This product is a must-have for every modern home.
High quality and durable.
</p>
</div>
<span class="text-lg font-semibold text-gray-600"
>$19.99</span
>
</div>
</main>
</div>
<!-- Footer -->
<footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center">
<p>&copy; 2025 My Awesome Library. All rights reserved.</p>
</div>
</footer>
<script type="text/javascript" src="static/script.js"></script>
</body>
</html>