mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Улучшение безопасности
This commit is contained in:
+231
-27
@@ -2,9 +2,11 @@
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
import pyotp
|
||||
|
||||
@@ -17,7 +19,19 @@ from library_service.models.dto import (
|
||||
UserList,
|
||||
RoleRead,
|
||||
RoleList,
|
||||
Token,
|
||||
PartialToken,
|
||||
LoginResponse,
|
||||
RecoveryCodeUse,
|
||||
RegisterResponse,
|
||||
RecoveryCodesStatus,
|
||||
RecoveryCodesResponse,
|
||||
PasswordResetResponse,
|
||||
TOTPSetupResponse,
|
||||
TOTPVerifyRequest,
|
||||
TOTPDisableRequest,
|
||||
)
|
||||
|
||||
from library_service.settings import get_session
|
||||
from library_service.auth import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
@@ -29,10 +43,18 @@ from library_service.auth import (
|
||||
decode_token,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
generate_totp_setup,
|
||||
generate_codes_for_user,
|
||||
verify_and_use_code,
|
||||
get_codes_status,
|
||||
verify_totp_code,
|
||||
verify_password,
|
||||
qr_to_bitmap_b64,
|
||||
create_partial_token,
|
||||
RequirePartialAuth,
|
||||
verify_and_use_code,
|
||||
)
|
||||
from pathlib import Path
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
@@ -40,10 +62,10 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=UserRead,
|
||||
response_model=RegisterResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Регистрация нового пользователя",
|
||||
description="Создает нового пользователя в системе",
|
||||
description="Создает нового пользователя и возвращает резервные коды",
|
||||
)
|
||||
def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
"""Регистрирует нового пользователя в системе"""
|
||||
@@ -61,7 +83,8 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
db_user = User(
|
||||
@@ -77,14 +100,25 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
session.commit()
|
||||
session.refresh(db_user)
|
||||
|
||||
return UserRead(**db_user.model_dump(), roles=[role.name for role in db_user.roles])
|
||||
recovery_codes = generate_codes_for_user(session, db_user)
|
||||
|
||||
return RegisterResponse(
|
||||
user=UserRead(
|
||||
**db_user.model_dump(),
|
||||
roles=[role.name for role in db_user.roles],
|
||||
),
|
||||
recovery_codes=RecoveryCodesResponse(
|
||||
codes=recovery_codes,
|
||||
generated_at=db_user.recovery_codes_generated_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/token",
|
||||
response_model=Token,
|
||||
response_model=LoginResponse,
|
||||
summary="Получение токена",
|
||||
description="Аутентификация и получение JWT токена",
|
||||
description="Аутентификация и получение токенов",
|
||||
)
|
||||
def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
@@ -99,17 +133,23 @@ def login(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username, "user_id": user.id},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
data={"sub": user.username, "user_id": user.id}
|
||||
)
|
||||
token_data = {"sub": user.username, "user_id": user.id}
|
||||
|
||||
return Token(
|
||||
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
|
||||
if user.is_2fa_enabled:
|
||||
return LoginResponse(
|
||||
partial_token=create_partial_token(token_data),
|
||||
token_type="partial",
|
||||
requires_2fa=True,
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return LoginResponse(
|
||||
access_token=create_access_token(
|
||||
data=token_data, expires_delta=access_token_expires
|
||||
),
|
||||
refresh_token=create_refresh_token(data=token_data),
|
||||
token_type="bearer",
|
||||
requires_2fa=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -330,18 +370,182 @@ def get_roles(
|
||||
|
||||
@router.get(
|
||||
"/2fa",
|
||||
response_model=TOTPSetupResponse,
|
||||
summary="Создание QR-кода TOTP 2FA",
|
||||
description="Получить информацию о текущем авторизованном пользователе",
|
||||
description="Генерирует секрет и QR-код для настройки TOTP",
|
||||
)
|
||||
def get_totp_qr_bitmap(auth: RequireAuth):
|
||||
"""Возвращает qr-код bitmap"""
|
||||
issuer = "issuer"
|
||||
username = auth.username
|
||||
secret = pyotp.random_base32()
|
||||
"""Возвращает данные для настройки TOTP"""
|
||||
return TOTPSetupResponse(**generate_totp_setup(auth.username))
|
||||
|
||||
totp = pyotp.TOTP(secret)
|
||||
provisioning_uri = totp.provisioning_uri(name=username, issuer_name=issuer)
|
||||
|
||||
bitmap_data = qr_to_bitmap_b64(provisioning_uri)
|
||||
@router.post(
|
||||
"/2fa/enable",
|
||||
summary="Включение TOTP 2FA",
|
||||
description="Подтверждает настройку и включает 2FA",
|
||||
)
|
||||
def enable_2fa(
|
||||
data: TOTPVerifyRequest,
|
||||
current_user: RequireAuth,
|
||||
secret: str = Body(..., embed=True),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Включает 2FA после проверки кода"""
|
||||
if current_user.is_2fa_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA already enabled",
|
||||
)
|
||||
|
||||
return {"secret": secret, "username": username, "issuer": issuer, **bitmap_data}
|
||||
if not verify_totp_code(secret, data.code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid TOTP code",
|
||||
)
|
||||
|
||||
current_user.totp_secret = secret
|
||||
current_user.is_2fa_enabled = True
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/2fa/disable",
|
||||
summary="Отключение TOTP 2FA",
|
||||
description="Отключает 2FA после проверки пароля и кода",
|
||||
)
|
||||
def disable_2fa(
|
||||
data: TOTPDisableRequest,
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Отключает 2FA"""
|
||||
if not current_user.is_2fa_enabled or not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA not enabled",
|
||||
)
|
||||
|
||||
if not verify_password(data.password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid password",
|
||||
)
|
||||
|
||||
current_user.totp_secret = None
|
||||
current_user.is_2fa_enabled = False
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/2fa/verify",
|
||||
response_model=Token,
|
||||
summary="Верификация 2FA",
|
||||
description="Завершает аутентификацию с помощью TOTP кода или резервного кода",
|
||||
)
|
||||
def verify_2fa(
|
||||
data: TOTPVerifyRequest,
|
||||
user: RequirePartialAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Верифицирует 2FA и возвращает полный токен"""
|
||||
if not data.code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Provide TOTP code",
|
||||
)
|
||||
|
||||
verified = False
|
||||
|
||||
if data.code and user.totp_secret:
|
||||
if verify_totp_code(user.totp_secret, data.code):
|
||||
verified = True
|
||||
|
||||
if not verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid 2FA code",
|
||||
)
|
||||
|
||||
token_data = {"sub": user.username, "user_id": user.id}
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
return Token(
|
||||
access_token=create_access_token(
|
||||
data=token_data, expires_delta=access_token_expires
|
||||
),
|
||||
refresh_token=create_refresh_token(data=token_data),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recovery-codes/status",
|
||||
response_model=RecoveryCodesStatus,
|
||||
summary="Статус резервных кодов",
|
||||
description="Показывает количество оставшихся кодов и какие использованы",
|
||||
)
|
||||
def get_recovery_codes_status(current_user: RequireAuth):
|
||||
"""Возвращает статус резервных кодов"""
|
||||
return RecoveryCodesStatus(**get_codes_status(current_user))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/recovery-codes/regenerate",
|
||||
response_model=RecoveryCodesResponse,
|
||||
summary="Перегенерация резервных кодов",
|
||||
description="Генерирует новые коды, старые аннулируются",
|
||||
)
|
||||
def regenerate_recovery_codes(
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Генерирует новые резервные коды"""
|
||||
codes = generate_codes_for_user(session, current_user)
|
||||
|
||||
return RecoveryCodesResponse(
|
||||
codes=codes,
|
||||
generated_at=current_user.recovery_codes_generated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/password/reset",
|
||||
response_model=PasswordResetResponse,
|
||||
summary="Сброс пароля через резервный код",
|
||||
description="Устанавливает новый пароль используя резервный код",
|
||||
)
|
||||
def reset_password(
|
||||
data: RecoveryCodeUse,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Сброс пароля с использованием резервного кода"""
|
||||
user = session.exec(select(User).where(User.username == data.username)).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid username or recovery code",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is deactivated",
|
||||
)
|
||||
|
||||
if not verify_and_use_code(session, user, data.recovery_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid username or recovery code",
|
||||
)
|
||||
|
||||
user.hashed_password = get_password_hash(data.new_password)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
return PasswordResetResponse(**get_codes_status(user))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Модуль работы с книгами"""
|
||||
from datetime import datetime
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
@@ -8,11 +9,25 @@ from sqlmodel import Session, select, col, func
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.settings import get_session
|
||||
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.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,
|
||||
BookFilteredList
|
||||
BookFilteredList,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,7 +43,7 @@ def close_active_loan(session: Session, book_id: int) -> None:
|
||||
).first()
|
||||
|
||||
if active_loan:
|
||||
active_loan.returned_at = datetime.utcnow()
|
||||
active_loan.returned_at = datetime.now(timezone.utc)
|
||||
session.add(active_loan)
|
||||
|
||||
|
||||
@@ -36,7 +51,7 @@ def close_active_loan(session: Session, book_id: int) -> None:
|
||||
"/filter",
|
||||
response_model=BookFilteredList,
|
||||
summary="Фильтрация книг",
|
||||
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией"
|
||||
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией",
|
||||
)
|
||||
def filter_books(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -55,10 +70,14 @@ def filter_books(
|
||||
)
|
||||
|
||||
if author_ids:
|
||||
statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids))
|
||||
statement = statement.join(AuthorBookLink).where(
|
||||
AuthorBookLink.author_id.in_(author_ids)
|
||||
) # ty: ignore[unresolved-attribute, unresolved-reference]
|
||||
|
||||
if genre_ids:
|
||||
statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids))
|
||||
statement = statement.join(GenreBookLink).where(
|
||||
GenreBookLink.genre_id.in_(genre_ids)
|
||||
) # ty: ignore[unresolved-attribute, unresolved-reference]
|
||||
|
||||
total_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = session.exec(total_statement).one()
|
||||
@@ -73,7 +92,7 @@ def filter_books(
|
||||
BookWithAuthorsAndGenres(
|
||||
**db_book.model_dump(),
|
||||
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
|
||||
genres=[GenreRead(**g.model_dump()) for g in db_book.genres]
|
||||
genres=[GenreRead(**g.model_dump()) for g in db_book.genres],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -89,7 +108,7 @@ def filter_books(
|
||||
def create_book(
|
||||
book: BookCreate,
|
||||
current_user: RequireStaff,
|
||||
session: Session = Depends(get_session)
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Создает новую книгу в системе"""
|
||||
db_book = Book(**book.model_dump())
|
||||
@@ -168,7 +187,7 @@ def update_book(
|
||||
if book_update.status == BookStatus.BORROWED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Статус 'borrowed' устанавливается только через выдачу книги"
|
||||
detail="Статус 'borrowed' устанавливается только через выдачу книги",
|
||||
)
|
||||
|
||||
if db_book.status == BookStatus.BORROWED:
|
||||
@@ -205,7 +224,10 @@ def delete_book(
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
book_read = BookRead(
|
||||
id=(book.id or 0), title=book.title, description=book.description, status=book.status
|
||||
id=(book.id or 0),
|
||||
title=book.title,
|
||||
description=book.description,
|
||||
status=book.status,
|
||||
)
|
||||
session.delete(book)
|
||||
session.commit()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Модуль работы с выдачей и бронированием книг"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
@@ -34,7 +35,7 @@ def create_loan(
|
||||
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"
|
||||
detail="You can only create loans for yourself",
|
||||
)
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
@@ -44,7 +45,7 @@ def create_loan(
|
||||
if book.status != BookStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Book is not available for loan (status: {book.status})"
|
||||
detail=f"Book is not available for loan (status: {book.status})",
|
||||
)
|
||||
|
||||
target_user = session.get(User, loan.user_id)
|
||||
@@ -55,7 +56,7 @@ def create_loan(
|
||||
book_id=loan.book_id,
|
||||
user_id=loan.user_id,
|
||||
due_date=loan.due_date,
|
||||
borrowed_at=datetime.utcnow()
|
||||
borrowed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
book.status = BookStatus.RESERVED
|
||||
@@ -109,8 +110,7 @@ def read_loans(
|
||||
loans = session.exec(statement).all()
|
||||
|
||||
return LoanList(
|
||||
loans=[LoanRead(**loan.model_dump()) for loan in loans],
|
||||
total=total
|
||||
loans=[LoanRead(**loan.model_dump()) for loan in loans], total=total
|
||||
)
|
||||
|
||||
|
||||
@@ -125,11 +125,12 @@ def get_loans_analytics(
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Возвращает аналитику по выдачам и возвратам книг"""
|
||||
end_date = datetime.utcnow()
|
||||
end_date = datetime.now(timezone.utc)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
total_loans = session.exec(
|
||||
select(func.count(BookUserLink.id))
|
||||
.where(BookUserLink.borrowed_at >= start_date)
|
||||
select(func.count(BookUserLink.id)).where(
|
||||
BookUserLink.borrowed_at >= start_date
|
||||
)
|
||||
).one()
|
||||
|
||||
active_loans = session.exec(
|
||||
@@ -156,7 +157,7 @@ def get_loans_analytics(
|
||||
loans_by_date = session.exec(
|
||||
select(
|
||||
cast(BookUserLink.borrowed_at, Date).label("date"),
|
||||
func.count(BookUserLink.id).label("count")
|
||||
func.count(BookUserLink.id).label("count"),
|
||||
)
|
||||
.where(BookUserLink.borrowed_at >= start_date)
|
||||
.group_by(cast(BookUserLink.borrowed_at, Date))
|
||||
@@ -166,9 +167,11 @@ def get_loans_analytics(
|
||||
returns_by_date = session.exec(
|
||||
select(
|
||||
cast(BookUserLink.returned_at, Date).label("date"),
|
||||
func.count(BookUserLink.id).label("count")
|
||||
func.count(BookUserLink.id).label("count"),
|
||||
)
|
||||
.where(
|
||||
BookUserLink.returned_at >= start_date # ty: ignore[unsupported-operator]
|
||||
)
|
||||
.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))
|
||||
@@ -185,10 +188,7 @@ def get_loans_analytics(
|
||||
daily_returns[date_str] = count
|
||||
|
||||
top_books = session.exec(
|
||||
select(
|
||||
BookUserLink.book_id,
|
||||
func.count(BookUserLink.id).label("loan_count")
|
||||
)
|
||||
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())
|
||||
@@ -201,38 +201,36 @@ def get_loans_analytics(
|
||||
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
|
||||
})
|
||||
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)
|
||||
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)
|
||||
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(),
|
||||
})
|
||||
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(
|
||||
@@ -256,8 +254,7 @@ def get_loan(
|
||||
|
||||
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"
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this loan"
|
||||
)
|
||||
|
||||
return LoanRead(**loan.model_dump())
|
||||
@@ -285,7 +282,7 @@ def update_loan(
|
||||
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"
|
||||
detail="You can only update your own loans",
|
||||
)
|
||||
|
||||
book = session.get(Book, db_loan.book_id)
|
||||
@@ -296,7 +293,7 @@ def update_loan(
|
||||
if not is_staff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only staff can change loan user"
|
||||
detail="Only staff can change loan user",
|
||||
)
|
||||
new_user = session.get(User, loan_update.user_id)
|
||||
if not new_user:
|
||||
@@ -308,10 +305,7 @@ def update_loan(
|
||||
|
||||
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"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Loan is already returned")
|
||||
db_loan.returned_at = loan_update.returned_at
|
||||
book.status = BookStatus.ACTIVE
|
||||
|
||||
@@ -349,7 +343,7 @@ def confirm_loan(
|
||||
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}"
|
||||
detail=f"Cannot confirm loan for book with status: {book.status}",
|
||||
)
|
||||
|
||||
book.status = BookStatus.BORROWED
|
||||
@@ -381,7 +375,7 @@ def return_loan(
|
||||
if loan.returned_at:
|
||||
raise HTTPException(status_code=400, detail="Loan is already returned")
|
||||
|
||||
loan.returned_at = datetime.utcnow()
|
||||
loan.returned_at = datetime.now(timezone.utc)
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
if book:
|
||||
@@ -416,7 +410,7 @@ def delete_loan(
|
||||
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"
|
||||
detail="You can only delete your own loans",
|
||||
)
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
@@ -424,7 +418,7 @@ def delete_loan(
|
||||
if book and book.status != BookStatus.RESERVED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Can only delete reservations. Use update endpoint to return borrowed books"
|
||||
detail="Can only delete reservations. Use update endpoint to return borrowed books",
|
||||
)
|
||||
|
||||
loan_read = LoanRead(**loan.model_dump())
|
||||
@@ -481,8 +475,7 @@ def issue_book_directly(
|
||||
|
||||
if book.status != BookStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Book is not available (status: {book.status})"
|
||||
status_code=400, detail=f"Book is not available (status: {book.status})"
|
||||
)
|
||||
|
||||
target_user = session.get(User, loan.user_id)
|
||||
@@ -493,7 +486,7 @@ def issue_book_directly(
|
||||
book_id=loan.book_id,
|
||||
user_id=loan.user_id,
|
||||
due_date=loan.due_date,
|
||||
borrowed_at=datetime.utcnow()
|
||||
borrowed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
book.status = BookStatus.BORROWED
|
||||
|
||||
@@ -103,7 +103,7 @@ async def auth(request: Request):
|
||||
return templates.TemplateResponse(request, "auth.html")
|
||||
|
||||
|
||||
@router.get("/set-2fa", include_in_schema=False)
|
||||
@router.get("/2fa", include_in_schema=False)
|
||||
async def set2fa(request: Request):
|
||||
"""Рендерит страницу установки двухфакторной аутентификации"""
|
||||
return templates.TemplateResponse(request, "2fa.html")
|
||||
|
||||
Reference in New Issue
Block a user