mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Добавление аналитики
This commit is contained in:
+39
-12
@@ -32,17 +32,17 @@ pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
||||
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 токена."""
|
||||
"""Создает JWT access токен"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
@@ -56,7 +56,7 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""Создание JWT refresh токена."""
|
||||
"""Создает JWT refresh токен"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
@@ -65,7 +65,7 @@ def create_refresh_token(data: dict) -> str:
|
||||
|
||||
|
||||
def decode_token(token: str, expected_type: str = "access") -> TokenData:
|
||||
"""Декодирование и проверка JWT токенов."""
|
||||
"""Декодирует и проверяет JWT токен"""
|
||||
token_error = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
@@ -88,7 +88,7 @@ def decode_token(token: str, expected_type: str = "access") -> TokenData:
|
||||
|
||||
|
||||
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):
|
||||
@@ -100,7 +100,7 @@ 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)
|
||||
@@ -116,7 +116,7 @@ def get_current_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"
|
||||
@@ -125,7 +125,7 @@ def get_current_active_user(
|
||||
|
||||
|
||||
def require_role(role_name: str):
|
||||
"""Dependency, требующая выполнения определенной роли."""
|
||||
"""Создает dependency для проверки наличия определенной роли"""
|
||||
|
||||
def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
|
||||
user_roles = [role.name for role in current_user.roles]
|
||||
@@ -139,15 +139,42 @@ def require_role(role_name: str):
|
||||
return role_checker
|
||||
|
||||
|
||||
def require_any_role(allowed_roles: list[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 not (user_roles & set(allowed_roles)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires one of roles: {allowed_roles}",
|
||||
)
|
||||
return current_user
|
||||
return role_checker
|
||||
|
||||
|
||||
# Создание dependencies
|
||||
RequireAuth = Annotated[User, Depends(get_current_active_user)]
|
||||
RequireAdmin = Annotated[User, Depends(require_role("admin"))]
|
||||
RequireMember = Annotated[User, Depends(require_role("member"))]
|
||||
RequireLibrarian = Annotated[User, Depends(require_role("librarian"))]
|
||||
RequireStaff = Annotated[User, Depends(require_any_role(["admin", "librarian"]))]
|
||||
|
||||
|
||||
def is_user_staff(user: User) -> bool:
|
||||
"""Проверяет, является ли пользователь сотрудником (admin или librarian)"""
|
||||
roles = {role.name for role in user.roles}
|
||||
return bool(roles & {"admin", "librarian"})
|
||||
|
||||
|
||||
|
||||
def is_user_admin(user: User) -> bool:
|
||||
"""Проверяет, является ли пользователь администратором"""
|
||||
roles = {role.name for role in user.roles}
|
||||
return "admin" in roles
|
||||
|
||||
|
||||
def seed_roles(session: Session) -> dict[str, Role]:
|
||||
"""Создаёт роли по умолчанию, если их нет."""
|
||||
"""Создает роли по умолчанию, если их нет"""
|
||||
default_roles = [
|
||||
{"name": "admin", "description": "Администратор системы", "payroll": 80000},
|
||||
{"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
|
||||
@@ -174,7 +201,7 @@ def seed_roles(session: Session) -> dict[str, Role]:
|
||||
|
||||
|
||||
def seed_admin(session: Session, admin_role: Role) -> User | None:
|
||||
"""Создаёт администратора по умолчанию, если нет ни одного."""
|
||||
"""Создает администратора по умолчанию, если нет ни одного"""
|
||||
existing_admins = session.exec(
|
||||
select(User).join(User.roles).where(Role.name == "admin")
|
||||
).all()
|
||||
@@ -219,6 +246,6 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
|
||||
|
||||
|
||||
def run_seeds(session: Session) -> None:
|
||||
"""Запускаем создание ролей и администратора."""
|
||||
"""Запускает создание ролей и администратора"""
|
||||
roles = seed_roles(session)
|
||||
seed_admin(session, roles["admin"])
|
||||
|
||||
@@ -7,6 +7,7 @@ from .user import User
|
||||
from .links import (
|
||||
AuthorBookLink,
|
||||
GenreBookLink,
|
||||
BookUserLink,
|
||||
UserRoleLink
|
||||
)
|
||||
|
||||
@@ -18,5 +19,6 @@ __all__ = [
|
||||
"User",
|
||||
"AuthorBookLink",
|
||||
"GenreBookLink",
|
||||
"BookUserLink",
|
||||
"UserRoleLink",
|
||||
]
|
||||
|
||||
@@ -19,6 +19,8 @@ class LoanCreate(LoanBase):
|
||||
|
||||
class LoanUpdate(SQLModel):
|
||||
"""Модель для обновления записи о выдаче"""
|
||||
user_id: int | None = None
|
||||
due_date: datetime | None = None
|
||||
returned_at: datetime | None = None
|
||||
|
||||
|
||||
|
||||
@@ -5,15 +5,19 @@ 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
|
||||
from .loans import router as loans_router
|
||||
from .relationships import router as relationships_router
|
||||
from .misc import router as misc_router
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
||||
# Подключение всех маршрутов
|
||||
api_router.include_router(misc_router)
|
||||
api_router.include_router(auth_router, prefix="/api")
|
||||
api_router.include_router(authors_router, prefix="/api")
|
||||
api_router.include_router(books_router, prefix="/api")
|
||||
api_router.include_router(genres_router, prefix="/api")
|
||||
api_router.include_router(loans_router, prefix="/api")
|
||||
api_router.include_router(relationships_router, prefix="/api")
|
||||
|
||||
@@ -9,10 +9,11 @@ from sqlmodel import Session, select
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList
|
||||
from library_service.settings import get_session
|
||||
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin, RequireAuth,
|
||||
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAuth, RequireAdmin, RequireStaff,
|
||||
authenticate_user, get_password_hash, decode_token,
|
||||
create_access_token, create_refresh_token)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@@ -24,8 +25,7 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
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()
|
||||
@@ -35,7 +35,6 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
# Проверка если email существует
|
||||
existing_email = session.exec(
|
||||
select(User).where(User.email == user_data.email)
|
||||
).first()
|
||||
@@ -70,7 +69,7 @@ def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт аутентификации и получения JWT токена"""
|
||||
"""Аутентифицирует пользователя и возвращает JWT токены"""
|
||||
user = authenticate_user(session, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
@@ -103,7 +102,7 @@ def refresh_token(
|
||||
refresh_token: str = Body(..., embed=True),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт для обновления токенов."""
|
||||
"""Обновляет пару токенов (access и refresh)"""
|
||||
try:
|
||||
token_data = decode_token(refresh_token, expected_type="refresh")
|
||||
except HTTPException:
|
||||
@@ -149,7 +148,7 @@ def refresh_token(
|
||||
description="Получить информацию о текущем авторизованном пользователе",
|
||||
)
|
||||
def get_my_profile(current_user: RequireAuth):
|
||||
"""Эндпоинт получения информации о себе"""
|
||||
"""Возвращает информацию о текущем пользователе"""
|
||||
return UserRead(
|
||||
**current_user.model_dump(), roles=[role.name for role in current_user.roles]
|
||||
)
|
||||
@@ -166,7 +165,7 @@ def update_user_me(
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт обновления пользователя"""
|
||||
"""Обновляет профиль текущего пользователя"""
|
||||
if user_update.email:
|
||||
current_user.email = user_update.email
|
||||
if user_update.full_name:
|
||||
@@ -190,12 +189,12 @@ def update_user_me(
|
||||
description="Получить список всех пользователей (только для админов)",
|
||||
)
|
||||
def read_users(
|
||||
admin: RequireAdmin,
|
||||
current_user: RequireStaff,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт получения списка всех пользователей"""
|
||||
"""Возвращает список всех пользователей"""
|
||||
users = session.exec(select(User).offset(skip).limit(limit)).all()
|
||||
return UserList(
|
||||
users=[
|
||||
@@ -218,7 +217,7 @@ def add_role_to_user(
|
||||
admin: RequireAdmin,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт добавления роли пользователю"""
|
||||
"""Добавляет роль пользователю"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
@@ -259,7 +258,7 @@ def remove_role_from_user(
|
||||
admin: RequireAdmin,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления роли у пользователя"""
|
||||
"""Удаляет роль у пользователя"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
@@ -298,7 +297,7 @@ def get_roles(
|
||||
auth: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт получения списа ролей"""
|
||||
"""Возвращает список ролей в системе"""
|
||||
user_roles = [role.name for role in auth.roles]
|
||||
exclude = {"payroll"} if "admin" in user_roles else set()
|
||||
roles = session.exec(select(Role)).all()
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.settings import get_session
|
||||
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"])
|
||||
|
||||
|
||||
@@ -18,11 +19,11 @@ router = APIRouter(prefix="/authors", tags=["authors"])
|
||||
description="Добавляет автора в систему",
|
||||
)
|
||||
def create_author(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
author: AuthorCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт создания автора"""
|
||||
"""Создает нового автора в системе"""
|
||||
db_author = Author(**author.model_dump())
|
||||
session.add(db_author)
|
||||
session.commit()
|
||||
@@ -37,7 +38,7 @@ def create_author(
|
||||
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],
|
||||
@@ -55,7 +56,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")
|
||||
@@ -79,12 +80,12 @@ def get_author(
|
||||
description="Обновляет информацию об авторе в системе",
|
||||
)
|
||||
def update_author(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
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")
|
||||
@@ -105,11 +106,11 @@ def update_author(
|
||||
description="Удаляет автора из системы",
|
||||
)
|
||||
def delete_author(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
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")
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Модуль работы с книгами"""
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlmodel import Session, select, col, func
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre
|
||||
from library_service.models.enums import BookStatus
|
||||
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre, BookUserLink
|
||||
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
|
||||
from library_service.models.dto.combined import (
|
||||
BookWithAuthorsAndGenres,
|
||||
@@ -17,6 +19,19 @@ from library_service.models.dto.combined import (
|
||||
router = APIRouter(prefix="/books", tags=["books"])
|
||||
|
||||
|
||||
def close_active_loan(session: Session, book_id: int) -> None:
|
||||
"""Закрывает активную выдачу книги при изменении статуса"""
|
||||
active_loan = session.exec(
|
||||
select(BookUserLink)
|
||||
.where(BookUserLink.book_id == book_id)
|
||||
.where(BookUserLink.returned_at == None) # noqa: E711
|
||||
).first()
|
||||
|
||||
if active_loan:
|
||||
active_loan.returned_at = datetime.utcnow()
|
||||
session.add(active_loan)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filter",
|
||||
response_model=BookFilteredList,
|
||||
@@ -31,7 +46,7 @@ def filter_books(
|
||||
page: int = Query(1, gt=0, description="Номер страницы"),
|
||||
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
|
||||
):
|
||||
"""Эндпоинт получения отфильтрованного списка книг"""
|
||||
"""Возвращает отфильтрованный список книг с пагинацией"""
|
||||
statement = select(Book).distinct()
|
||||
|
||||
if q:
|
||||
@@ -72,9 +87,11 @@ def filter_books(
|
||||
description="Добавляет книгу в систему",
|
||||
)
|
||||
def create_book(
|
||||
current_user: RequireAuth, book: BookCreate, session: Session = Depends(get_session)
|
||||
book: BookCreate,
|
||||
current_user: RequireStaff,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Эндпоинт создания книги"""
|
||||
"""Создает новую книгу в системе"""
|
||||
db_book = Book(**book.model_dump())
|
||||
session.add(db_book)
|
||||
session.commit()
|
||||
@@ -89,7 +106,7 @@ def create_book(
|
||||
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)
|
||||
@@ -106,7 +123,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")
|
||||
@@ -137,22 +154,39 @@ def get_book(
|
||||
description="Обновляет информацию о книге в системе",
|
||||
)
|
||||
def update_book(
|
||||
current_user: RequireAuth,
|
||||
book: BookUpdate,
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
current_user: RequireStaff,
|
||||
book_update: BookUpdate,
|
||||
book_id: int = Path(..., 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")
|
||||
|
||||
db_book.title = book.title or db_book.title
|
||||
db_book.description = book.description or db_book.description
|
||||
db_book.status = book.status or db_book.status
|
||||
if book_update.status is not None:
|
||||
if book_update.status == BookStatus.BORROWED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Статус 'borrowed' устанавливается только через выдачу книги"
|
||||
)
|
||||
|
||||
if db_book.status == BookStatus.BORROWED:
|
||||
close_active_loan(session, book_id)
|
||||
|
||||
db_book.status = book_update.status
|
||||
|
||||
if book_update.title is not None or book_update.description is not None:
|
||||
if book_update.title is not None:
|
||||
db_book.title = book_update.title
|
||||
if book_update.description is not None:
|
||||
db_book.description = book_update.description
|
||||
|
||||
session.add(db_book)
|
||||
session.commit()
|
||||
session.refresh(db_book)
|
||||
return db_book
|
||||
|
||||
return BookRead(**db_book.model_dump())
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -162,11 +196,11 @@ def update_book(
|
||||
description="Удаляет книгу их системы",
|
||||
)
|
||||
def delete_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
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")
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.auth import RequireStaff
|
||||
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
|
||||
|
||||
|
||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
||||
|
||||
|
||||
@@ -17,11 +18,11 @@ router = APIRouter(prefix="/genres", tags=["genres"])
|
||||
description="Добавляет жанр книг в систему",
|
||||
)
|
||||
def create_genre(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
genre: GenreCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт создания жанра"""
|
||||
"""Создает новый жанр в системе"""
|
||||
db_genre = Genre(**genre.model_dump())
|
||||
session.add(db_genre)
|
||||
session.commit()
|
||||
@@ -36,7 +37,7 @@ def create_genre(
|
||||
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)
|
||||
@@ -53,7 +54,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")
|
||||
@@ -73,16 +74,16 @@ def get_genre(
|
||||
@router.put(
|
||||
"/{genre_id}",
|
||||
response_model=GenreRead,
|
||||
summary="Обновляет информацию о жанре",
|
||||
summary="Обновить информацию о жанре",
|
||||
description="Обновляет информацию о жанре в системе",
|
||||
)
|
||||
def update_genre(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
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")
|
||||
@@ -100,14 +101,14 @@ def update_genre(
|
||||
"/{genre_id}",
|
||||
response_model=GenreRead,
|
||||
summary="Удалить жанр",
|
||||
description="Удаляет автора из системы",
|
||||
description="Удаляет жанр из системы",
|
||||
)
|
||||
def delete_genre(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Модуль работы с выдачей и бронированием книг"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session, select, col, func
|
||||
from sqlalchemy import cast, Date
|
||||
|
||||
from library_service.auth import RequireAuth, RequireStaff, RequireAdmin, is_user_staff
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Book, User, BookUserLink
|
||||
from library_service.models.dto import LoanCreate, LoanRead, LoanList, LoanUpdate
|
||||
from library_service.models.enums import BookStatus
|
||||
|
||||
|
||||
router = APIRouter(prefix="/loans", tags=["loans"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=LoanRead,
|
||||
summary="Создать выдачу/бронь",
|
||||
description="Создает запись о выдаче или бронировании книги",
|
||||
)
|
||||
def create_loan(
|
||||
current_user: RequireAuth,
|
||||
loan: LoanCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Создает выдачу или бронирование книги"""
|
||||
is_staff = is_user_staff(current_user)
|
||||
|
||||
if not is_staff and loan.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only create loans for yourself"
|
||||
)
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
if book.status != BookStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Book is not available for loan (status: {book.status})"
|
||||
)
|
||||
|
||||
target_user = session.get(User, loan.user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
db_loan = BookUserLink(
|
||||
book_id=loan.book_id,
|
||||
user_id=loan.user_id,
|
||||
due_date=loan.due_date,
|
||||
borrowed_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
book.status = BookStatus.RESERVED
|
||||
|
||||
session.add(db_loan)
|
||||
session.add(book)
|
||||
session.commit()
|
||||
session.refresh(db_loan)
|
||||
|
||||
return LoanRead(**db_loan.model_dump())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=LoanList,
|
||||
summary="Получить список выдач",
|
||||
description="Возвращает список выдач. Читатели видят только свои. Сотрудники видят все.",
|
||||
)
|
||||
def read_loans(
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
user_id: int | None = Query(None, description="Фильтр по user_ID"),
|
||||
book_id: int | None = Query(None, description="Фильтр по book_ID"),
|
||||
active_only: bool = Query(False, description="Только не возвращенные выдачи"),
|
||||
page: int = Query(1, gt=0, description="Номер страницы"),
|
||||
size: int = Query(20, gt=0, lt=101, description="Элементов на странице"),
|
||||
):
|
||||
"""Возвращает список выдач с фильтрацией и пагинацией"""
|
||||
is_staff = is_user_staff(current_user)
|
||||
|
||||
statement = select(BookUserLink)
|
||||
|
||||
if not is_staff:
|
||||
statement = statement.where(BookUserLink.user_id == current_user.id)
|
||||
elif user_id is not None:
|
||||
statement = statement.where(BookUserLink.user_id == user_id)
|
||||
|
||||
if book_id is not None:
|
||||
statement = statement.where(BookUserLink.book_id == book_id)
|
||||
|
||||
if active_only:
|
||||
statement = statement.where(BookUserLink.returned_at == None) # noqa: E711
|
||||
|
||||
total_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = session.exec(total_statement).one()
|
||||
|
||||
offset = (page - 1) * size
|
||||
statement = statement.order_by(col(BookUserLink.borrowed_at).desc())
|
||||
statement = statement.offset(offset).limit(size)
|
||||
|
||||
loans = session.exec(statement).all()
|
||||
|
||||
return LoanList(
|
||||
loans=[LoanRead(**loan.model_dump()) for loan in loans],
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analytics",
|
||||
summary="Аналитика выдач и возвратов",
|
||||
description="Возвращает аналитику выдач и возвратов. Только для админов.",
|
||||
)
|
||||
def get_loans_analytics(
|
||||
current_user: RequireAdmin,
|
||||
days: int = Query(30, ge=1, le=365, description="Количество дней для анализа"),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Возвращает аналитику по выдачам и возвратам книг"""
|
||||
end_date = datetime.utcnow()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
total_loans = session.exec(
|
||||
select(func.count(BookUserLink.id))
|
||||
.where(BookUserLink.borrowed_at >= start_date)
|
||||
).one()
|
||||
|
||||
active_loans = session.exec(
|
||||
select(func.count(BookUserLink.id))
|
||||
.where(BookUserLink.borrowed_at >= start_date)
|
||||
.where(BookUserLink.returned_at == None) # noqa: E711
|
||||
).one()
|
||||
|
||||
returned_loans = session.exec(
|
||||
select(func.count(BookUserLink.id))
|
||||
.where(BookUserLink.borrowed_at >= start_date)
|
||||
.where(BookUserLink.returned_at != None) # noqa: E711
|
||||
).one()
|
||||
|
||||
overdue_loans = session.exec(
|
||||
select(func.count(BookUserLink.id))
|
||||
.where(BookUserLink.returned_at == None) # noqa: E711
|
||||
.where(BookUserLink.due_date < end_date)
|
||||
).one()
|
||||
|
||||
daily_loans = {}
|
||||
daily_returns = {}
|
||||
|
||||
loans_by_date = session.exec(
|
||||
select(
|
||||
cast(BookUserLink.borrowed_at, Date).label("date"),
|
||||
func.count(BookUserLink.id).label("count")
|
||||
)
|
||||
.where(BookUserLink.borrowed_at >= start_date)
|
||||
.group_by(cast(BookUserLink.borrowed_at, Date))
|
||||
.order_by(cast(BookUserLink.borrowed_at, Date))
|
||||
).all()
|
||||
|
||||
returns_by_date = session.exec(
|
||||
select(
|
||||
cast(BookUserLink.returned_at, Date).label("date"),
|
||||
func.count(BookUserLink.id).label("count")
|
||||
)
|
||||
.where(BookUserLink.returned_at >= start_date)
|
||||
.where(BookUserLink.returned_at != None) # noqa: E711
|
||||
.group_by(cast(BookUserLink.returned_at, Date))
|
||||
.order_by(cast(BookUserLink.returned_at, Date))
|
||||
).all()
|
||||
|
||||
for row in loans_by_date:
|
||||
date_str = str(row[0]) if isinstance(row, tuple) else str(row.date)
|
||||
count = row[1] if isinstance(row, tuple) else row.count
|
||||
daily_loans[date_str] = count
|
||||
|
||||
for row in returns_by_date:
|
||||
date_str = str(row[0]) if isinstance(row, tuple) else str(row.date)
|
||||
count = row[1] if isinstance(row, tuple) else row.count
|
||||
daily_returns[date_str] = count
|
||||
|
||||
top_books = session.exec(
|
||||
select(
|
||||
BookUserLink.book_id,
|
||||
func.count(BookUserLink.id).label("loan_count")
|
||||
)
|
||||
.where(BookUserLink.borrowed_at >= start_date)
|
||||
.group_by(BookUserLink.book_id)
|
||||
.order_by(func.count(BookUserLink.id).desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
|
||||
top_books_data = []
|
||||
for row in top_books:
|
||||
book_id = row[0] if isinstance(row, tuple) else row.book_id
|
||||
loan_count = row[1] if isinstance(row, tuple) else row.loan_count
|
||||
book = session.get(Book, book_id)
|
||||
if book:
|
||||
top_books_data.append({
|
||||
"book_id": book_id,
|
||||
"title": book.title,
|
||||
"loan_count": loan_count
|
||||
})
|
||||
|
||||
reserved_count = session.exec(
|
||||
select(func.count(Book.id))
|
||||
.where(Book.status == BookStatus.RESERVED)
|
||||
).one()
|
||||
|
||||
borrowed_count = session.exec(
|
||||
select(func.count(Book.id))
|
||||
.where(Book.status == BookStatus.BORROWED)
|
||||
).one()
|
||||
|
||||
return JSONResponse(content={
|
||||
"summary": {
|
||||
"total_loans": total_loans,
|
||||
"active_loans": active_loans,
|
||||
"returned_loans": returned_loans,
|
||||
"overdue_loans": overdue_loans,
|
||||
"reserved_books": reserved_count,
|
||||
"borrowed_books": borrowed_count,
|
||||
},
|
||||
"daily_loans": daily_loans,
|
||||
"daily_returns": daily_returns,
|
||||
"top_books": top_books_data,
|
||||
"period_days": days,
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": end_date.isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{loan_id}",
|
||||
response_model=LoanRead,
|
||||
summary="Получить выдачу по ID",
|
||||
description="Возвращает выдачу по ID",
|
||||
)
|
||||
def get_loan(
|
||||
current_user: RequireAuth,
|
||||
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Возвращает информацию о выдаче по ID"""
|
||||
loan = session.get(BookUserLink, loan_id)
|
||||
|
||||
if not loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
|
||||
is_staff = is_user_staff(current_user)
|
||||
|
||||
if not is_staff and loan.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this loan"
|
||||
)
|
||||
|
||||
return LoanRead(**loan.model_dump())
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{loan_id}",
|
||||
response_model=LoanRead,
|
||||
summary="Обновить выдачу",
|
||||
description="Обновляет информацию о выдаче. Сотрудники могут обновлять любые, читатели только свои.",
|
||||
)
|
||||
def update_loan(
|
||||
current_user: RequireAuth,
|
||||
loan_update: LoanUpdate,
|
||||
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Обновляет информацию о выдаче"""
|
||||
db_loan = session.get(BookUserLink, loan_id)
|
||||
if not db_loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
|
||||
is_staff = is_user_staff(current_user)
|
||||
|
||||
if not is_staff and db_loan.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only update your own loans"
|
||||
)
|
||||
|
||||
book = session.get(Book, db_loan.book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
if loan_update.user_id is not None:
|
||||
if not is_staff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only staff can change loan user"
|
||||
)
|
||||
new_user = session.get(User, loan_update.user_id)
|
||||
if not new_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db_loan.user_id = loan_update.user_id
|
||||
|
||||
if loan_update.due_date is not None:
|
||||
db_loan.due_date = loan_update.due_date
|
||||
|
||||
if loan_update.returned_at is not None:
|
||||
if db_loan.returned_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Loan is already returned"
|
||||
)
|
||||
db_loan.returned_at = loan_update.returned_at
|
||||
book.status = BookStatus.ACTIVE
|
||||
|
||||
session.add(db_loan)
|
||||
session.add(book)
|
||||
session.commit()
|
||||
session.refresh(db_loan)
|
||||
|
||||
return LoanRead(**db_loan.model_dump())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{loan_id}/confirm",
|
||||
response_model=LoanRead,
|
||||
summary="Подтвердить бронь",
|
||||
description="Подтверждает бронирование и меняет статус книги на BORROWED",
|
||||
)
|
||||
def confirm_loan(
|
||||
current_user: RequireStaff,
|
||||
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Подтверждает бронирование и меняет статус книги на BORROWED"""
|
||||
loan = session.get(BookUserLink, loan_id)
|
||||
if not loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
|
||||
if loan.returned_at:
|
||||
raise HTTPException(status_code=400, detail="Loan is already returned")
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot confirm loan for book with status: {book.status}"
|
||||
)
|
||||
|
||||
book.status = BookStatus.BORROWED
|
||||
|
||||
session.add(loan)
|
||||
session.add(book)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
|
||||
return LoanRead(**loan.model_dump())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{loan_id}/return",
|
||||
response_model=LoanRead,
|
||||
summary="Вернуть книгу",
|
||||
description="Возвращает книгу и закрывает выдачу",
|
||||
)
|
||||
def return_loan(
|
||||
current_user: RequireStaff,
|
||||
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Возвращает книгу и закрывает выдачу"""
|
||||
loan = session.get(BookUserLink, loan_id)
|
||||
if not loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
|
||||
if loan.returned_at:
|
||||
raise HTTPException(status_code=400, detail="Loan is already returned")
|
||||
|
||||
loan.returned_at = datetime.utcnow()
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
if book:
|
||||
book.status = BookStatus.ACTIVE
|
||||
session.add(book)
|
||||
|
||||
session.add(loan)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
|
||||
return LoanRead(**loan.model_dump())
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{loan_id}",
|
||||
response_model=LoanRead,
|
||||
summary="Удалить выдачу/бронь",
|
||||
description="Удаляет выдачу/бронь. Работает только для статуса RESERVED.",
|
||||
)
|
||||
def delete_loan(
|
||||
current_user: RequireAuth,
|
||||
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Удаляет выдачу или бронирование (только для RESERVED статуса)"""
|
||||
loan = session.get(BookUserLink, loan_id)
|
||||
if not loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
|
||||
is_staff = is_user_staff(current_user)
|
||||
|
||||
if not is_staff and loan.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only delete your own loans"
|
||||
)
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
|
||||
if book and book.status != BookStatus.RESERVED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Can only delete reservations. Use update endpoint to return borrowed books"
|
||||
)
|
||||
|
||||
loan_read = LoanRead(**loan.model_dump())
|
||||
session.delete(loan)
|
||||
|
||||
if book:
|
||||
book.status = BookStatus.ACTIVE
|
||||
session.add(book)
|
||||
|
||||
session.commit()
|
||||
|
||||
return loan_read
|
||||
|
||||
|
||||
@router.get(
|
||||
"/book/{book_id}/active",
|
||||
response_model=LoanRead | None,
|
||||
summary="Получить активную выдачу книги",
|
||||
description="Возвращает активную выдачу для указанной книги",
|
||||
)
|
||||
def get_active_loan_for_book(
|
||||
current_user: RequireStaff,
|
||||
book_id: int = Path(..., description="Book ID (integer, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Возвращает активную выдачу для указанной книги"""
|
||||
loan = session.exec(
|
||||
select(BookUserLink)
|
||||
.where(BookUserLink.book_id == book_id)
|
||||
.where(BookUserLink.returned_at == None) # noqa: E711
|
||||
).first()
|
||||
|
||||
if not loan:
|
||||
return None
|
||||
|
||||
return LoanRead(**loan.model_dump())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issue",
|
||||
response_model=LoanRead,
|
||||
summary="Выдать книгу напрямую",
|
||||
description="Только для администраторов. Создает выдачу и устанавливает статус книги на BORROWED.",
|
||||
)
|
||||
def issue_book_directly(
|
||||
current_user: RequireAdmin,
|
||||
loan: LoanCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Выдает книгу напрямую без бронирования (только для администраторов)"""
|
||||
book = session.get(Book, loan.book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
if book.status != BookStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Book is not available (status: {book.status})"
|
||||
)
|
||||
|
||||
target_user = session.get(User, loan.user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
db_loan = BookUserLink(
|
||||
book_id=loan.book_id,
|
||||
user_id=loan.user_id,
|
||||
due_date=loan.due_date,
|
||||
borrowed_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
book.status = BookStatus.BORROWED
|
||||
|
||||
session.add(db_loan)
|
||||
session.add(book)
|
||||
session.commit()
|
||||
session.refresh(db_loan)
|
||||
|
||||
return LoanRead(**db_loan.model_dump())
|
||||
@@ -18,7 +18,7 @@ templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates"
|
||||
|
||||
|
||||
def get_info(app) -> Dict:
|
||||
"""Форматированная информация о приложении"""
|
||||
"""Возвращает информацию о приложении"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"app_info": {
|
||||
@@ -32,103 +32,115 @@ def get_info(app) -> Dict:
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def root(request: Request):
|
||||
"""Эндпоинт главной страницы"""
|
||||
"""Рендерит главную страницу"""
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
|
||||
|
||||
@router.get("/genre/create", include_in_schema=False)
|
||||
async def create_genre(request: Request):
|
||||
"""Эндпоинт страницы создания жанра"""
|
||||
"""Рендерит страницу создания жанра"""
|
||||
return templates.TemplateResponse(request, "create_genre.html")
|
||||
|
||||
|
||||
@router.get("/genre/{genre_id}/edit", include_in_schema=False)
|
||||
async def edit_genre(request: Request, genre_id: int):
|
||||
"""Эндпоинт страницы редактирования жанра"""
|
||||
"""Рендерит страницу редактирования жанра"""
|
||||
return templates.TemplateResponse(request, "edit_genre.html")
|
||||
|
||||
|
||||
@router.get("/authors", include_in_schema=False)
|
||||
async def authors(request: Request):
|
||||
"""Эндпоинт страницы выбора автора"""
|
||||
"""Рендерит страницу списка авторов"""
|
||||
return templates.TemplateResponse(request, "authors.html")
|
||||
|
||||
|
||||
@router.get("/author/create", include_in_schema=False)
|
||||
async def create_author(request: Request):
|
||||
"""Эндпоинт страницы создания автора"""
|
||||
"""Рендерит страницу создания автора"""
|
||||
return templates.TemplateResponse(request, "create_author.html")
|
||||
|
||||
|
||||
@router.get("/author/{author_id}/edit", include_in_schema=False)
|
||||
async def edit_author(request: Request, author_id: int):
|
||||
"""Эндпоинт страницы редактирования автора"""
|
||||
"""Рендерит страницу редактирования автора"""
|
||||
return templates.TemplateResponse(request, "edit_author.html")
|
||||
|
||||
|
||||
@router.get("/author/{author_id}", include_in_schema=False)
|
||||
async def author(request: Request, author_id: int):
|
||||
"""Эндпоинт страницы автора"""
|
||||
"""Рендерит страницу просмотра автора"""
|
||||
return templates.TemplateResponse(request, "author.html")
|
||||
|
||||
|
||||
@router.get("/books", include_in_schema=False)
|
||||
async def books(request: Request):
|
||||
"""Эндпоинт страницы выбора книг"""
|
||||
"""Рендерит страницу списка книг"""
|
||||
return templates.TemplateResponse(request, "books.html")
|
||||
|
||||
|
||||
@router.get("/book/create", include_in_schema=False)
|
||||
async def create_book(request: Request):
|
||||
"""Эндпоинт страницы создания книги"""
|
||||
"""Рендерит страницу создания книги"""
|
||||
return templates.TemplateResponse(request, "create_book.html")
|
||||
|
||||
|
||||
@router.get("/book/{book_id}/edit", include_in_schema=False)
|
||||
async def edit_book(request: Request, book_id: int):
|
||||
"""Эндпоинт страницы редактирования книги"""
|
||||
"""Рендерит страницу редактирования книги"""
|
||||
return templates.TemplateResponse(request, "edit_book.html")
|
||||
|
||||
|
||||
@router.get("/book/{book_id}", include_in_schema=False)
|
||||
async def book(request: Request, book_id: int):
|
||||
"""Эндпоинт страницы книги"""
|
||||
"""Рендерит страницу просмотра книги"""
|
||||
return templates.TemplateResponse(request, "book.html")
|
||||
|
||||
|
||||
@router.get("/auth", include_in_schema=False)
|
||||
async def auth(request: Request):
|
||||
"""Эндпоинт страницы авторизации"""
|
||||
"""Рендерит страницу авторизации"""
|
||||
return templates.TemplateResponse(request, "auth.html")
|
||||
|
||||
|
||||
@router.get("/profile", include_in_schema=False)
|
||||
async def profile(request: Request):
|
||||
"""Эндпоинт страницы профиля"""
|
||||
"""Рендерит страницу профиля пользователя"""
|
||||
return templates.TemplateResponse(request, "profile.html")
|
||||
|
||||
|
||||
@router.get("/users", include_in_schema=False)
|
||||
async def users(request: Request):
|
||||
"""Эндпоинт страницы управления пользователями"""
|
||||
"""Рендерит страницу управления пользователями"""
|
||||
return templates.TemplateResponse(request, "users.html")
|
||||
|
||||
|
||||
@router.get("/my-books", include_in_schema=False)
|
||||
async def my_books(request: Request):
|
||||
"""Рендерит страницу моих книг пользователя"""
|
||||
return templates.TemplateResponse(request, "my_books.html")
|
||||
|
||||
|
||||
@router.get("/analytics", include_in_schema=False)
|
||||
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"""
|
||||
"""Рендерит страницу с ссылками на документацию API"""
|
||||
return templates.TemplateResponse(request, "api.html", get_info(app))
|
||||
|
||||
|
||||
@router.get("/favicon.ico", include_in_schema=False)
|
||||
def redirect_favicon():
|
||||
"""Редирект иконки вкладки"""
|
||||
"""Редиректит на favicon.svg"""
|
||||
return RedirectResponse("/favicon.svg")
|
||||
|
||||
|
||||
@router.get("/favicon.svg", include_in_schema=False)
|
||||
async def favicon():
|
||||
"""Эндпоинт иконки вкладки"""
|
||||
"""Возвращает иконку сайта"""
|
||||
return FileResponse(
|
||||
"library_service/static/favicon.svg", media_type="image/svg+xml"
|
||||
)
|
||||
@@ -140,7 +152,7 @@ async def favicon():
|
||||
description="Возвращает общую информацию о системе",
|
||||
)
|
||||
async def api_info(app=Depends(lambda: get_app())):
|
||||
"""Эндпоинт информации об API"""
|
||||
"""Возвращает информацию о сервисе"""
|
||||
return JSONResponse(content=get_info(app))
|
||||
|
||||
|
||||
@@ -150,7 +162,7 @@ async def api_info(app=Depends(lambda: get_app())):
|
||||
description="Возвращает статистическую информацию о системе",
|
||||
)
|
||||
async def api_stats(session: Session = Depends(get_session)):
|
||||
"""Эндпоинт стстистики системы"""
|
||||
"""Возвращает статистику системы"""
|
||||
authors = select(func.count()).select_from(Author)
|
||||
books = select(func.count()).select_from(Book)
|
||||
genres = select(func.count()).select_from(Genre)
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Dict, List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.auth import RequireStaff
|
||||
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
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(tags=["relations"])
|
||||
|
||||
|
||||
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")
|
||||
@@ -22,7 +22,7 @@ def check_entity_exists(session, model, entity_id, entity_name):
|
||||
|
||||
|
||||
def add_relationship(session, link_model, id1, field1, id2, field2, detail):
|
||||
"""Создание связи между сущностями в БД"""
|
||||
"""Создает связь между сущностями в базе данных"""
|
||||
existing_link = session.exec(
|
||||
select(link_model)
|
||||
.where(getattr(link_model, field1) == id1)
|
||||
@@ -40,7 +40,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
|
||||
|
||||
|
||||
def remove_relationship(session, link_model, id1, field1, id2, field2):
|
||||
"""Удаление связи между сущностями в БД"""
|
||||
"""Удаляет связь между сущностями в базе данных"""
|
||||
link = session.exec(
|
||||
select(link_model)
|
||||
.where(getattr(link_model, field1) == id1)
|
||||
@@ -66,7 +66,7 @@ def get_related(
|
||||
link_related_field,
|
||||
read_model
|
||||
):
|
||||
"""Получение связанных в БД сущностей"""
|
||||
"""Возвращает список связанных сущностей"""
|
||||
check_entity_exists(session, main_model, main_id, main_name)
|
||||
|
||||
related = session.exec(
|
||||
@@ -84,12 +84,12 @@ def get_related(
|
||||
description="Добавляет связь между автором и книгой в систему",
|
||||
)
|
||||
def add_author_to_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
author_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт добавления автора к книге"""
|
||||
"""Добавляет связь между автором и книгой"""
|
||||
check_entity_exists(session, Author, author_id, "Author")
|
||||
check_entity_exists(session, Book, book_id, "Book")
|
||||
|
||||
@@ -104,12 +104,12 @@ def add_author_to_book(
|
||||
description="Удаляет связь между автором и книгой в системе",
|
||||
)
|
||||
def remove_author_from_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
author_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления автора из книги"""
|
||||
"""Удаляет связь между автором и книгой"""
|
||||
return remove_relationship(session, AuthorBookLink,
|
||||
author_id, "author_id", book_id, "book_id")
|
||||
|
||||
@@ -121,7 +121,7 @@ def remove_author_from_book(
|
||||
description="Возвращает все книги в системе, написанные автором",
|
||||
)
|
||||
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
|
||||
"""Эндпоинт получения книг, написанных автором"""
|
||||
"""Возвращает список книг автора"""
|
||||
return get_related(session,
|
||||
Author, author_id, "Author", Book,
|
||||
AuthorBookLink, "author_id", "book_id", BookRead)
|
||||
@@ -134,7 +134,7 @@ 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)):
|
||||
"""Эндпоинт получения авторов книги"""
|
||||
"""Возвращает список авторов книги"""
|
||||
return get_related(session,
|
||||
Book, book_id, "Book", Author,
|
||||
AuthorBookLink, "book_id", "author_id", AuthorRead)
|
||||
@@ -147,12 +147,12 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
description="Добавляет связь между книгой и жанром в систему",
|
||||
)
|
||||
def add_genre_to_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
genre_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт добавления жанра к книге"""
|
||||
"""Добавляет связь между жанром и книгой"""
|
||||
check_entity_exists(session, Genre, genre_id, "Genre")
|
||||
check_entity_exists(session, Book, book_id, "Book")
|
||||
|
||||
@@ -167,12 +167,12 @@ def add_genre_to_book(
|
||||
description="Удаляет связь между жанром и книгой в системе",
|
||||
)
|
||||
def remove_genre_from_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
genre_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления жанра из книги"""
|
||||
"""Удаляет связь между жанром и книгой"""
|
||||
return remove_relationship(session, GenreBookLink,
|
||||
genre_id, "genre_id", book_id, "book_id")
|
||||
|
||||
@@ -184,7 +184,7 @@ def remove_genre_from_book(
|
||||
description="Возвращает все книги в системе в этом жанре",
|
||||
)
|
||||
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
"""Эндпоинт получения книг с жанром"""
|
||||
"""Возвращает список книг в жанре"""
|
||||
return get_related(session,
|
||||
Genre, genre_id, "Genre", Book,
|
||||
GenreBookLink, "genre_id", "book_id", BookRead)
|
||||
@@ -197,7 +197,7 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
description="Возвращает все жанры книги в системе",
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -13,7 +13,7 @@ with open("pyproject.toml", 'r', encoding='utf-8') as f:
|
||||
|
||||
|
||||
def get_app(lifespan=None, /) -> FastAPI:
|
||||
"""Dependency для получения экземпляра FastAPI application"""
|
||||
"""Возвращает экземпляр FastAPI приложения"""
|
||||
if not hasattr(get_app, 'instance'):
|
||||
get_app.instance = FastAPI(
|
||||
title=config["tool"]["poetry"]["name"],
|
||||
@@ -37,6 +37,10 @@ def get_app(lifespan=None, /) -> FastAPI:
|
||||
"name": "genres",
|
||||
"description": "Действия с жанрами.",
|
||||
},
|
||||
{
|
||||
"name": "loans",
|
||||
"description": "Действия с выдачами.",
|
||||
},
|
||||
{
|
||||
"name": "relations",
|
||||
"description": "Действия с связями.",
|
||||
@@ -64,11 +68,11 @@ engine = create_engine(POSTGRES_DATABASE_URL, echo=False, future=True)
|
||||
|
||||
|
||||
def get_session():
|
||||
"""Dependency, для получение сессии БД"""
|
||||
"""Возвращает сессию базы данных"""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def get_logger(name: str = "uvicorn"):
|
||||
"""Dependency, для получение логгера"""
|
||||
"""Возвращает логгер с указанным именем"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
$(document).ready(() => {
|
||||
if (!window.isAdmin()) {
|
||||
$(".container").html(
|
||||
'<div class="bg-white rounded-xl shadow-sm p-8 text-center border border-gray-100"><svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg><h3 class="text-lg font-medium text-gray-900 mb-2">Доступ запрещён</h3><p class="text-gray-500 mb-4">Только администраторы могут просматривать аналитику</p><a href="/" class="inline-block px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition">На главную</a></div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let loansChart = null;
|
||||
let returnsChart = null;
|
||||
let currentPeriod = 30;
|
||||
|
||||
init();
|
||||
|
||||
function init() {
|
||||
$("#period-select").on("change", function () {
|
||||
currentPeriod = parseInt($(this).val());
|
||||
loadAnalytics();
|
||||
});
|
||||
|
||||
$("#refresh-btn").on("click", loadAnalytics);
|
||||
|
||||
loadAnalytics();
|
||||
}
|
||||
|
||||
async function loadAnalytics() {
|
||||
try {
|
||||
const data = await Api.get(`/api/loans/analytics?days=${currentPeriod}`);
|
||||
renderSummary(data.summary);
|
||||
renderCharts(data);
|
||||
renderTopBooks(data.top_books);
|
||||
} catch (error) {
|
||||
console.error("Failed to load analytics", error);
|
||||
Utils.showToast("Ошибка загрузки аналитики", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
$("#total-loans").text(summary.total_loans || 0);
|
||||
$("#active-loans").text(summary.active_loans || 0);
|
||||
$("#returned-loans").text(summary.returned_loans || 0);
|
||||
$("#overdue-loans").text(summary.overdue_loans || 0);
|
||||
$("#reserved-books").text(summary.reserved_books || 0);
|
||||
$("#borrowed-books").text(summary.borrowed_books || 0);
|
||||
}
|
||||
|
||||
function renderCharts(data) {
|
||||
// Подготовка данных для графиков
|
||||
const startDate = new Date(data.start_date);
|
||||
const endDate = new Date(data.end_date);
|
||||
const dates = [];
|
||||
const loansData = [];
|
||||
const returnsData = [];
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().split("T")[0];
|
||||
dates.push(new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }));
|
||||
loansData.push(data.daily_loans[dateStr] || 0);
|
||||
returnsData.push(data.daily_returns[dateStr] || 0);
|
||||
}
|
||||
|
||||
// График выдач
|
||||
const loansCtx = document.getElementById("loans-chart");
|
||||
if (loansChart) {
|
||||
loansChart.destroy();
|
||||
}
|
||||
loansChart = new Chart(loansCtx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [
|
||||
{
|
||||
label: "Выдачи",
|
||||
data: loansData,
|
||||
borderColor: "rgb(75, 85, 99)",
|
||||
backgroundColor: "rgba(75, 85, 99, 0.05)",
|
||||
borderWidth: 1.5,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2.5,
|
||||
pointHoverRadius: 4,
|
||||
pointBackgroundColor: "rgb(75, 85, 99)",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 1.5,
|
||||
pointStyle: "circle",
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 10,
|
||||
titleFont: { size: 12, weight: "500" },
|
||||
bodyFont: { size: 11 },
|
||||
cornerRadius: 6,
|
||||
displayColors: false,
|
||||
borderColor: "rgba(255, 255, 255, 0.08)",
|
||||
borderWidth: 1,
|
||||
titleSpacing: 4,
|
||||
bodySpacing: 4,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(0, 0, 0, 0.03)",
|
||||
drawBorder: false,
|
||||
lineWidth: 1,
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
font: { size: 10 },
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
font: { size: 10 },
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// График возвратов
|
||||
const returnsCtx = document.getElementById("returns-chart");
|
||||
if (returnsChart) {
|
||||
returnsChart.destroy();
|
||||
}
|
||||
returnsChart = new Chart(returnsCtx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [
|
||||
{
|
||||
label: "Возвраты",
|
||||
data: returnsData,
|
||||
borderColor: "rgb(107, 114, 128)",
|
||||
backgroundColor: "rgba(107, 114, 128, 0.05)",
|
||||
borderWidth: 1.5,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2.5,
|
||||
pointHoverRadius: 4,
|
||||
pointBackgroundColor: "rgb(107, 114, 128)",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 1.5,
|
||||
pointStyle: "circle",
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 10,
|
||||
titleFont: { size: 12, weight: "500" },
|
||||
bodyFont: { size: 11 },
|
||||
cornerRadius: 6,
|
||||
displayColors: false,
|
||||
borderColor: "rgba(255, 255, 255, 0.08)",
|
||||
borderWidth: 1,
|
||||
titleSpacing: 4,
|
||||
bodySpacing: 4,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(0, 0, 0, 0.03)",
|
||||
drawBorder: false,
|
||||
lineWidth: 1,
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
font: { size: 10 },
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
font: { size: 10 },
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderTopBooks(topBooks) {
|
||||
const $container = $("#top-books-container");
|
||||
$container.empty();
|
||||
|
||||
if (!topBooks || topBooks.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет данных</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
topBooks.forEach((book, index) => {
|
||||
const $item = $(`
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100 hover:bg-gray-100 transition-colors">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="w-7 h-7 bg-gray-600 text-white rounded-full flex items-center justify-center font-medium text-xs flex-shrink-0">
|
||||
${index + 1}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href="/book/${book.book_id}" class="text-sm font-medium text-gray-900 hover:text-gray-600 transition-colors block truncate">
|
||||
${Utils.escapeHtml(book.title)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
<span class="px-2.5 py-1 bg-gray-200 text-gray-700 rounded-full text-xs font-medium">
|
||||
${book.loan_count} ${book.loan_count === 1 ? "выдача" : book.loan_count < 5 ? "выдачи" : "выдач"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
$container.append($item);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+413
-105
@@ -32,6 +32,194 @@ $(document).ready(() => {
|
||||
},
|
||||
};
|
||||
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const bookId = parseInt(pathParts[pathParts.length - 1]);
|
||||
let currentBook = null;
|
||||
let cachedUsers = null;
|
||||
let selectedLoanUserId = null;
|
||||
let activeLoan = null;
|
||||
|
||||
init();
|
||||
|
||||
function init() {
|
||||
if (!bookId || isNaN(bookId)) {
|
||||
Utils.showToast("Некорректный ID книги", "error");
|
||||
return;
|
||||
}
|
||||
loadBookData();
|
||||
setupEventHandlers();
|
||||
}
|
||||
|
||||
function setupEventHandlers() {
|
||||
$(document).on("click", (e) => {
|
||||
const $menu = $("#status-menu");
|
||||
const $toggleBtn = $("#status-toggle-btn");
|
||||
if (
|
||||
$menu.length &&
|
||||
!$menu.hasClass("hidden") &&
|
||||
!$toggleBtn.is(e.target) &&
|
||||
$toggleBtn.has(e.target).length === 0 &&
|
||||
!$menu.has(e.target).length
|
||||
) {
|
||||
$menu.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$("#cancel-loan-btn").on("click", closeLoanModal);
|
||||
$("#user-search-input").on("input", handleUserSearch);
|
||||
$("#confirm-loan-btn").on("click", submitLoan);
|
||||
$("#refresh-loans-btn").on("click", loadLoans);
|
||||
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + 14);
|
||||
$("#loan-due-date").val(future.toISOString().split("T")[0]);
|
||||
}
|
||||
|
||||
function loadBookData() {
|
||||
Api.get(`/api/books/${bookId}`)
|
||||
.then((book) => {
|
||||
currentBook = book;
|
||||
document.title = `LiB - ${book.title}`;
|
||||
renderBook(book);
|
||||
if (window.canManage()) {
|
||||
$("#edit-book-btn")
|
||||
.attr("href", `/book/${book.id}/edit`)
|
||||
.removeClass("hidden");
|
||||
$("#loans-section").removeClass("hidden");
|
||||
loadLoans();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Книга не найдена", "error");
|
||||
$("#book-loader").html(
|
||||
'<p class="text-center text-red-500 w-full p-4">Ошибка загрузки</p>',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLoans() {
|
||||
if (!window.canManage()) return;
|
||||
|
||||
try {
|
||||
const data = await Api.get(
|
||||
`/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10`
|
||||
);
|
||||
activeLoan = data.loans.length > 0 ? data.loans[0] : null;
|
||||
renderLoans(data.loans);
|
||||
} catch (error) {
|
||||
console.error("Failed to load loans", error);
|
||||
$("#loans-container").html(
|
||||
'<div class="text-center text-red-500 py-4">Ошибка загрузки выдач</div>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLoans(loans) {
|
||||
const $container = $("#loans-container");
|
||||
$container.empty();
|
||||
|
||||
if (!loans || loans.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
loans.forEach((loan) => {
|
||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
|
||||
"ru-RU"
|
||||
);
|
||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||
const isOverdue =
|
||||
!loan.returned_at && new Date(loan.due_date) < new Date();
|
||||
|
||||
const $loanCard = $(`
|
||||
<div class="border border-gray-200 rounded-lg p-4 ${
|
||||
isOverdue ? "bg-red-50 border-red-300" : "bg-gray-50"
|
||||
}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="font-medium text-gray-900">ID выдачи: ${loan.id}</span>
|
||||
${
|
||||
isOverdue
|
||||
? '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full">Просрочена</span>'
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-1">
|
||||
<span class="font-medium">Дата выдачи:</span> ${borrowedDate}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 mb-1">
|
||||
<span class="font-medium">Срок возврата:</span> ${dueDate}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
<span class="font-medium">Пользователь ID:</span> ${loan.user_id}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
${
|
||||
!loan.returned_at && currentBook.status === "reserved"
|
||||
? `<button class="confirm-loan-btn px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors" data-loan-id="${loan.id}">
|
||||
Подтвердить
|
||||
</button>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
!loan.returned_at
|
||||
? `<button class="return-loan-btn px-3 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors" data-loan-id="${loan.id}">
|
||||
Вернуть
|
||||
</button>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$loanCard.find(".confirm-loan-btn").on("click", function () {
|
||||
const loanId = $(this).data("loan-id");
|
||||
confirmLoan(loanId);
|
||||
});
|
||||
|
||||
$loanCard.find(".return-loan-btn").on("click", function () {
|
||||
const loanId = $(this).data("loan-id");
|
||||
returnLoan(loanId);
|
||||
});
|
||||
|
||||
$container.append($loanCard);
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmLoan(loanId) {
|
||||
try {
|
||||
await Api.post(`/api/loans/${loanId}/confirm`);
|
||||
Utils.showToast("Бронь подтверждена", "success");
|
||||
loadBookData();
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка подтверждения брони", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function returnLoan(loanId) {
|
||||
if (!confirm("Вы уверены, что хотите вернуть эту книгу?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Api.post(`/api/loans/${loanId}/return`);
|
||||
Utils.showToast("Книга возвращена", "success");
|
||||
loadBookData();
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка возврата книги", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusConfig(status) {
|
||||
return (
|
||||
STATUS_CONFIG[status] || {
|
||||
@@ -43,34 +231,6 @@ $(document).ready(() => {
|
||||
);
|
||||
}
|
||||
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const bookId = pathParts[pathParts.length - 1];
|
||||
let currentBook = null;
|
||||
|
||||
if (!bookId || isNaN(bookId)) {
|
||||
Utils.showToast("Некорректный ID книги", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
Api.get(`/api/books/${bookId}`)
|
||||
.then((book) => {
|
||||
currentBook = book;
|
||||
document.title = `LiB - ${book.title}`;
|
||||
renderBook(book);
|
||||
if (window.canManage()) {
|
||||
$("#edit-book-btn")
|
||||
.attr("href", `/book/${book.id}/edit`)
|
||||
.removeClass("hidden");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Книга не найдена", "error");
|
||||
$("#book-loader").html(
|
||||
'<p class="text-center text-red-500 w-full p-4">Ошибка загрузки</p>',
|
||||
);
|
||||
});
|
||||
|
||||
function renderBook(book) {
|
||||
$("#book-title").text(book.title);
|
||||
$("#book-id").text(`ID: ${book.id}`);
|
||||
@@ -81,8 +241,10 @@ $(document).ready(() => {
|
||||
|
||||
renderStatusWidget(book);
|
||||
|
||||
if (!window.canManage && book.status === "active") {
|
||||
if (!window.canManage() && book.status === "active") {
|
||||
renderReserveButton();
|
||||
} else {
|
||||
$("#book-actions-container").empty();
|
||||
}
|
||||
|
||||
if (book.genres && book.genres.length > 0) {
|
||||
@@ -91,10 +253,10 @@ $(document).ready(() => {
|
||||
$genres.empty();
|
||||
book.genres.forEach((g) => {
|
||||
$genres.append(`
|
||||
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
|
||||
${Utils.escapeHtml(g.name)}
|
||||
</a>
|
||||
`);
|
||||
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
|
||||
${Utils.escapeHtml(g.name)}
|
||||
</a>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,13 +266,13 @@ $(document).ready(() => {
|
||||
$authors.empty();
|
||||
book.authors.forEach((a) => {
|
||||
$authors.append(`
|
||||
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
|
||||
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
|
||||
${a.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
|
||||
</a>
|
||||
`);
|
||||
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
|
||||
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
|
||||
${a.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
|
||||
</a>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,86 +287,96 @@ $(document).ready(() => {
|
||||
|
||||
if (window.canManage()) {
|
||||
const $dropdownHTML = $(`
|
||||
<div class="relative inline-block text-left w-full md:w-auto">
|
||||
<button id="status-toggle-btn" type="button" class="w-full justify-center md:w-auto inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition-all shadow-sm ${config.bgClass} ${config.textClass} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400">
|
||||
${config.icon}
|
||||
<span class="ml-2">${config.label}</span>
|
||||
<svg class="ml-2 -mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="relative inline-block text-left w-full md:w-auto">
|
||||
<button id="status-toggle-btn" type="button" class="w-full justify-center md:w-auto inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition-all shadow-sm ${config.bgClass} ${config.textClass} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400">
|
||||
${config.icon}
|
||||
<span class="ml-2">${config.label}</span>
|
||||
<svg class="ml-2 -mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div id="status-menu" class="hidden absolute left-0 md:left-1/2 md:-translate-x-1/2 mt-2 w-56 rounded-xl shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none overflow-hidden z-20">
|
||||
<div class="py-1" role="menu">
|
||||
${Object.entries(STATUS_CONFIG)
|
||||
.map(
|
||||
([key, conf]) => `
|
||||
<button class="status-option w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center gap-2 ${book.status === key ? "bg-gray-50 font-medium" : "text-gray-700"}"
|
||||
data-status="${key}">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${conf.bgClass} ${conf.textClass}">
|
||||
${conf.icon}
|
||||
</span>
|
||||
<span>${conf.label}</span>
|
||||
${book.status === key ? '<svg class="ml-auto h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""}
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div id="status-menu" class="hidden absolute left-0 md:left-1/2 md:-translate-x-1/2 mt-2 w-56 rounded-xl shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none overflow-hidden z-20">
|
||||
<div class="py-1" role="menu">
|
||||
${Object.entries(STATUS_CONFIG)
|
||||
.map(([key, conf]) => {
|
||||
const isCurrent = book.status === key;
|
||||
return `
|
||||
<button class="status-option w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center gap-2 ${isCurrent ? "bg-gray-50 font-medium" : "text-gray-700"}"
|
||||
data-status="${key}">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${conf.bgClass} ${conf.textClass}">
|
||||
${conf.icon}
|
||||
</span>
|
||||
<span>${conf.label}</span>
|
||||
${isCurrent ? '<svg class="ml-auto h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""}
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.append($dropdownHTML);
|
||||
|
||||
const $toggleBtn = $("#status-toggle-btn");
|
||||
const $menu = $("#status-menu");
|
||||
|
||||
$toggleBtn.on("click", (e) => {
|
||||
$("#status-toggle-btn").on("click", (e) => {
|
||||
e.stopPropagation();
|
||||
$menu.toggleClass("hidden");
|
||||
});
|
||||
|
||||
$(document).on("click", (e) => {
|
||||
if (
|
||||
!$toggleBtn.is(e.target) &&
|
||||
$toggleBtn.has(e.target).length === 0 &&
|
||||
!$menu.has(e.target).length
|
||||
) {
|
||||
$menu.addClass("hidden");
|
||||
}
|
||||
$("#status-menu").toggleClass("hidden");
|
||||
});
|
||||
|
||||
$(".status-option").on("click", function () {
|
||||
const newStatus = $(this).data("status");
|
||||
if (newStatus !== currentBook.status) {
|
||||
$("#status-menu").addClass("hidden");
|
||||
|
||||
if (newStatus === currentBook.status) return;
|
||||
|
||||
if (newStatus === "borrowed") {
|
||||
openLoanModal();
|
||||
} else {
|
||||
updateBookStatus(newStatus);
|
||||
}
|
||||
$menu.addClass("hidden");
|
||||
});
|
||||
} else {
|
||||
$container.append(`
|
||||
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium ${config.bgClass} ${config.textClass} shadow-sm">
|
||||
${config.icon}
|
||||
${config.label}
|
||||
</span>
|
||||
`);
|
||||
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium ${config.bgClass} ${config.textClass} shadow-sm">
|
||||
${config.icon}
|
||||
${config.label}
|
||||
</span>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReserveButton() {
|
||||
const $container = $("#book-actions-container");
|
||||
$container.html(`
|
||||
<button id="reserve-btn" class="w-full flex items-center justify-center px-4 py-2.5 bg-gray-800 text-white font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all shadow-sm">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Зарезервировать
|
||||
</button>
|
||||
`);
|
||||
<button id="reserve-btn" class="w-full flex items-center justify-center px-4 py-2.5 bg-gray-800 text-white font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all shadow-sm">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Зарезервировать
|
||||
</button>
|
||||
`);
|
||||
|
||||
$("#reserve-btn").on("click", function () {
|
||||
Utils.showToast("Функция бронирования в разработке", "info");
|
||||
const user = window.getUser();
|
||||
if (!user) {
|
||||
Utils.showToast("Необходима авторизация", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
Api.post("/api/loans/", {
|
||||
book_id: currentBook.id,
|
||||
user_id: user.id,
|
||||
due_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
.then((loan) => {
|
||||
Utils.showToast("Книга забронирована", "success");
|
||||
loadBookData();
|
||||
})
|
||||
.catch((err) => {
|
||||
Utils.showToast(err.message || "Ошибка бронирования", "error");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,14 +385,12 @@ $(document).ready(() => {
|
||||
const originalContent = $toggleBtn.html();
|
||||
|
||||
$toggleBtn.prop("disabled", true).addClass("opacity-75").html(`
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
Обновление...
|
||||
`);
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
Обновление...
|
||||
`);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title: currentBook.title,
|
||||
description: currentBook.description,
|
||||
status: newStatus,
|
||||
};
|
||||
|
||||
@@ -229,17 +399,155 @@ $(document).ready(() => {
|
||||
payload,
|
||||
);
|
||||
currentBook = updatedBook;
|
||||
|
||||
Utils.showToast("Статус успешно изменен", "success");
|
||||
|
||||
renderStatusWidget(updatedBook);
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка при смене статуса", "error");
|
||||
Utils.showToast(error.message || "Ошибка при смене статуса", "error");
|
||||
$toggleBtn
|
||||
.prop("disabled", false)
|
||||
.removeClass("opacity-75")
|
||||
.html(originalContent);
|
||||
}
|
||||
}
|
||||
|
||||
function openLoanModal() {
|
||||
$("#loan-modal").removeClass("hidden");
|
||||
$("#user-search-input").val("")[0].focus();
|
||||
$("#users-list-container").html(
|
||||
'<div class="p-4 text-center text-gray-500 text-sm">Загрузка списка пользователей...</div>',
|
||||
);
|
||||
$("#confirm-loan-btn").prop("disabled", true);
|
||||
selectedLoanUserId = null;
|
||||
|
||||
fetchUsers();
|
||||
}
|
||||
|
||||
function closeLoanModal() {
|
||||
$("#loan-modal").addClass("hidden");
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
if (cachedUsers) {
|
||||
renderUsersList(cachedUsers);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await Api.get("/api/auth/users?skip=0&limit=500");
|
||||
cachedUsers = data.users;
|
||||
renderUsersList(cachedUsers);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users", error);
|
||||
$("#users-list-container").html(
|
||||
'<div class="p-4 text-center text-red-500 text-sm">Ошибка загрузки пользователей</div>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsersList(users) {
|
||||
const $container = $("#users-list-container");
|
||||
$container.empty();
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
$container.html(
|
||||
'<div class="p-4 text-center text-gray-500 text-sm">Пользователи не найдены</div>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach((user) => {
|
||||
const roleBadges = user.roles
|
||||
.map((r) => {
|
||||
const color =
|
||||
r === "admin"
|
||||
? "bg-purple-100 text-purple-800"
|
||||
: r === "librarian"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-800";
|
||||
return `<span class="text-xs px-2 py-0.5 rounded-full ${color} mr-1">${r}</span>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const $item = $(`
|
||||
<div class="user-item p-3 hover:bg-blue-50 cursor-pointer transition-colors flex items-center justify-between group" data-id="${user.id}">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">${Utils.escapeHtml(user.full_name || user.username)}</div>
|
||||
<div class="text-xs text-gray-500">@${Utils.escapeHtml(user.username)} • ${Utils.escapeHtml(user.email)}</div>
|
||||
</div>
|
||||
<div>${roleBadges}</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$item.on("click", function () {
|
||||
$(".user-item").removeClass("bg-blue-100 border-l-4 border-blue-500");
|
||||
$(this).addClass("bg-blue-100 border-l-4 border-blue-500");
|
||||
selectedLoanUserId = user.id;
|
||||
$("#confirm-loan-btn")
|
||||
.prop("disabled", false)
|
||||
.text(`Выдать для ${user.username}`);
|
||||
});
|
||||
|
||||
$container.append($item);
|
||||
});
|
||||
}
|
||||
|
||||
function handleUserSearch() {
|
||||
const query = $(this).val().toLowerCase();
|
||||
if (!cachedUsers) return;
|
||||
|
||||
if (!query) {
|
||||
renderUsersList(cachedUsers);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = cachedUsers.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(query) ||
|
||||
(u.full_name && u.full_name.toLowerCase().includes(query)) ||
|
||||
u.email.toLowerCase().includes(query),
|
||||
);
|
||||
renderUsersList(filtered);
|
||||
}
|
||||
|
||||
async function submitLoan() {
|
||||
if (!selectedLoanUserId) return;
|
||||
const dueDate = $("#loan-due-date").val();
|
||||
|
||||
if (!dueDate) {
|
||||
Utils.showToast("Выберите дату возврата", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const $btn = $("#confirm-loan-btn");
|
||||
const originalText = $btn.text();
|
||||
$btn.prop("disabled", true).text("Обработка...");
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
book_id: currentBook.id,
|
||||
user_id: selectedLoanUserId,
|
||||
due_date: new Date(dueDate).toISOString(),
|
||||
};
|
||||
|
||||
// Используем прямой эндпоинт выдачи для администраторов
|
||||
if (window.isAdmin()) {
|
||||
await Api.post("/api/loans/issue", payload);
|
||||
} else {
|
||||
// Для библиотекарей создаем бронь, которую потом нужно подтвердить
|
||||
await Api.post("/api/loans/", payload);
|
||||
}
|
||||
|
||||
Utils.showToast("Книга успешно выдана", "success");
|
||||
closeLoanModal();
|
||||
loadBookData();
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка выдачи", "error");
|
||||
} finally {
|
||||
$btn.prop("disabled", false).text(originalText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
$(document).ready(() => {
|
||||
let allLoans = [];
|
||||
let booksCache = new Map();
|
||||
|
||||
init();
|
||||
|
||||
function init() {
|
||||
const user = window.getUser();
|
||||
if (!user) {
|
||||
Utils.showToast("Необходима авторизация", "error");
|
||||
window.location.href = "/auth";
|
||||
return;
|
||||
}
|
||||
loadLoans();
|
||||
}
|
||||
|
||||
async function loadLoans() {
|
||||
try {
|
||||
const data = await Api.get("/api/loans/?page=1&size=100");
|
||||
allLoans = data.loans;
|
||||
|
||||
// Загружаем информацию о книгах
|
||||
const bookIds = [...new Set(allLoans.map(loan => loan.book_id))];
|
||||
await loadBooks(bookIds);
|
||||
|
||||
renderLoans();
|
||||
} catch (error) {
|
||||
console.error("Failed to load loans", error);
|
||||
Utils.showToast("Ошибка загрузки выдач", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBooks(bookIds) {
|
||||
const promises = bookIds.map(async (bookId) => {
|
||||
if (!booksCache.has(bookId)) {
|
||||
try {
|
||||
const book = await Api.get(`/api/books/${bookId}`);
|
||||
booksCache.set(bookId, book);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load book ${bookId}`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
function renderLoans() {
|
||||
const reservations = allLoans.filter(
|
||||
loan => !loan.returned_at && getBookStatus(loan.book_id) === "reserved"
|
||||
);
|
||||
const activeLoans = allLoans.filter(
|
||||
loan => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed"
|
||||
);
|
||||
const returned = allLoans.filter(loan => loan.returned_at !== null);
|
||||
|
||||
renderReservations(reservations);
|
||||
renderActiveLoans(activeLoans);
|
||||
renderReturned(returned);
|
||||
}
|
||||
|
||||
function getBookStatus(bookId) {
|
||||
const book = booksCache.get(bookId);
|
||||
return book ? book.status : null;
|
||||
}
|
||||
|
||||
function renderReservations(reservations) {
|
||||
const $container = $("#reservations-container");
|
||||
$("#reservations-count").text(reservations.length);
|
||||
$container.empty();
|
||||
|
||||
if (reservations.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reservations.forEach((loan) => {
|
||||
const book = booksCache.get(loan.book_id);
|
||||
if (!book) return;
|
||||
|
||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
|
||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||
|
||||
const $card = $(`
|
||||
<div class="border border-blue-200 rounded-lg p-4 bg-blue-50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
|
||||
${Utils.escapeHtml(book.title)}
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
|
||||
</p>
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
||||
<p><span class="font-medium">Дата бронирования:</span> ${borrowedDate}</p>
|
||||
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
|
||||
Забронирована
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="cancel-reservation-btn ml-4 px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors"
|
||||
data-loan-id="${loan.id}"
|
||||
data-book-id="${book.id}"
|
||||
>
|
||||
Отменить бронь
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$card.find(".cancel-reservation-btn").on("click", function () {
|
||||
const loanId = $(this).data("loan-id");
|
||||
const bookId = $(this).data("book-id");
|
||||
cancelReservation(loanId, bookId);
|
||||
});
|
||||
|
||||
$container.append($card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderActiveLoans(activeLoans) {
|
||||
const $container = $("#active-loans-container");
|
||||
$("#active-loans-count").text(activeLoans.length);
|
||||
$container.empty();
|
||||
|
||||
if (activeLoans.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
activeLoans.forEach((loan) => {
|
||||
const book = booksCache.get(loan.book_id);
|
||||
if (!book) return;
|
||||
|
||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
|
||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||
const isOverdue = new Date(loan.due_date) < new Date();
|
||||
|
||||
const $card = $(`
|
||||
<div class="border ${isOverdue ? "border-red-300 bg-red-50" : "border-yellow-200 bg-yellow-50"} rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-yellow-600 transition-colors">
|
||||
${Utils.escapeHtml(book.title)}
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
|
||||
</p>
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
||||
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
|
||||
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
|
||||
Выдана
|
||||
</span>
|
||||
${isOverdue ? '<span class="inline-flex items-center px-2 py-1 bg-red-100 text-red-800 rounded-full text-xs font-medium">Просрочена</span>' : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.append($card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderReturned(returned) {
|
||||
const $container = $("#returned-container");
|
||||
$("#returned-count").text(returned.length);
|
||||
$container.empty();
|
||||
|
||||
if (returned.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
returned.forEach((loan) => {
|
||||
const book = booksCache.get(loan.book_id);
|
||||
if (!book) return;
|
||||
|
||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
|
||||
const returnedDate = new Date(loan.returned_at).toLocaleDateString("ru-RU");
|
||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||
|
||||
const $card = $(`
|
||||
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-gray-600 transition-colors">
|
||||
${Utils.escapeHtml(book.title)}
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
|
||||
</p>
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
||||
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
|
||||
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
|
||||
<p><span class="font-medium">Дата возврата:</span> ${returnedDate}</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-gray-100 text-gray-800 rounded-full text-xs font-medium">
|
||||
Возвращена
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.append($card);
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelReservation(loanId, bookId) {
|
||||
if (!confirm("Вы уверены, что хотите отменить бронирование?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Api.delete(`/api/loans/${loanId}`);
|
||||
Utils.showToast("Бронирование отменено", "success");
|
||||
|
||||
// Удаляем из кэша и перезагружаем
|
||||
allLoans = allLoans.filter(loan => loan.id !== loanId);
|
||||
const book = booksCache.get(bookId);
|
||||
if (book) {
|
||||
book.status = "active";
|
||||
booksCache.set(bookId, book);
|
||||
}
|
||||
|
||||
renderLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка отмены бронирования", "error");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %} {% block title %}Аналитика - LiB{% endblock %} {% block content %}
|
||||
<div class="container mx-auto p-4 max-w-7xl">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
|
||||
<p class="text-sm text-gray-500">Статистика и графики по выдачам книг</p>
|
||||
</div>
|
||||
|
||||
<!-- Период анализа -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-4 mb-6 border border-gray-100">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
|
||||
<select id="period-select" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 transition bg-white">
|
||||
<option value="7">7 дней</option>
|
||||
<option value="30" selected>30 дней</option>
|
||||
<option value="90">90 дней</option>
|
||||
<option value="180">180 дней</option>
|
||||
<option value="365">365 дней</option>
|
||||
</select>
|
||||
<button id="refresh-btn" class="px-3 py-1.5 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm font-medium">
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Общая статистика -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Всего выдач</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" id="total-loans">—</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Активные выдачи</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" id="active-loans">—</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Возвращено</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" id="returned-loans">—</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Просрочено</p>
|
||||
<p class="text-2xl font-semibold text-red-600" id="overdue-loans">—</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-red-50 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Забронировано</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" id="reserved-books">—</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Выдано сейчас</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" id="borrowed-books">—</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Графики -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<h2 class="text-base font-medium text-gray-700 mb-6">Выдачи по дням</h2>
|
||||
<div class="h-64">
|
||||
<canvas id="loans-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<h2 class="text-base font-medium text-gray-700 mb-6">Возвраты по дням</h2>
|
||||
<div class="h-64">
|
||||
<canvas id="returns-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Топ книг -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<h2 class="text-base font-medium text-gray-700 mb-6">Топ книг по выдачам</h2>
|
||||
<div id="top-books-container" class="space-y-2">
|
||||
<div class="text-center text-gray-500 py-8">Загрузка данных...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/analytics.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -162,25 +162,46 @@
|
||||
<template
|
||||
x-if="user.roles && user.roles.includes('admin')"
|
||||
>
|
||||
<a
|
||||
href="/users"
|
||||
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-3 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<div>
|
||||
<a
|
||||
href="/users"
|
||||
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path>
|
||||
</svg>
|
||||
Пользователи
|
||||
</a>
|
||||
<svg
|
||||
class="w-4 h-4 mr-3 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path>
|
||||
</svg>
|
||||
Пользователи
|
||||
</a>
|
||||
<a
|
||||
href="/analytics"
|
||||
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-3 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
></path>
|
||||
</svg>
|
||||
Аналитика
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<div class="border-t border-gray-200">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<div class="container mx-auto p-4 max-w-4xl">
|
||||
<div id="book-card" class="bg-white rounded-lg shadow-md p-6">
|
||||
<div class="container mx-auto p-4 max-w-6xl">
|
||||
<div id="book-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<a
|
||||
href="/books"
|
||||
@@ -84,10 +84,11 @@
|
||||
</div>
|
||||
<div
|
||||
id="book-status-container"
|
||||
class="relative w-full flex justify-center z-10"
|
||||
class="relative w-full flex justify-center z-10 mb-4"
|
||||
></div>
|
||||
<div id="book-actions-container" class="mt-4 w-full"></div>
|
||||
<div id="book-actions-container" class="w-full"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 w-full">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
|
||||
@@ -105,7 +106,6 @@
|
||||
id="book-authors-text"
|
||||
class="text-lg text-gray-600 font-medium mb-6"
|
||||
></p>
|
||||
|
||||
<div class="prose prose-gray max-w-none mb-8">
|
||||
<h3
|
||||
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
|
||||
@@ -117,7 +117,6 @@
|
||||
class="text-gray-700 leading-relaxed"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<div id="genres-section" class="mb-6 hidden">
|
||||
<h3
|
||||
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
|
||||
@@ -129,7 +128,6 @@
|
||||
class="flex flex-wrap gap-2"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div id="authors-section" class="mb-6 hidden">
|
||||
<h3
|
||||
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
|
||||
@@ -144,6 +142,158 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Секция выдачи для библиотекарей и администраторов -->
|
||||
<div id="loans-section" class="hidden bg-white rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900">Выдачи книги</h2>
|
||||
<button
|
||||
id="refresh-loans-btn"
|
||||
class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Обновить список выдач"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="loans-container" class="space-y-3">
|
||||
<div class="text-center text-gray-500 py-8">
|
||||
Загрузка информации о выдачах...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для выдачи книги -->
|
||||
<div
|
||||
id="loan-modal"
|
||||
class="hidden fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<span
|
||||
class="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>​</span
|
||||
>
|
||||
<div
|
||||
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
|
||||
>
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div
|
||||
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full"
|
||||
>
|
||||
<h3
|
||||
class="text-lg leading-6 font-medium text-gray-900"
|
||||
id="modal-title"
|
||||
>
|
||||
Оформить выдачу книги
|
||||
</h3>
|
||||
<div class="mt-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="user-search-input"
|
||||
class="w-full border border-gray-300 rounded-md px-4 py-2 pl-10 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Поиск пользователя (имя, email)..."
|
||||
/>
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-400 absolute left-3 top-2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
id="users-list-container"
|
||||
class="mt-4 border border-gray-200 rounded-md max-h-60 overflow-y-auto divide-y divide-gray-100"
|
||||
>
|
||||
<div
|
||||
class="p-4 text-center text-gray-500 text-sm"
|
||||
>
|
||||
Начните ввод для поиска...
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Срок возврата</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
id="loan-due-date"
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="confirm-loan-btn"
|
||||
disabled
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-base font-medium text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Выдать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="cancel-loan-btn"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/book.js"></script>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<div class="container mx-auto p-4 max-w-6xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
|
||||
<p class="text-gray-600">Управление вашими бронированиями и выдачами</p>
|
||||
</div>
|
||||
|
||||
<!-- Бронирования -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900">Мои бронирования</h2>
|
||||
<span id="reservations-count" class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">0</span>
|
||||
</div>
|
||||
<div id="reservations-container" class="space-y-3">
|
||||
<div class="text-center text-gray-500 py-8">
|
||||
Загрузка бронирований...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Активные выдачи -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900">Активные выдачи</h2>
|
||||
<span id="active-loans-count" class="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-sm font-medium">0</span>
|
||||
</div>
|
||||
<div id="active-loans-container" class="space-y-3">
|
||||
<div class="text-center text-gray-500 py-8">
|
||||
Загрузка активных выдач...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Возвращенные книги -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900">История возвратов</h2>
|
||||
<span id="returned-count" class="px-3 py-1 bg-gray-100 text-gray-800 rounded-full text-sm font-medium">0</span>
|
||||
</div>
|
||||
<div id="returned-container" class="space-y-3">
|
||||
<div class="text-center text-gray-500 py-8">
|
||||
Загрузка истории...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/my_books.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user