Добавление аналитики

This commit is contained in:
2025-12-24 06:50:44 +03:00
parent 82d298effe
commit 5a814d99e6
21 changed files with 2170 additions and 312 deletions
+4
View File
@@ -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")
+12 -13
View File
@@ -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()
+10 -9
View File
@@ -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")
+51 -17
View File
@@ -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")
+12 -11
View File
@@ -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")
+506
View File
@@ -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())
+32 -20
View File
@@ -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)
+17 -17
View File
@@ -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)