Добавление catpcha при регистрации, фильтрация по количеству страниц

This commit is contained in:
2026-01-23 23:32:09 +03:00
parent 7c3074e8fe
commit c1ac0ca246
19 changed files with 1258 additions and 568 deletions
+11 -2
View File
@@ -1,4 +1,6 @@
"""Основной модуль""" """Основной модуль"""
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -7,12 +9,13 @@ from uuid import uuid4
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from fastapi import Request, Response from fastapi import FastAPI, Depends, Request, Response, status
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlmodel import Session from sqlmodel import Session
from library_service.auth import run_seeds from library_service.auth import run_seeds
from library_service.routers import api_router from library_service.routers import api_router
from library_service.services.captcha import limiter, cleanup_task, require_captcha
from library_service.settings import ( from library_service.settings import (
LOGGING_CONFIG, LOGGING_CONFIG,
engine, engine,
@@ -20,6 +23,7 @@ from library_service.settings import (
get_logger, get_logger,
) )
SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"}) SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"})
@@ -47,6 +51,7 @@ async def lifespan(_):
except Exception as e: except Exception as e:
logger.error(f"[-] Seeding failed: {e}") logger.error(f"[-] Seeding failed: {e}")
asyncio.create_task(cleanup_task())
logger.info("[+] Starting application...") logger.info("[+] Starting application...")
yield # Обработка запросов yield # Обработка запросов
logger.info("[+] Application shutdown") logger.info("[+] Application shutdown")
@@ -113,7 +118,10 @@ async def log_requests(request: Request, call_next):
}, },
exc_info=True, exc_info=True,
) )
return Response(status_code=500, content="Internal Server Error") return Response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content="Internal Server Error",
)
# Подключение маршрутов # Подключение маршрутов
@@ -127,6 +135,7 @@ app.mount(
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run( uvicorn.run(
"library_service.main:app", "library_service.main:app",
host="0.0.0.0", host="0.0.0.0",
+2
View File
@@ -8,6 +8,7 @@ from .books import router as books_router
from .genres import router as genres_router from .genres import router as genres_router
from .loans import router as loans_router from .loans import router as loans_router
from .relationships import router as relationships_router from .relationships import router as relationships_router
from .cap import router as cap_router
from .users import router as users_router from .users import router as users_router
from .misc import router as misc_router from .misc import router as misc_router
@@ -22,5 +23,6 @@ api_router.include_router(authors_router, prefix="/api")
api_router.include_router(books_router, prefix="/api") api_router.include_router(books_router, prefix="/api")
api_router.include_router(genres_router, prefix="/api") api_router.include_router(genres_router, prefix="/api")
api_router.include_router(loans_router, prefix="/api") api_router.include_router(loans_router, prefix="/api")
api_router.include_router(cap_router, prefix="/api")
api_router.include_router(users_router, prefix="/api") api_router.include_router(users_router, prefix="/api")
api_router.include_router(relationships_router, prefix="/api") api_router.include_router(relationships_router, prefix="/api")
+6 -1
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.services import require_captcha
from library_service.models.db import Role, User from library_service.models.db import Role, User
from library_service.models.dto import ( from library_service.models.dto import (
Token, Token,
@@ -63,7 +64,11 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
summary="Регистрация нового пользователя", summary="Регистрация нового пользователя",
description="Создает нового пользователя и возвращает резервные коды", description="Создает нового пользователя и возвращает резервные коды",
) )
def register(user_data: UserCreate, session: Session = Depends(get_session)): def register(
user_data: UserCreate,
_=Depends(require_captcha),
session: Session = Depends(get_session),
):
"""Регистрирует нового пользователя в системе""" """Регистрирует нового пользователя в системе"""
existing_user = session.exec( existing_user = session.exec(
select(User).where(User.username == user_data.username) select(User).where(User.username == user_data.username)
+19 -6
View File
@@ -1,12 +1,19 @@
"""Модуль работы с авторами""" """Модуль работы с авторами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireStaff from library_service.auth import RequireStaff
from library_service.settings import get_session from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import (BookRead, AuthorWithBooks, from library_service.models.dto import (
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate) BookRead,
AuthorWithBooks,
AuthorCreate,
AuthorList,
AuthorRead,
AuthorUpdate,
)
router = APIRouter(prefix="/authors", tags=["authors"]) router = APIRouter(prefix="/authors", tags=["authors"])
@@ -59,7 +66,9 @@ def get_author(
"""Возвращает информацию об авторе и его книгах""" """Возвращает информацию об авторе и его книгах"""
author = session.get(Author, author_id) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
books = session.exec( books = session.exec(
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id) select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
@@ -88,7 +97,9 @@ def update_author(
"""Обновляет информацию об авторе""" """Обновляет информацию об авторе"""
db_author = session.get(Author, author_id) db_author = session.get(Author, author_id)
if not db_author: if not db_author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
update_data = author.model_dump(exclude_unset=True) update_data = author.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
@@ -113,7 +124,9 @@ def delete_author(
"""Удаляет автора из системы""" """Удаляет автора из системы"""
author = session.get(Author, author_id) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
author_read = AuthorRead(**author.model_dump()) author_read = AuthorRead(**author.model_dump())
session.delete(author) session.delete(author)
+26 -8
View File
@@ -3,7 +3,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlmodel import Session, select, col, func from sqlmodel import Session, select, col, func
from library_service.auth import RequireStaff from library_service.auth import RequireStaff
@@ -56,10 +56,16 @@ def close_active_loan(session: Session, book_id: int) -> None:
def filter_books( def filter_books(
session: Session = Depends(get_session), session: Session = Depends(get_session),
q: str | None = Query(None, max_length=50, description="Поиск"), q: str | None = Query(None, max_length=50, description="Поиск"),
author_ids: List[int] | None = Query(None, description="Список ID авторов"), min_page_count: int | None = Query(
genre_ids: List[int] | None = Query(None, description="Список ID жанров"), None, ge=0, description="Минимальное количество страниц"
),
max_page_count: int | None = Query(
None, ge=0, description="Максимальное количество страниц"
),
author_ids: List[int] | None = Query(None, gt=0, description="Список ID авторов"),
genre_ids: List[int] | None = Query(None, gt=0, description="Список ID жанров"),
page: int = Query(1, gt=0, description="Номер страницы"), page: int = Query(1, gt=0, description="Номер страницы"),
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"), size: int = Query(20, gt=0, le=100, description="Количество элементов на странице"),
): ):
"""Возвращает отфильтрованный список книг с пагинацией""" """Возвращает отфильтрованный список книг с пагинацией"""
statement = select(Book).distinct() statement = select(Book).distinct()
@@ -69,6 +75,12 @@ def filter_books(
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%")) (col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
) )
if min_page_count:
statement = statement.where(Book.page_count >= min_page_count)
if max_page_count:
statement = statement.where(Book.page_count <= max_page_count)
if author_ids: if author_ids:
statement = statement.join(AuthorBookLink).where( statement = statement.join(AuthorBookLink).where(
AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference] AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
@@ -149,7 +161,9 @@ def get_book(
"""Возвращает информацию о книге с авторами и жанрами""" """Возвращает информацию о книге с авторами и жанрами"""
book = session.get(Book, book_id) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
authors = session.exec( authors = session.exec(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
@@ -185,12 +199,14 @@ def update_book(
"""Обновляет информацию о книге""" """Обновляет информацию о книге"""
db_book = session.get(Book, book_id) db_book = session.get(Book, book_id)
if not db_book: if not db_book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book_update.status is not None: if book_update.status is not None:
if book_update.status == BookStatus.BORROWED: if book_update.status == BookStatus.BORROWED:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail="Статус 'borrowed' устанавливается только через выдачу книги", detail="Статус 'borrowed' устанавливается только через выдачу книги",
) )
@@ -226,7 +242,9 @@ def delete_book(
"""Удаляет книгу из системы""" """Удаляет книгу из системы"""
book = session.get(Book, book_id) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
book_read = BookRead( book_read = BookRead(
id=(book.id or 0), id=(book.id or 0),
title=book.title, title=book.title,
+101
View File
@@ -0,0 +1,101 @@
import asyncio
import hashlib
import secrets
from fastapi import APIRouter, Request, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from library_service.services.captcha import (
limiter,
get_ip,
active_challenges,
challenges_by_ip,
MAX_CHALLENGES_PER_IP,
MAX_TOTAL_CHALLENGES,
CHALLENGE_TTL,
REDEEM_TTL,
prng,
now_ms,
redeem_tokens,
)
router = APIRouter(prefix="/cap", tags=["captcha"])
@router.post("/challenge")
@limiter.limit("15/minute")
async def challenge(request: Request, ip: str = Depends(get_ip)):
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges"
)
if len(active_challenges) >= MAX_TOTAL_CHALLENGES:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Server busy"
)
token = secrets.token_hex(25)
redeem = secrets.token_hex(25)
expires = now_ms() + CHALLENGE_TTL
active_challenges[token] = {
"c": 50,
"s": 32,
"d": 4,
"expires": expires,
"redeem_token": redeem,
"ip": ip,
}
challenges_by_ip[ip] += 1
return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires}
@router.post("/redeem")
@limiter.limit("30/minute")
async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
token = payload.get("token")
solutions = payload.get("solutions", [])
if token not in active_challenges:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid challenge"
)
ch = active_challenges.pop(token)
challenges_by_ip[ch["ip"]] -= 1
if now_ms() > ch["expires"]:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Expired")
if len(solutions) < ch["c"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Bad solutions"
)
def verify(i: int) -> bool:
salt = prng(f"{token}{i+1}", ch["s"])
target = prng(f"{token}{i+1}d", ch["d"])
h = hashlib.sha256((salt + str(solutions[i])).encode()).hexdigest()
return h.startswith(target)
results = await asyncio.gather(
*(asyncio.to_thread(verify, i) for i in range(ch["c"]))
)
if not all(results):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid solution"
)
r_token = ch["redeem_token"]
redeem_tokens[r_token] = now_ms() + REDEEM_TTL
resp = JSONResponse(
{"success": True, "token": r_token, "expires": redeem_tokens[r_token]}
)
resp.set_cookie(
key="capjs_token",
value=r_token,
httponly=True,
samesite="lax",
max_age=REDEEM_TTL // 1000,
)
return resp
+19 -5
View File
@@ -1,10 +1,18 @@
"""Модуль работы с жанрами""" """Модуль работы с жанрами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireStaff from library_service.auth import RequireStaff
from library_service.models.db import Book, Genre, GenreBookLink from library_service.models.db import Book, Genre, GenreBookLink
from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks from library_service.models.dto import (
BookRead,
GenreCreate,
GenreList,
GenreRead,
GenreUpdate,
GenreWithBooks,
)
from library_service.settings import get_session from library_service.settings import get_session
@@ -57,7 +65,9 @@ def get_genre(
"""Возвращает информацию о жанре и книгах с ним""" """Возвращает информацию о жанре и книгах с ним"""
genre = session.get(Genre, genre_id) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
books = session.exec( books = session.exec(
select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id) select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
@@ -86,7 +96,9 @@ def update_genre(
"""Обновляет информацию о жанре""" """Обновляет информацию о жанре"""
db_genre = session.get(Genre, genre_id) db_genre = session.get(Genre, genre_id)
if not db_genre: if not db_genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
update_data = genre.model_dump(exclude_unset=True) update_data = genre.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
@@ -111,7 +123,9 @@ def delete_genre(
"""Удаляет жанр из системы""" """Удаляет жанр из системы"""
genre = session.get(Genre, genre_id) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
genre_read = GenreRead(**genre.model_dump()) genre_read = GenreRead(**genre.model_dump())
session.delete(genre) session.delete(genre)
+51 -19
View File
@@ -40,17 +40,21 @@ def create_loan(
book = session.get(Book, loan.book_id) book = session.get(Book, loan.book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status != BookStatus.ACTIVE: if book.status != BookStatus.ACTIVE:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
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) target_user = session.get(User, loan.user_id)
if not target_user: if not target_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan = BookUserLink( db_loan = BookUserLink(
book_id=loan.book_id, book_id=loan.book_id,
@@ -248,7 +252,9 @@ def get_loan(
loan = session.get(BookUserLink, loan_id) loan = session.get(BookUserLink, loan_id)
if not loan: if not loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user) is_staff = is_user_staff(current_user)
@@ -275,7 +281,9 @@ def update_loan(
"""Обновляет информацию о выдаче""" """Обновляет информацию о выдаче"""
db_loan = session.get(BookUserLink, loan_id) db_loan = session.get(BookUserLink, loan_id)
if not db_loan: if not db_loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user) is_staff = is_user_staff(current_user)
@@ -287,7 +295,9 @@ def update_loan(
book = session.get(Book, db_loan.book_id) book = session.get(Book, db_loan.book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if loan_update.user_id is not None: if loan_update.user_id is not None:
if not is_staff: if not is_staff:
@@ -297,7 +307,9 @@ def update_loan(
) )
new_user = session.get(User, loan_update.user_id) new_user = session.get(User, loan_update.user_id)
if not new_user: if not new_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan.user_id = loan_update.user_id db_loan.user_id = loan_update.user_id
if loan_update.due_date is not None: if loan_update.due_date is not None:
@@ -305,7 +317,10 @@ def update_loan(
if loan_update.returned_at is not None: if loan_update.returned_at is not None:
if db_loan.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=status.HTTP_400_BAD_REQUEST,
detail="Loan is already returned",
)
db_loan.returned_at = loan_update.returned_at db_loan.returned_at = loan_update.returned_at
book.status = BookStatus.ACTIVE book.status = BookStatus.ACTIVE
@@ -331,18 +346,24 @@ def confirm_loan(
"""Подтверждает бронирование и меняет статус книги на BORROWED""" """Подтверждает бронирование и меняет статус книги на BORROWED"""
loan = session.get(BookUserLink, loan_id) loan = session.get(BookUserLink, loan_id)
if not loan: if not loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
if loan.returned_at: if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
)
book = session.get(Book, loan.book_id) book = session.get(Book, loan.book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]: if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot confirm loan for book with status: {book.status}", detail=f"Cannot confirm loan for book with status: {book.status}",
) )
@@ -370,10 +391,14 @@ def return_loan(
"""Возвращает книгу и закрывает выдачу""" """Возвращает книгу и закрывает выдачу"""
loan = session.get(BookUserLink, loan_id) loan = session.get(BookUserLink, loan_id)
if not loan: if not loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
if loan.returned_at: if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
)
loan.returned_at = datetime.now(timezone.utc) loan.returned_at = datetime.now(timezone.utc)
@@ -403,7 +428,9 @@ def delete_loan(
"""Удаляет выдачу или бронирование (только для RESERVED статуса)""" """Удаляет выдачу или бронирование (только для RESERVED статуса)"""
loan = session.get(BookUserLink, loan_id) loan = session.get(BookUserLink, loan_id)
if not loan: if not loan:
raise HTTPException(status_code=404, detail="Loan not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user) is_staff = is_user_staff(current_user)
@@ -417,7 +444,7 @@ def delete_loan(
if book and book.status != BookStatus.RESERVED: if book and book.status != BookStatus.RESERVED:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only delete reservations. Use update endpoint to return borrowed books", detail="Can only delete reservations. Use update endpoint to return borrowed books",
) )
@@ -471,16 +498,21 @@ def issue_book_directly(
"""Выдает книгу напрямую без бронирования (только для администраторов)""" """Выдает книгу напрямую без бронирования (только для администраторов)"""
book = session.get(Book, loan.book_id) book = session.get(Book, loan.book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status != BookStatus.ACTIVE: if book.status != BookStatus.ACTIVE:
raise HTTPException( raise HTTPException(
status_code=400, detail=f"Book is not available (status: {book.status})" status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Book is not available (status: {book.status})",
) )
target_user = session.get(User, loan.user_id) target_user = session.get(User, loan.user_id)
if not target_user: if not target_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan = BookUserLink( db_loan = BookUserLink(
book_id=loan.book_id, book_id=loan.book_id,
+89 -35
View File
@@ -1,7 +1,8 @@
"""Модуль работы со связями""" """Модуль работы со связями"""
from typing import Dict, List from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireStaff from library_service.auth import RequireStaff
@@ -17,7 +18,9 @@ def check_entity_exists(session, model, entity_id, entity_name):
"""Проверяет существование сущности в базе данных""" """Проверяет существование сущности в базе данных"""
entity = session.get(model, entity_id) entity = session.get(model, entity_id)
if not entity: if not entity:
raise HTTPException(status_code=404, detail=f"{entity_name} not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"{entity_name} not found"
)
return entity return entity
@@ -30,7 +33,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
).first() ).first()
if existing_link: if existing_link:
raise HTTPException(status_code=400, detail=detail) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
link = link_model(**{field1: id1, field2: id2}) link = link_model(**{field1: id1, field2: id2})
session.add(link) session.add(link)
@@ -48,7 +51,9 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
).first() ).first()
if not link: if not link:
raise HTTPException(status_code=404, detail="Relationship not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Relationship not found"
)
session.delete(link) session.delete(link)
session.commit() session.commit()
@@ -56,21 +61,22 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
def get_related( def get_related(
session, session,
main_model, main_model,
main_id, main_id,
main_name, main_name,
related_model, related_model,
link_model, link_model,
link_main_field, link_main_field,
link_related_field, link_related_field,
read_model read_model,
): ):
"""Возвращает список связанных сущностей""" """Возвращает список связанных сущностей"""
check_entity_exists(session, main_model, main_id, main_name) check_entity_exists(session, main_model, main_id, main_name)
related = session.exec( related = session.exec(
select(related_model).join(link_model) select(related_model)
.join(link_model)
.where(getattr(link_model, link_main_field) == main_id) .where(getattr(link_model, link_main_field) == main_id)
).all() ).all()
@@ -93,8 +99,15 @@ def add_author_to_book(
check_entity_exists(session, Author, author_id, "Author") check_entity_exists(session, Author, author_id, "Author")
check_entity_exists(session, Book, book_id, "Book") check_entity_exists(session, Book, book_id, "Book")
return add_relationship(session, AuthorBookLink, return add_relationship(
author_id, "author_id", book_id, "book_id", "Relationship already exists") session,
AuthorBookLink,
author_id,
"author_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete( @router.delete(
@@ -110,8 +123,9 @@ def remove_author_from_book(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Удаляет связь между автором и книгой""" """Удаляет связь между автором и книгой"""
return remove_relationship(session, AuthorBookLink, return remove_relationship(
author_id, "author_id", book_id, "book_id") session, AuthorBookLink, author_id, "author_id", book_id, "book_id"
)
@router.get( @router.get(
@@ -122,9 +136,17 @@ def remove_author_from_book(
) )
def get_books_for_author(author_id: int, session: Session = Depends(get_session)): def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
"""Возвращает список книг автора""" """Возвращает список книг автора"""
return get_related(session, return get_related(
Author, author_id, "Author", Book, session,
AuthorBookLink, "author_id", "book_id", BookRead) Author,
author_id,
"Author",
Book,
AuthorBookLink,
"author_id",
"book_id",
BookRead,
)
@router.get( @router.get(
@@ -135,9 +157,17 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
) )
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
"""Возвращает список авторов книги""" """Возвращает список авторов книги"""
return get_related(session, return get_related(
Book, book_id, "Book", Author, session,
AuthorBookLink, "book_id", "author_id", AuthorRead) Book,
book_id,
"Book",
Author,
AuthorBookLink,
"book_id",
"author_id",
AuthorRead,
)
@router.post( @router.post(
@@ -156,8 +186,15 @@ def add_genre_to_book(
check_entity_exists(session, Genre, genre_id, "Genre") check_entity_exists(session, Genre, genre_id, "Genre")
check_entity_exists(session, Book, book_id, "Book") check_entity_exists(session, Book, book_id, "Book")
return add_relationship(session, GenreBookLink, return add_relationship(
genre_id, "genre_id", book_id, "book_id", "Relationship already exists") session,
GenreBookLink,
genre_id,
"genre_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete( @router.delete(
@@ -173,8 +210,9 @@ def remove_genre_from_book(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Удаляет связь между жанром и книгой""" """Удаляет связь между жанром и книгой"""
return remove_relationship(session, GenreBookLink, return remove_relationship(
genre_id, "genre_id", book_id, "book_id") session, GenreBookLink, genre_id, "genre_id", book_id, "book_id"
)
@router.get( @router.get(
@@ -185,9 +223,17 @@ def remove_genre_from_book(
) )
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)): def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
"""Возвращает список книг в жанре""" """Возвращает список книг в жанре"""
return get_related(session, return get_related(
Genre, genre_id, "Genre", Book, session,
GenreBookLink, "genre_id", "book_id", BookRead) Genre,
genre_id,
"Genre",
Book,
GenreBookLink,
"genre_id",
"book_id",
BookRead,
)
@router.get( @router.get(
@@ -198,6 +244,14 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
) )
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)): def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
"""Возвращает список жанров книги""" """Возвращает список жанров книги"""
return get_related(session, return get_related(
Book, book_id, "Book", Genre, session,
GenreBookLink, "book_id", "genre_id", GenreRead) Book,
book_id,
"Book",
Genre,
GenreBookLink,
"book_id",
"genre_id",
GenreRead,
)
+29
View File
@@ -0,0 +1,29 @@
from .captcha import (
limiter,
cleanup_task,
get_ip,
require_captcha,
active_challenges,
redeem_tokens,
challenges_by_ip,
MAX_CHALLENGES_PER_IP,
MAX_TOTAL_CHALLENGES,
CHALLENGE_TTL,
REDEEM_TTL,
prng,
)
__all__ = [
"limiter",
"cleanup_task",
"get_ip",
"require_captcha",
"active_challenges",
"redeem_tokens",
"challenges_by_ip",
"MAX_CHALLENGES_PER_IP",
"MAX_TOTAL_CHALLENGES",
"CHALLENGE_TTL",
"REDEEM_TTL",
"prng",
]
+75
View File
@@ -0,0 +1,75 @@
import os
import asyncio
import hashlib
import secrets
import time
from collections import defaultdict
from fastapi import Request, HTTPException, Depends, status
from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
CLEANUP_INTERVAL = int(os.getenv("CAP_CLEANUP_INTERVAL", "10"))
REDEEM_TTL = int(os.getenv("CAP_REDEEM_TTL_SECONDS", "180")) * 1000
CHALLENGE_TTL = int(os.getenv("CAP_CHALLENGE_TTL_SECONDS", "120")) * 1000
MAX_CHALLENGES_PER_IP = int(os.getenv("CAP_MAX_CHALLENGES_PER_IP", "12"))
MAX_TOTAL_CHALLENGES = int(os.getenv("CAP_MAX_TOTAL_CHALLENGES", "1000"))
active_challenges: dict[str, dict] = {}
redeem_tokens: dict[str, int] = {}
challenges_by_ip: defaultdict[str, int] = defaultdict(int)
limiter = Limiter(key_func=get_remote_address)
def now_ms() -> int:
return int(time.time() * 1000)
def fnv1a_utf16(seed: str) -> int:
h = 2166136261
data = seed.encode("utf-16le")
i = 0
while i < len(data):
unit = data[i] + (data[i + 1] << 8)
h ^= unit
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & 0xFFFFFFFF
i += 2
return h
def prng(seed: str, length: int) -> str:
state = fnv1a_utf16(seed)
out = ""
while len(out) < length:
state ^= (state << 13) & 0xFFFFFFFF
state ^= state >> 17
state ^= (state << 5) & 0xFFFFFFFF
out += f"{state & 0xFFFFFFFF:08x}"
return out[:length]
async def cleanup_task():
while True:
now = now_ms()
for token, data in list(active_challenges.items()):
if data["expires"] < now:
challenges_by_ip[data["ip"]] -= 1
del active_challenges[token]
for token, exp in list(redeem_tokens.items()):
if exp < now:
del redeem_tokens[token]
await asyncio.sleep(CLEANUP_INTERVAL)
def get_ip(request: Request) -> str:
return get_remote_address(request)
async def require_captcha(request: Request):
token = request.cookies.get("capjs_token")
if not token or token not in redeem_tokens or redeem_tokens[token] < now_ms():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail={"error": "captcha_required"}
)
del redeem_tokens[token]
+1
View File
@@ -61,6 +61,7 @@ OPENAPI_TAGS = [
{"name": "loans", "description": "Действия с выдачами."}, {"name": "loans", "description": "Действия с выдачами."},
{"name": "relations", "description": "Действия со связями."}, {"name": "relations", "description": "Действия со связями."},
{"name": "users", "description": "Действия с пользователями."}, {"name": "users", "description": "Действия с пользователями."},
{"name": "captcha", "description": "Создание и проверка cap.js каптчи."},
{"name": "misc", "description": "Прочие."}, {"name": "misc", "description": "Прочие."},
] ]
+274 -273
View File
@@ -1,6 +1,70 @@
$(() => { $(() => {
const PARTIAL_TOKEN_KEY = "partial_token"; const SELECTORS = {
const PARTIAL_USERNAME_KEY = "partial_username"; loginForm: "#login-form",
registerForm: "#register-form",
resetForm: "#reset-password-form",
loginTab: "#login-tab",
registerTab: "#register-tab",
forgotBtn: "#forgot-password-btn",
backToLoginBtn: "#back-to-login-btn",
backToCredentialsBtn: "#back-to-credentials-btn",
submitLogin: "#login-submit",
submitRegister: "#register-submit",
submitReset: "#reset-submit",
usernameLogin: "#login-username",
passwordLogin: "#login-password",
totpInput: "#login-totp",
rememberMe: "#remember-me",
credentialsSection: "#credentials-section",
totpSection: "#totp-section",
registerUsername: "#register-username",
registerEmail: "#register-email",
registerFullname: "#register-fullname",
registerPassword: "#register-password",
registerConfirm: "#register-password-confirm",
passwordStrengthBar: "#password-strength-bar",
passwordStrengthText: "#password-strength-text",
passwordMatchError: "#password-match-error",
resetUsername: "#reset-username",
resetCode: "#reset-recovery-code",
resetNewPassword: "#reset-new-password",
resetConfirmPassword: "#reset-confirm-password",
resetMatchError: "#reset-password-match-error",
recoveryModal: "#recovery-codes-modal",
recoveryList: "#recovery-codes-list",
codesSavedCheckbox: "#codes-saved-checkbox",
closeRecoveryBtn: "#close-recovery-modal-btn",
copyCodesBtn: "#copy-codes-btn",
downloadCodesBtn: "#download-codes-btn",
gotoLoginAfterReset: "#goto-login-after-reset",
capWidget: "#cap",
lockProgressCircle: "#lock-progress-circle",
};
const STORAGE_KEYS = {
partialToken: "partial_token",
partialUsername: "partial_username",
};
const TEXTS = {
login: "Войти",
confirm: "Подтвердить",
checking: "Проверка...",
registering: "Регистрация...",
resetting: "Сброс...",
enterTotp: "Введите код из приложения аутентификатора",
sessionExpired: "Время сессии истекло. Пожалуйста, войдите заново.",
invalidCode: "Неверный код",
passwordsNotMatch: "Пароли не совпадают",
captchaRequired: "Пожалуйста, пройдите проверку Captcha",
registrationSuccess: "Регистрация успешна! Войдите в систему.",
codesCopied: "Коды скопированы в буфер обмена",
codesDownloaded: "Файл с кодами скачан",
passwordResetSuccess: "Пароль успешно изменён!",
invalidRecoveryCode: "Неверный формат резервного кода",
passwordTooShort: "Пароль должен содержать минимум 8 символов",
};
const TOTP_PERIOD = 30; const TOTP_PERIOD = 30;
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38; const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38;
@@ -14,96 +78,71 @@ $(() => {
let registeredRecoveryCodes = []; let registeredRecoveryCodes = [];
let totpAnimationFrame = null; let totpAnimationFrame = null;
function getTotpProgress() { const getTotpProgress = () => {
const now = Date.now() / 1000; const now = Date.now() / 1000;
const elapsed = now % TOTP_PERIOD; const elapsed = now % TOTP_PERIOD;
return elapsed / TOTP_PERIOD; return elapsed / TOTP_PERIOD;
} };
function updateTotpTimer() { const updateTotpTimer = () => {
const circle = document.getElementById("lock-progress-circle"); const circle = $(SELECTORS.lockProgressCircle).get(0);
if (!circle) return; if (!circle) return;
const progress = getTotpProgress(); const progress = getTotpProgress();
const offset = CIRCLE_CIRCUMFERENCE * (1 - progress); const offset = CIRCLE_CIRCUMFERENCE * (1 - progress);
circle.style.strokeDashoffset = offset; circle.style.strokeDashoffset = offset;
totpAnimationFrame = requestAnimationFrame(updateTotpTimer); totpAnimationFrame = requestAnimationFrame(updateTotpTimer);
} };
function startTotpTimer() { const startTotpTimer = () => {
stopTotpTimer(); stopTotpTimer();
updateTotpTimer(); updateTotpTimer();
} };
function stopTotpTimer() { const stopTotpTimer = () => {
if (totpAnimationFrame) { if (totpAnimationFrame) {
cancelAnimationFrame(totpAnimationFrame); cancelAnimationFrame(totpAnimationFrame);
totpAnimationFrame = null; totpAnimationFrame = null;
} }
} };
function resetCircle() { const resetCircle = () => {
const circle = document.getElementById("lock-progress-circle"); const circle = $(SELECTORS.lockProgressCircle).get(0);
if (circle) { if (circle) circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE;
circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE; };
}
}
function initLoginState() { const savePartialToken = (token, username) => {
const savedToken = sessionStorage.getItem(PARTIAL_TOKEN_KEY); sessionStorage.setItem(STORAGE_KEYS.partialToken, token);
const savedUsername = sessionStorage.getItem(PARTIAL_USERNAME_KEY); sessionStorage.setItem(STORAGE_KEYS.partialUsername, username);
};
if (savedToken && savedUsername) { const clearPartialToken = () => {
loginState.partialToken = savedToken; sessionStorage.removeItem(STORAGE_KEYS.partialToken);
loginState.username = savedUsername; sessionStorage.removeItem(STORAGE_KEYS.partialUsername);
loginState.step = "2fa"; };
$("#login-username").val(savedUsername); const showForm = (formId) => {
$("#credentials-section").addClass("hidden"); $(
$("#totp-section").removeClass("hidden"); `${SELECTORS.loginForm}, ${SELECTORS.registerForm}, ${SELECTORS.resetForm}`,
$("#login-submit").text("Подтвердить"); ).addClass("hidden");
startTotpTimer();
setTimeout(() => {
const totpInput = document.getElementById("login-totp");
if (totpInput) totpInput.focus();
}, 100);
}
}
function savePartialToken(token, username) {
sessionStorage.setItem(PARTIAL_TOKEN_KEY, token);
sessionStorage.setItem(PARTIAL_USERNAME_KEY, username);
}
function clearPartialToken() {
sessionStorage.removeItem(PARTIAL_TOKEN_KEY);
sessionStorage.removeItem(PARTIAL_USERNAME_KEY);
}
function showForm(formId) {
$("#login-form, #register-form, #reset-password-form").addClass("hidden");
$(formId).removeClass("hidden"); $(formId).removeClass("hidden");
$("#login-tab, #register-tab") $(`${SELECTORS.loginTab}, ${SELECTORS.registerTab}`)
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500") .removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
.addClass("text-gray-400 hover:text-gray-600"); .addClass("text-gray-400 hover:text-gray-600");
if (formId === "#login-form") { if (formId === SELECTORS.loginForm) {
$("#login-tab") $(SELECTORS.loginTab)
.removeClass("text-gray-400 hover:text-gray-600") .removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
resetLoginState(); resetLoginState();
} else if (formId === "#register-form") { } else if (formId === SELECTORS.registerForm) {
$("#register-tab") $(SELECTORS.registerTab)
.removeClass("text-gray-400 hover:text-gray-600") .removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
} }
} };
function resetLoginState() { const resetLoginState = () => {
clearPartialToken(); clearPartialToken();
stopTotpTimer(); stopTotpTimer();
loginState = { loginState = {
@@ -112,30 +151,68 @@ $(() => {
username: "", username: "",
rememberMe: false, rememberMe: false,
}; };
$("#totp-section").addClass("hidden"); $(SELECTORS.totpSection).addClass("hidden");
$("#login-totp").val(""); $(SELECTORS.totpInput).val("");
$("#credentials-section").removeClass("hidden"); $(SELECTORS.credentialsSection).removeClass("hidden");
$("#login-submit").text("Войти"); $(SELECTORS.submitLogin).text(TEXTS.login);
resetCircle(); resetCircle();
} };
$("#login-tab").on("click", () => showForm("#login-form")); const checkPasswordMatch = (passwordId, confirmId, errorId) => {
$("#register-tab").on("click", () => showForm("#register-form")); const password = $(passwordId).val();
$("#forgot-password-btn").on("click", () => showForm("#reset-password-form")); const confirm = $(confirmId).val();
$("#back-to-login-btn").on("click", () => showForm("#login-form")); const $error = $(errorId);
if (confirm && password !== confirm) {
$error.removeClass("hidden");
return false;
}
$error.addClass("hidden");
return true;
};
const saveTokensAndRedirect = (data, rememberMe) => {
const storage = rememberMe ? localStorage : sessionStorage;
const otherStorage = rememberMe ? sessionStorage : localStorage;
storage.setItem("access_token", data.access_token);
if (data.refresh_token)
storage.setItem("refresh_token", data.refresh_token);
otherStorage.removeItem("access_token");
otherStorage.removeItem("refresh_token");
window.location.href = "/";
};
const initLoginState = () => {
const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken);
const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername);
if (savedToken && savedUsername) {
loginState.partialToken = savedToken;
loginState.username = savedUsername;
loginState.step = "2fa";
$(SELECTORS.usernameLogin).val(savedUsername);
$(SELECTORS.credentialsSection).addClass("hidden");
$(SELECTORS.totpSection).removeClass("hidden");
$(SELECTORS.submitLogin).text(TEXTS.confirm);
startTotpTimer();
setTimeout(() => $(SELECTORS.totpInput).get(0)?.focus(), 100);
}
};
$(SELECTORS.loginTab).on("click", () => showForm(SELECTORS.loginForm));
$(SELECTORS.registerTab).on("click", () => showForm(SELECTORS.registerForm));
$(SELECTORS.forgotBtn).on("click", () => showForm(SELECTORS.resetForm));
$(SELECTORS.backToLoginBtn).on("click", () => showForm(SELECTORS.loginForm));
$(SELECTORS.backToCredentialsBtn).on("click", resetLoginState);
$("body").on("click", ".toggle-password", function () { $("body").on("click", ".toggle-password", function () {
const $btn = $(this); const $input = $(this).siblings("input");
const $input = $btn.siblings("input");
const isPassword = $input.attr("type") === "password"; const isPassword = $input.attr("type") === "password";
$input.attr("type", isPassword ? "text" : "password"); $input.attr("type", isPassword ? "text" : "password");
$btn.find("svg").toggleClass("hidden"); $(this).find("svg").toggleClass("hidden");
}); });
$("#register-password").on("input", function () { $(SELECTORS.registerPassword).on("input", function () {
const password = $(this).val(); const password = $(this).val();
let strength = 0; let strength = 0;
if (password.length >= 8) strength++; if (password.length >= 8) strength++;
if (password.length >= 12) strength++; if (password.length >= 12) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++; if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
@@ -150,91 +227,64 @@ $(() => {
{ width: "80%", color: "bg-lime-500", text: "Хороший" }, { width: "80%", color: "bg-lime-500", text: "Хороший" },
{ width: "100%", color: "bg-green-500", text: "Отличный" }, { width: "100%", color: "bg-green-500", text: "Отличный" },
]; ];
const level = levels[strength]; const level = levels[strength];
$("#password-strength-bar") $(SELECTORS.passwordStrengthBar)
.css("width", level.width) .css("width", level.width)
.attr("class", "h-full transition-all duration-300 " + level.color); .attr("class", `h-full transition-all duration-300 ${level.color}`);
$("#password-strength-text").text(level.text); $(SELECTORS.passwordStrengthText).text(level.text);
checkPasswordMatch(
checkPasswordMatch(); SELECTORS.registerPassword,
SELECTORS.registerConfirm,
SELECTORS.passwordMatchError,
);
}); });
function checkPasswordMatch() { $(SELECTORS.registerConfirm).on("input", () =>
const password = $("#register-password").val(); checkPasswordMatch(
const confirm = $("#register-password-confirm").val(); SELECTORS.registerPassword,
if (confirm && password !== confirm) { SELECTORS.registerConfirm,
$("#password-match-error").removeClass("hidden"); SELECTORS.passwordMatchError,
return false; ),
} );
$("#password-match-error").addClass("hidden");
return true;
}
$("#register-password-confirm").on("input", checkPasswordMatch); $(SELECTORS.resetCode).on("input", function () {
let value = this.value.toUpperCase().replace(/[^0-9A-F]/g, "");
function formatRecoveryCode(input) {
let value = input.value.toUpperCase().replace(/[^0-9A-F]/g, "");
let formatted = ""; let formatted = "";
for (let i = 0; i < value.length && i < 16; i++) { for (let i = 0; i < value.length && i < 16; i++) {
if (i > 0 && i % 4 === 0) formatted += "-"; if (i > 0 && i % 4 === 0) formatted += "-";
formatted += value[i]; formatted += value[i];
} }
input.value = formatted; this.value = formatted;
}
$("#reset-recovery-code").on("input", function () {
formatRecoveryCode(this);
}); });
$("#login-totp").on("input", function () { $(SELECTORS.totpInput).on("input", function () {
this.value = this.value.replace(/\D/g, "").slice(0, 6); this.value = this.value.replace(/\D/g, "").slice(0, 6);
if (this.value.length === 6) { if (this.value.length === 6) $(SELECTORS.loginForm).trigger("submit");
$("#login-form").trigger("submit");
}
}); });
$("#back-to-credentials-btn").on("click", function () { $(SELECTORS.loginForm).on("submit", async function (event) {
resetLoginState();
});
$("#login-form").on("submit", async function (event) {
event.preventDefault(); event.preventDefault();
const $submitBtn = $("#login-submit"); const $submitBtn = $(SELECTORS.submitLogin);
if (loginState.step === "credentials") { if (loginState.step === "credentials") {
const username = $("#login-username").val(); const username = $(SELECTORS.usernameLogin).val();
const password = $("#login-password").val(); const password = $(SELECTORS.passwordLogin).val();
const rememberMe = $("#remember-me").prop("checked"); const rememberMe = $(SELECTORS.rememberMe).prop("checked");
loginState.username = username; loginState.username = username;
loginState.rememberMe = rememberMe; loginState.rememberMe = rememberMe;
$submitBtn.prop("disabled", true).text("Вход..."); $submitBtn.prop("disabled", true).text("Вход...");
try { try {
const formData = new URLSearchParams(); const formData = new URLSearchParams({ username, password });
formData.append("username", username);
formData.append("password", password);
const data = await Api.postForm("/api/auth/token", formData); const data = await Api.postForm("/api/auth/token", formData);
if (data.requires_2fa && data.partial_token) { if (data.requires_2fa && data.partial_token) {
loginState.partialToken = data.partial_token; loginState.partialToken = data.partial_token;
loginState.step = "2fa"; loginState.step = "2fa";
savePartialToken(data.partial_token, username); savePartialToken(data.partial_token, username);
$(SELECTORS.credentialsSection).addClass("hidden");
$("#credentials-section").addClass("hidden"); $(SELECTORS.totpSection).removeClass("hidden");
$("#totp-section").removeClass("hidden");
startTotpTimer(); startTotpTimer();
$(SELECTORS.totpInput).get(0)?.focus();
const totpInput = document.getElementById("login-totp"); $submitBtn.text(TEXTS.confirm);
if (totpInput) totpInput.focus(); Utils.showToast(TEXTS.enterTotp, "info");
$submitBtn.text("Подтвердить");
Utils.showToast("Введите код из приложения аутентификатора", "info");
} else if (data.access_token) { } else if (data.access_token) {
clearPartialToken(); clearPartialToken();
saveTokensAndRedirect(data, rememberMe); saveTokensAndRedirect(data, rememberMe);
@@ -243,20 +293,15 @@ $(() => {
Utils.showToast(error.message || "Ошибка входа", "error"); Utils.showToast(error.message || "Ошибка входа", "error");
} finally { } finally {
$submitBtn.prop("disabled", false); $submitBtn.prop("disabled", false);
if (loginState.step === "credentials") { if (loginState.step === "credentials") $submitBtn.text(TEXTS.login);
$submitBtn.text("Войти");
}
} }
} else if (loginState.step === "2fa") { } else if (loginState.step === "2fa") {
const totpCode = $("#login-totp").val(); const totpCode = $(SELECTORS.totpInput).val();
if (!totpCode || totpCode.length !== 6) { if (!totpCode || totpCode.length !== 6) {
Utils.showToast("Введите 6-значный код", "error"); Utils.showToast("Введите 6-значный код", "error");
return; return;
} }
$submitBtn.prop("disabled", true).text(TEXTS.checking);
$submitBtn.prop("disabled", true).text("Проверка...");
try { try {
const response = await fetch("/api/auth/2fa/verify", { const response = await fetch("/api/auth/2fa/verify", {
method: "POST", method: "POST",
@@ -266,113 +311,93 @@ $(() => {
}, },
body: JSON.stringify({ code: totpCode }), body: JSON.stringify({ code: totpCode }),
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
if (response.status === 401) { if (response.status === 401) {
resetLoginState(); resetLoginState();
throw new Error( throw new Error(TEXTS.sessionExpired);
"Время сессии истекло. Пожалуйста, войдите заново.",
);
} }
throw new Error(errorData.detail || TEXTS.invalidCode);
throw new Error(errorData.detail || "Неверный код");
} }
const data = await response.json(); const data = await response.json();
clearPartialToken(); clearPartialToken();
stopTotpTimer(); stopTotpTimer();
saveTokensAndRedirect(data, loginState.rememberMe); saveTokensAndRedirect(data, loginState.rememberMe);
} catch (error) { } catch (error) {
Utils.showToast(error.message || "Неверный код", "error"); Utils.showToast(error.message || TEXTS.invalidCode, "error");
$("#login-totp").val(""); $(SELECTORS.totpInput).val("");
const totpInput = document.getElementById("login-totp"); $(SELECTORS.totpInput).get(0)?.focus();
if (totpInput) totpInput.focus();
} finally { } finally {
$submitBtn.prop("disabled", false).text("Подтвердить"); $submitBtn.prop("disabled", false).text(TEXTS.confirm);
} }
} }
}); });
function saveTokensAndRedirect(data, rememberMe) { $(SELECTORS.registerForm).on("submit", async function (event) {
const storage = rememberMe ? localStorage : sessionStorage;
const otherStorage = rememberMe ? sessionStorage : localStorage;
storage.setItem("access_token", data.access_token);
if (data.refresh_token) {
storage.setItem("refresh_token", data.refresh_token);
}
otherStorage.removeItem("access_token");
otherStorage.removeItem("refresh_token");
window.location.href = "/";
}
$("#register-form").on("submit", async function (event) {
event.preventDefault(); event.preventDefault();
const $submitBtn = $("#register-submit"); const $submitBtn = $(SELECTORS.submitRegister);
const pass = $("#register-password").val(); const pass = $(SELECTORS.registerPassword).val();
const confirm = $("#register-password-confirm").val(); const confirm = $(SELECTORS.registerConfirm).val();
if (pass !== confirm) { if (pass !== confirm) {
Utils.showToast("Пароли не совпадают", "error"); Utils.showToast(TEXTS.passwordsNotMatch, "error");
return; return;
} }
const userData = { const userData = {
username: $("#register-username").val(), username: $(SELECTORS.registerUsername).val(),
email: $("#register-email").val(), email: $(SELECTORS.registerEmail).val(),
full_name: $("#register-fullname").val() || null, full_name: $(SELECTORS.registerFullname).val() || null,
password: pass, password: pass,
}; };
$submitBtn.prop("disabled", true).text(TEXTS.registering);
$submitBtn.prop("disabled", true).text("Регистрация...");
try { try {
const response = await Api.post("/api/auth/register", userData); const response = await Api.post("/api/auth/register", userData);
if (response.recovery_codes && response.recovery_codes.codes) { if (response.recovery_codes && response.recovery_codes.codes) {
registeredRecoveryCodes = response.recovery_codes.codes; registeredRecoveryCodes = response.recovery_codes.codes;
showRecoveryCodesModal(registeredRecoveryCodes, userData.username); showRecoveryCodesModal(registeredRecoveryCodes, userData.username);
} else { } else {
Utils.showToast("Регистрация успешна! Войдите в систему.", "success"); Utils.showToast(TEXTS.registrationSuccess, "success");
setTimeout(() => { setTimeout(() => {
showForm("#login-form"); showForm(SELECTORS.loginForm);
$("#login-username").val(userData.username); $(SELECTORS.usernameLogin).val(userData.username);
}, 1500); }, 1500);
} }
} catch (error) { } catch (error) {
if (error.detail && error.detail.error === "captcha_required") {
Utils.showToast(TEXTS.captchaRequired, "error");
const $capElement = $(SELECTORS.capWidget);
const $parent = $capElement.parent();
$capElement.remove();
$parent.append(
`<cap-widget id="cap" data-cap-api-endpoint="/api/cap/" style="--cap-widget-width: 100%;"></cap-widget>`,
);
return;
}
let msg = error.message; let msg = error.message;
if (error.detail && Array.isArray(error.detail)) { if (error.detail && Array.isArray(error.detail)) {
msg = error.detail.map((e) => e.msg).join(". "); msg = error.detail.map((e) => e.msg).join(". ");
} }
Utils.showToast(msg || "Ошибка регистрации", "error"); Utils.showToast(msg || "Ошибка регистрации", "error");
} finally { } finally {
$submitBtn.prop("disabled", false).text("Зарегистрироваться"); $submitBtn
.prop("disabled", false)
.text(TEXTS.registering.replace("...", ""));
} }
}); });
function showRecoveryCodesModal(codes, username) { const showRecoveryCodesModal = (codes, username) => {
const $list = $("#recovery-codes-list"); const $list = $(SELECTORS.recoveryList);
$list.empty(); $list.empty();
codes.forEach((code, index) => { codes.forEach((code, index) => {
$list.append(` $list.append(
<div class="py-1 px-2 bg-white rounded border select-all font-mono"> `<div class="py-1 px-2 bg-white rounded border select-all font-mono">${index + 1}. ${Utils.escapeHtml(code)}</div>`,
${index + 1}. ${Utils.escapeHtml(code)} );
</div>
`);
}); });
$(SELECTORS.codesSavedCheckbox).prop("checked", false);
$(SELECTORS.closeRecoveryBtn).prop("disabled", true);
$(SELECTORS.recoveryModal).data("username", username).removeClass("hidden");
};
$("#codes-saved-checkbox").prop("checked", false); const renderRecoveryCodesStatus = (usedCodes) => {
$("#close-recovery-modal-btn").prop("disabled", true);
$("#recovery-codes-modal").data("username", username);
$("#recovery-codes-modal").removeClass("hidden");
}
function renderRecoveryCodesStatus(usedCodes) {
return usedCodes return usedCodes
.map((used, index) => { .map((used, index) => {
const codeDisplay = "████-████-████-████"; const codeDisplay = "████-████-████-████";
@@ -380,31 +405,25 @@ $(() => {
? "text-gray-300 line-through" ? "text-gray-300 line-through"
: "text-green-600"; : "text-green-600";
const statusIcon = used ? "✗" : "✓"; const statusIcon = used ? "✗" : "✓";
return ` return `<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}"><span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span><span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span></div>`;
<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}">
<span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span>
<span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span>
</div>
`;
}) })
.join(""); .join("");
} };
$("#codes-saved-checkbox").on("change", function () { $(SELECTORS.codesSavedCheckbox).on("change", function () {
$("#close-recovery-modal-btn").prop("disabled", !this.checked); $(SELECTORS.closeRecoveryBtn).prop("disabled", !this.checked);
}); });
$("#copy-codes-btn").on("click", function () { $(SELECTORS.copyCodesBtn).on("click", function () {
const codesText = registeredRecoveryCodes.join("\n"); const codesText = registeredRecoveryCodes.join("\n");
navigator.clipboard.writeText(codesText).then(() => { navigator.clipboard
Utils.showToast("Коды скопированы в буфер обмена", "success"); .writeText(codesText)
}); .then(() => Utils.showToast(TEXTS.codesCopied, "success"));
}); });
$("#download-codes-btn").on("click", function () { $(SELECTORS.downloadCodesBtn).on("click", function () {
const username = $("#recovery-codes-modal").data("username") || "user"; const username = $(SELECTORS.recoveryModal).data("username") || "user";
const codesText = `Резервные коды для аккаунта: ${username}\nДата: ${new Date().toLocaleString()}\n\n${registeredRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nХраните эти коды в надёжном месте!`; const codesText = `Резервные коды для аккаунта: ${username}\nДата: ${new Date().toLocaleString()}\n\n${registeredRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nХраните эти коды в надёжном месте!`;
const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" }); const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
@@ -412,69 +431,54 @@ $(() => {
a.download = `recovery-codes-${username}.txt`; a.download = `recovery-codes-${username}.txt`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
Utils.showToast(TEXTS.codesDownloaded, "success");
Utils.showToast("Файл с кодами скачан", "success");
}); });
$("#close-recovery-modal-btn").on("click", function () { $(SELECTORS.closeRecoveryBtn).on("click", function () {
const username = $("#recovery-codes-modal").data("username"); const username = $(SELECTORS.recoveryModal).data("username");
$("#recovery-codes-modal").addClass("hidden"); $(SELECTORS.recoveryModal).addClass("hidden");
Utils.showToast(TEXTS.registrationSuccess, "success");
Utils.showToast("Регистрация успешна! Войдите в систему.", "success"); showForm(SELECTORS.loginForm);
showForm("#login-form"); $(SELECTORS.usernameLogin).val(username);
$("#login-username").val(username);
}); });
function checkResetPasswordMatch() { $(SELECTORS.resetConfirmPassword).on("input", () =>
const password = $("#reset-new-password").val(); checkPasswordMatch(
const confirm = $("#reset-confirm-password").val(); SELECTORS.resetNewPassword,
if (confirm && password !== confirm) { SELECTORS.resetConfirmPassword,
$("#reset-password-match-error").removeClass("hidden"); SELECTORS.resetMatchError,
return false; ),
} );
$("#reset-password-match-error").addClass("hidden");
return true;
}
$("#reset-confirm-password").on("input", checkResetPasswordMatch); $(SELECTORS.resetForm).on("submit", async function (event) {
$("#reset-password-form").on("submit", async function (event) {
event.preventDefault(); event.preventDefault();
const $submitBtn = $("#reset-submit"); const $submitBtn = $(SELECTORS.submitReset);
const newPassword = $(SELECTORS.resetNewPassword).val();
const newPassword = $("#reset-new-password").val(); const confirmPassword = $(SELECTORS.resetConfirmPassword).val();
const confirmPassword = $("#reset-confirm-password").val();
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
Utils.showToast("Пароли не совпадают", "error"); Utils.showToast(TEXTS.passwordsNotMatch, "error");
return; return;
} }
if (newPassword.length < 8) { if (newPassword.length < 8) {
Utils.showToast("Пароль должен содержать минимум 8 символов", "error"); Utils.showToast(TEXTS.passwordTooShort, "error");
return; return;
} }
const data = { const data = {
username: $("#reset-username").val(), username: $(SELECTORS.resetUsername).val(),
recovery_code: $("#reset-recovery-code").val().toUpperCase(), recovery_code: $(SELECTORS.resetCode).val().toUpperCase(),
new_password: newPassword, new_password: newPassword,
}; };
if ( if (
!/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test( !/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test(
data.recovery_code, data.recovery_code,
) )
) { ) {
Utils.showToast("Неверный формат резервного кода", "error"); Utils.showToast(TEXTS.invalidRecoveryCode, "error");
return; return;
} }
$submitBtn.prop("disabled", true).text(TEXTS.resetting);
$submitBtn.prop("disabled", true).text("Сброс...");
try { try {
const response = await Api.post("/api/auth/password/reset", data); const response = await Api.post("/api/auth/password/reset", data);
showPasswordResetResult(response, data.username); showPasswordResetResult(response, data.username);
} catch (error) { } catch (error) {
Utils.showToast(error.message || "Ошибка сброса пароля", "error"); Utils.showToast(error.message || "Ошибка сброса пароля", "error");
@@ -482,9 +486,8 @@ $(() => {
} }
}); });
function showPasswordResetResult(response, username) { const showPasswordResetResult = (response, username) => {
const $form = $("#reset-password-form"); const $form = $(SELECTORS.resetForm);
$form.html(` $form.html(`
<div class="text-center mb-4"> <div class="text-center mb-4">
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3"> <div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
@@ -492,22 +495,19 @@ $(() => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg> </svg>
</div> </div>
<h3 class="text-lg font-semibold text-gray-800">Пароль успешно изменён!</h3> <h3 class="text-lg font-semibold text-gray-800">${TEXTS.passwordResetSuccess}</h3>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<p class="text-sm text-gray-600 mb-2 text-center"> <p class="text-sm text-gray-600 mb-2 text-center">
Осталось резервных кодов: <strong class="${response.remaining <= 2 ? "text-red-600" : "text-green-600"}">${response.remaining}</strong> из ${response.total} Осталось резервных кодов: <strong class="${response.remaining <= 2 ? "text-red-600" : "text-green-600"}">${response.remaining}</strong> из ${response.total}
</p> </p>
${ ${
response.should_regenerate response.should_regenerate
? ` ? `
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3"> <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
<p class="text-sm text-yellow-800 flex items-center gap-2"> <p class="text-sm text-yellow-800 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
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> </svg>
Рекомендуем сгенерировать новые коды в профиле Рекомендуем сгенерировать новые коды в профиле
</p> </p>
@@ -515,12 +515,10 @@ $(() => {
` `
: "" : ""
} }
<div class="bg-gray-50 rounded-lg p-3 space-y-1 max-h-48 overflow-y-auto"> <div class="bg-gray-50 rounded-lg p-3 space-y-1 max-h-48 overflow-y-auto">
<p class="text-xs text-gray-500 mb-2 text-center">Статус резервных кодов:</p> <p class="text-xs text-gray-500 mb-2 text-center">Статус резервных кодов:</p>
${renderRecoveryCodesStatus(response.used_codes)} ${renderRecoveryCodesStatus(response.used_codes)}
</div> </div>
${ ${
response.generated_at response.generated_at
? ` ? `
@@ -531,23 +529,26 @@ $(() => {
: "" : ""
} }
</div> </div>
<button type="button" id="goto-login-after-reset" class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
<button type="button" id="goto-login-after-reset"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
Перейти к входу Перейти к входу
</button> </button>
`); `);
$form.off("submit"); $form.off("submit");
$("#goto-login-after-reset").on("click", function () { $("#goto-login-after-reset").on("click", function () {
location.reload(); location.reload();
setTimeout(() => { setTimeout(() => {
showForm("#login-form"); showForm(SELECTORS.loginForm);
$("#login-username").val(username); $(SELECTORS.usernameLogin).val(username);
}, 100); }, 100);
}); });
} };
initLoginState(); initLoginState();
const widget = $(SELECTORS.capWidget).get(0);
if (widget && widget.shadowRoot) {
const style = document.createElement("style");
style.textContent = `.credits { right: 20px !important; }`;
$(widget.shadowRoot).append(style);
}
}); });
+314 -213
View File
@@ -1,4 +1,25 @@
$(document).ready(() => { $(() => {
const SELECTORS = {
booksContainer: "#books-container",
paginationContainer: "#pagination-container",
bookSearchInput: "#book-search-input",
authorSearchInput: "#author-search-input",
authorDropdown: "#author-dropdown",
selectedAuthorsContainer: "#selected-authors-container",
genresList: "#genres-list",
applyFiltersBtn: "#apply-filters-btn",
resetFiltersBtn: "#reset-filters-btn",
adminActions: "#admin-actions",
pagesMin: "#pages-min",
pagesMax: "#pages-max",
};
const TEMPLATES = {
bookCard: document.getElementById("book-card-template"),
genreBadge: document.getElementById("genre-badge-template"),
emptyState: document.getElementById("empty-state-template"),
};
const STATUS_CONFIG = { const STATUS_CONFIG = {
active: { active: {
label: "Доступна", label: "Доступна",
@@ -27,6 +48,40 @@ $(document).ready(() => {
}, },
}; };
const PAGE_SIZE = 12;
const STATE = {
selectedAuthors: new Map(),
selectedGenres: new Map(),
currentPage: 1,
totalBooks: 0,
};
const urlParams = new URLSearchParams(window.location.search);
const INITIAL_FILTERS = {
search: urlParams.get("q") || "",
authorIds: new Set(urlParams.getAll("author_id")),
genreIds: new Set(urlParams.getAll("genre_id")),
};
if (INITIAL_FILTERS.search) {
$(SELECTORS.bookSearchInput).val(INITIAL_FILTERS.search);
}
const LOADING_SKELETON_HTML = `<div class="space-y-4">${Array.from(
{ length: 3 },
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
</div>
`,
).join("")}</div>`;
const USER_CAN_MANAGE =
typeof window.canManage === "function" && window.canManage();
function getStatusConfig(status) { function getStatusConfig(status) {
return ( return (
STATUS_CONFIG[status] || { STATUS_CONFIG[status] || {
@@ -37,224 +92,191 @@ $(document).ready(() => {
); );
} }
let selectedAuthors = new Map();
let selectedGenres = new Map();
let currentPage = 1;
let pageSize = 12;
let totalBooks = 0;
const urlParams = new URLSearchParams(window.location.search);
const genreIdsFromUrl = urlParams.getAll("genre_id");
const authorIdsFromUrl = urlParams.getAll("author_id");
const searchFromUrl = urlParams.get("q");
if (searchFromUrl) $("#book-search-input").val(searchFromUrl);
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
.then(([authorsData, genresData]) => {
initAuthors(authorsData.authors);
initGenres(genresData.genres);
initializeAuthorDropdownListeners();
renderChips();
loadBooks();
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки данных", "error");
});
function initAuthors(authors) { function initAuthors(authors) {
const $dropdown = $("#author-dropdown"); const $dropdown = $(SELECTORS.authorDropdown);
authors.forEach((author) => { const fragment = document.createDocumentFragment();
$("<div>")
.addClass(
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors",
)
.attr("data-id", author.id)
.attr("data-name", author.name)
.text(author.name)
.appendTo($dropdown);
if (authorIdsFromUrl.includes(String(author.id))) { authors.forEach((author) => {
selectedAuthors.set(author.id, author.name); const item = document.createElement("div");
item.className =
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors";
item.dataset.id = author.id;
item.dataset.name = author.name;
item.textContent = author.name;
fragment.appendChild(item);
if (INITIAL_FILTERS.authorIds.has(String(author.id))) {
STATE.selectedAuthors.set(author.id, author.name);
} }
}); });
$dropdown.empty().append(fragment);
} }
function initGenres(genres) { function initGenres(genres) {
const $list = $("#genres-list"); const $list = $(SELECTORS.genresList);
genres.forEach((genre) => { const canManage = USER_CAN_MANAGE;
const isChecked = genreIdsFromUrl.includes(String(genre.id)); let html = "";
if (isChecked) selectedGenres.set(genre.id, genre.name);
const editButton = window.canManage() genres.forEach((genre) => {
const isChecked = INITIAL_FILTERS.genreIds.has(String(genre.id));
if (isChecked) {
STATE.selectedGenres.set(genre.id, genre.name);
}
const safeName = Utils.escapeHtml(genre.name);
const editButton = canManage
? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр"> ? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg> </svg>
</a>` </a>`
: ""; : "";
html += `
$list.append(`
<li class="mb-1"> <li class="mb-1">
<div class="flex items-center"> <div class="flex items-center">
<label class="custom-checkbox flex items-center flex-1"> <label class="custom-checkbox flex items-center flex-1">
<input type="checkbox" data-id="${genre.id}" data-name="${Utils.escapeHtml(genre.name)}" ${isChecked ? "checked" : ""} /> <input type="checkbox" data-id="${genre.id}" data-name="${safeName}" ${
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)} isChecked ? "checked" : ""
} />
<span class="checkmark"></span> ${safeName}
</label> </label>
${editButton} ${editButton}
</div> </div>
</li> </li>
`); `;
}); });
$list.on("change", "input", function () { $list.html(html);
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
});
$list.on("change", "input", function () { $list.on("change", "input", function () {
const id = parseInt($(this).data("id")); const id = parseInt($(this).data("id"), 10);
const name = $(this).data("name"); const name = $(this).data("name");
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id); if (this.checked) {
STATE.selectedGenres.set(id, name);
} else {
STATE.selectedGenres.delete(id);
}
}); });
} }
function getTotalPages() {
return Math.max(1, Math.ceil(STATE.totalBooks / PAGE_SIZE));
}
function loadBooks() { function loadBooks() {
const searchQuery = $("#book-search-input").val().trim(); const searchQuery = $(SELECTORS.bookSearchInput).val().trim();
const params = new URLSearchParams(); const $minPages = $(SELECTORS.pagesMin);
const $maxPages = $(SELECTORS.pagesMax);
params.append("q", searchQuery); const minPages = $minPages.length ? $minPages.val() : "";
selectedAuthors.forEach((_, id) => params.append("author_ids", id)); const maxPages = $maxPages.length ? $maxPages.val() : "";
selectedGenres.forEach((_, id) => params.append("genre_ids", id));
const apiParams = new URLSearchParams();
const browserParams = new URLSearchParams(); const browserParams = new URLSearchParams();
browserParams.append("q", searchQuery);
selectedAuthors.forEach((_, id) => browserParams.append("author_id", id)); if (searchQuery) {
selectedGenres.forEach((_, id) => browserParams.append("genre_id", id)); apiParams.append("q", searchQuery);
browserParams.append("q", searchQuery);
}
if (minPages && minPages > 0) {
apiParams.append("min_page_count", minPages);
browserParams.append("min_page_count", minPages);
}
if (maxPages && maxPages < 2000) {
apiParams.append("max_page_count", maxPages);
browserParams.append("max_page_count", maxPages);
}
STATE.selectedAuthors.forEach((_, id) => {
apiParams.append("author_ids", id);
browserParams.append("author_id", id);
});
STATE.selectedGenres.forEach((_, id) => {
apiParams.append("genre_ids", id);
browserParams.append("genre_id", id);
});
apiParams.append("page", STATE.currentPage);
apiParams.append("size", PAGE_SIZE);
const newUrl = const newUrl =
window.location.pathname + window.location.pathname +
(browserParams.toString() ? `?${browserParams.toString()}` : ""); (browserParams.toString() ? `?${browserParams.toString()}` : "");
window.history.replaceState({}, "", newUrl); window.history.replaceState({}, "", newUrl);
params.append("page", currentPage);
params.append("size", pageSize);
showLoadingState(); showLoadingState();
Api.get(`/api/books/filter?${params.toString()}`) Api.get(`/api/books/filter?${apiParams.toString()}`)
.then((data) => { .then((data) => {
totalBooks = data.total; STATE.totalBooks = data.total || 0;
renderBooks(data.books); renderBooks(data.books || []);
renderPagination(); renderPagination();
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
Utils.showToast("Не удалось загрузить книги", "error"); Utils.showToast("Не удалось загрузить книги", "error");
$("#books-container").html( $(SELECTORS.booksContainer).html(
document.getElementById("empty-state-template").innerHTML, TEMPLATES.emptyState.content.cloneNode(true),
); );
}); });
} }
function renderBooks(books) { function renderBooks(books) {
const $container = $("#books-container"); const $container = $(SELECTORS.booksContainer);
const tpl = document.getElementById("book-card-template");
const emptyTpl = document.getElementById("empty-state-template");
const badgeTpl = document.getElementById("genre-badge-template");
$container.empty(); $container.empty();
if (books.length === 0) { if (!books.length) {
$container.append(emptyTpl.content.cloneNode(true)); $container.append(TEMPLATES.emptyState.content.cloneNode(true));
return; return;
} }
books.forEach((book) => { const fragment = document.createDocumentFragment();
const clone = tpl.content.cloneNode(true);
const card = clone.querySelector(".book-card");
books.forEach((book) => {
const clone = TEMPLATES.bookCard.content.cloneNode(true);
const card = clone.querySelector(".book-card");
card.dataset.id = book.id; card.dataset.id = book.id;
clone.querySelector(".book-title").textContent = book.title;
clone.querySelector(".book-authors").textContent = const titleEl = clone.querySelector(".book-title");
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен"; const authorsEl = clone.querySelector(".book-authors");
const pageCountWrapper = clone.querySelector(".book-page-count");
const pageCountValue =
pageCountWrapper.querySelector(".page-count-value");
const descEl = clone.querySelector(".book-desc");
const statusEl = clone.querySelector(".book-status");
const genresContainer = clone.querySelector(".book-genres");
titleEl.textContent = book.title;
authorsEl.textContent =
(book.authors && book.authors.map((a) => a.name).join(", ")) ||
"Автор неизвестен";
if (book.page_count && book.page_count > 0) { if (book.page_count && book.page_count > 0) {
const pageEl = clone.querySelector(".book-page-count"); pageCountValue.textContent = book.page_count;
pageEl.querySelector(".page-count-value").textContent = book.page_count; pageCountWrapper.classList.remove("hidden");
pageEl.classList.remove("hidden");
} }
clone.querySelector(".book-desc").textContent = book.description || "";
descEl.textContent = book.description || "";
const statusConfig = getStatusConfig(book.status); const statusConfig = getStatusConfig(book.status);
const statusEl = clone.querySelector(".book-status");
statusEl.textContent = statusConfig.label; statusEl.textContent = statusConfig.label;
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass); statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
const genresContainer = clone.querySelector(".book-genres"); if (Array.isArray(book.genres)) {
book.genres.forEach((g) => { book.genres.forEach((g) => {
const badge = badgeTpl.content.cloneNode(true); const badge = TEMPLATES.genreBadge.content.cloneNode(true);
const span = badge.querySelector("span"); const span = badge.querySelector("span");
span.textContent = g.name; span.textContent = g.name;
genresContainer.appendChild(badge); genresContainer.appendChild(badge);
}); });
$container.append(clone);
});
}
function renderPagination() {
$("#pagination-container").empty();
const totalPages = Math.ceil(totalBooks / pageSize);
if (totalPages <= 1) return;
const $pagination = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => {
if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2 text-gray-500">...</span>`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
`);
} }
fragment.appendChild(clone);
}); });
$("#pagination-container").append($pagination); $container.append(fragment);
$("#prev-page").on("click", function () {
if (currentPage > 1) {
currentPage--;
loadBooks();
scrollToTop();
}
});
$("#next-page").on("click", function () {
if (currentPage < totalPages) {
currentPage++;
loadBooks();
scrollToTop();
}
});
$(".page-btn").on("click", function () {
const page = parseInt($(this).data("page"));
if (page !== currentPage) {
currentPage = page;
loadBooks();
scrollToTop();
}
});
} }
function generatePageNumbers(current, total) { function generatePageNumbers(current, total) {
@@ -274,49 +296,81 @@ $(document).ready(() => {
return pages; return pages;
} }
function renderPagination() {
const totalPages = getTotalPages();
const $container = $(SELECTORS.paginationContainer);
$container.empty();
if (totalPages <= 1) {
return;
}
const pages = generatePageNumbers(STATE.currentPage, totalPages);
let pagesHtml = "";
pages.forEach((page) => {
if (page === "...") {
pagesHtml += `<span class="px-3 py-2 text-gray-500">...</span>`;
} else {
const isActive = page === STATE.currentPage;
pagesHtml += `<button class="page-btn px-3 py-2 rounded-lg transition-colors ${
isActive
? "bg-gray-600 text-white"
: "bg-white border border-gray-300 hover:bg-gray-50"
}" data-page="${page}">${page}</button>`;
}
});
const html = `
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
STATE.currentPage === 1 ? "disabled" : ""
}>&larr;</button>
<div id="page-numbers" class="flex gap-1">${pagesHtml}</div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
STATE.currentPage === totalPages ? "disabled" : ""
}>&rarr;</button>
</div>
`;
$container.html(html);
}
function scrollToTop() { function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
function showLoadingState() { function showLoadingState() {
$("#books-container").html(` $(SELECTORS.booksContainer).html(LOADING_SKELETON_HTML);
<div class="space-y-4">
${Array(3)
.fill()
.map(
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
</div>
`,
)
.join("")}
</div>
`);
} }
function renderChips() { function renderSelectedAuthors() {
const $container = $("#selected-authors-container"); const $container = $(SELECTORS.selectedAuthorsContainer);
const $dropdown = $("#author-dropdown"); const $dropdown = $(SELECTORS.authorDropdown);
$container.empty(); $container.empty();
selectedAuthors.forEach((name, id) => { const fragment = document.createDocumentFragment();
$(`<span class="author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)} STATE.selectedAuthors.forEach((name, id) => {
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}"> const wrapper = document.createElement("span");
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> wrapper.className =
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> "author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full";
</svg> wrapper.innerHTML = `
</button> ${Utils.escapeHtml(name)}
</span>`).appendTo($container); <button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
`;
fragment.appendChild(wrapper);
}); });
$container.append(fragment);
$dropdown.find(".author-item").each(function () { $dropdown.find(".author-item").each(function () {
const id = parseInt($(this).data("id")); const id = parseInt($(this).data("id"), 10);
if (selectedAuthors.has(id)) { if (STATE.selectedAuthors.has(id)) {
$(this) $(this)
.addClass("bg-gray-200 text-gray-900 font-semibold") .addClass("bg-gray-200 text-gray-900 font-semibold")
.removeClass("hover:bg-gray-100"); .removeClass("hover:bg-gray-100");
@@ -329,11 +383,11 @@ $(document).ready(() => {
} }
function initializeAuthorDropdownListeners() { function initializeAuthorDropdownListeners() {
const $input = $("#author-search-input"); const $input = $(SELECTORS.authorSearchInput);
const $dropdown = $("#author-dropdown"); const $dropdown = $(SELECTORS.authorDropdown);
const $container = $("#selected-authors-container"); const $container = $(SELECTORS.selectedAuthorsContainer);
$input.on("focus", function () { $input.on("focus", () => {
$dropdown.removeClass("hidden"); $dropdown.removeClass("hidden");
}); });
@@ -349,7 +403,7 @@ $(document).ready(() => {
$(document).on("click", function (e) { $(document).on("click", function (e) {
if ( if (
!$(e.target).closest( !$(e.target).closest(
"#author-search-input, #author-dropdown, #selected-authors-container", `${SELECTORS.authorSearchInput}, ${SELECTORS.authorDropdown}, ${SELECTORS.selectedAuthorsContainer}`,
).length ).length
) { ) {
$dropdown.addClass("hidden"); $dropdown.addClass("hidden");
@@ -358,61 +412,108 @@ $(document).ready(() => {
$dropdown.on("click", ".author-item", function (e) { $dropdown.on("click", ".author-item", function (e) {
e.stopPropagation(); e.stopPropagation();
const id = parseInt($(this).data("id")); const id = parseInt($(this).data("id"), 10);
const name = $(this).data("name"); const name = $(this).data("name");
if (selectedAuthors.has(id)) { if (STATE.selectedAuthors.has(id)) {
selectedAuthors.delete(id); STATE.selectedAuthors.delete(id);
} else { } else {
selectedAuthors.set(id, name); STATE.selectedAuthors.set(id, name);
} }
$input.val(""); $input.val("");
$dropdown.find(".author-item").show(); $dropdown.find(".author-item").show();
renderChips(); renderSelectedAuthors();
$input[0].focus(); $input[0].focus();
}); });
$container.on("click", ".remove-author", function (e) { $container.on("click", ".remove-author", function (e) {
e.stopPropagation(); e.stopPropagation();
const id = parseInt($(this).data("id")); const id = parseInt($(this).data("id"), 10);
selectedAuthors.delete(id); STATE.selectedAuthors.delete(id);
renderChips(); renderSelectedAuthors();
}); });
} }
$("#books-container").on("click", ".book-card", function () { $(SELECTORS.booksContainer).on("click", ".book-card", function () {
window.location.href = `/book/${$(this).data("id")}`; const id = $(this).data("id");
if (id) {
window.location.href = `/book/${id}`;
}
}); });
$("#apply-filters-btn").on("click", function () { $(SELECTORS.applyFiltersBtn).on("click", function () {
currentPage = 1; STATE.currentPage = 1;
loadBooks(); loadBooks();
}); });
$("#reset-filters-btn").on("click", function () { $(SELECTORS.resetFiltersBtn).on("click", function () {
$("#book-search-input").val(""); $(SELECTORS.bookSearchInput).val("");
selectedAuthors.clear(); STATE.selectedAuthors.clear();
selectedGenres.clear(); STATE.selectedGenres.clear();
$("#genres-list input").prop("checked", false); $(`${SELECTORS.genresList} input`).prop("checked", false);
renderChips();
currentPage = 1; const $min = $(SELECTORS.pagesMin);
const $max = $(SELECTORS.pagesMax);
if ($min.length && $max.length) {
const minDefault = $min.attr("min");
const maxDefault = $max.attr("max");
if (minDefault !== undefined) $min.val(minDefault).trigger("input");
if (maxDefault !== undefined) $max.val(maxDefault).trigger("input");
}
renderSelectedAuthors();
STATE.currentPage = 1;
loadBooks(); loadBooks();
}); });
$("#book-search-input").on("keypress", function (e) { $(SELECTORS.bookSearchInput).on("keypress", function (e) {
if (e.which === 13) { if (e.which === 13) {
currentPage = 1; STATE.currentPage = 1;
loadBooks(); loadBooks();
} }
}); });
function showAdminControls() { $(SELECTORS.paginationContainer).on("click", "#prev-page", function () {
if (window.canManage()) { if (STATE.currentPage > 1) {
$("#admin-actions").removeClass("hidden"); STATE.currentPage -= 1;
loadBooks();
scrollToTop();
} }
});
$(SELECTORS.paginationContainer).on("click", "#next-page", function () {
const totalPages = getTotalPages();
if (STATE.currentPage < totalPages) {
STATE.currentPage += 1;
loadBooks();
scrollToTop();
}
});
$(SELECTORS.paginationContainer).on("click", ".page-btn", function () {
const page = parseInt($(this).data("page"), 10);
if (page && page !== STATE.currentPage) {
STATE.currentPage = page;
loadBooks();
scrollToTop();
}
});
if (USER_CAN_MANAGE) {
$(SELECTORS.adminActions).removeClass("hidden");
} }
showAdminControls(); Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
setTimeout(showAdminControls, 100); .then(([authorsData, genresData]) => {
initAuthors(authorsData.authors || []);
initGenres(genresData.genres || []);
initializeAuthorDropdownListeners();
renderSelectedAuthors();
loadBooks();
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки данных", "error");
});
}); });
+22
View File
@@ -184,6 +184,27 @@
</p> </p>
</div> </div>
<div class="mb-4">
<cap-widget id="cap"
data-cap-api-endpoint="/api/cap/"
style="
--cap-widget-width: 100%;
--cap-background: #fdfdfd;
--cap-border-color: #d1d5db;
--cap-border-radius: 8px;
--cap-widget-height: auto;
--cap-color: #212121;
--cap-checkbox-size: 32px;
--cap-checkbox-border: 1.5px dashed #d1d5db;
--cap-checkbox-border-radius: 6px;
--cap-checkbox-background: #fafafa;
--cap-checkbox-margin: 2px;
--cap-spinner-color: #4b5563;
--cap-spinner-background-color: #eee;
--cap-spinner-thickness: 5px;"
></cap-widget>
</div>
<button type="submit" id="register-submit" <button type="submit" id="register-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed"> class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
Зарегистрироваться Зарегистрироваться
@@ -320,5 +341,6 @@
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
<script src="/static/page/auth.js"></script> <script src="/static/page/auth.js"></script>
{% endblock %} {% endblock %}
+106
View File
@@ -1,5 +1,38 @@
{% extends "base.html" %} {% block title %}LiB - Книги{% endblock %} {% extends "base.html" %} {% block title %}LiB - Книги{% endblock %}
{% block content %} {% block content %}
<style>
.range-double {
height: 0;
pointer-events: none;
appearance: none;
-webkit-appearance: none;
}
.range-double::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 8px;
height: 8px;
border-radius: 100%;
background: #4b5563;
border: 1px solid #ffffff;
box-shadow: 0 0 0 1px #4b5563;
cursor: pointer;
pointer-events: auto;
}
.range-double::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 100%;
background: #4b5563;
border: 1px solid #ffffff;
box-shadow: 0 0 0 1px #4b5563;
cursor: pointer;
pointer-events: auto;
}
.range-double::-moz-range-track {
background: transparent;
}
</style>
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6"> <div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
<aside class="w-full md:w-1/4"> <aside class="w-full md:w-1/4">
<div <div
@@ -88,6 +121,49 @@
</svg> </svg>
</div> </div>
</div> </div>
<div
x-data="pagesSlider(0, 2000, 10)"
class="bg-white p-4 rounded-lg shadow-md mb-6"
>
<h2 class="text-xl font-bold mb-4">Страниц</h2>
<div class="flex justify-between text-xs text-gray-500 mb-2">
<span>От: <span id="pages-min-value" x-text="minValue"></span></span>
<span>До: <span id="pages-max-value" x-text="maxValue"></span></span>
</div>
<div class="relative mt-4 mb-6">
<div class="absolute top-1/2 -translate-y-1/2 w-full h-1 bg-gray-200 rounded-full"></div>
<div
id="pages-range-progress"
class="absolute top-1/2 -translate-y-1/2 h-1 bg-gray-600 rounded-full"
:style="{ left: leftPercent + '%', right: rightPercent + '%' }"
></div>
<input
id="pages-min"
type="range"
:min="min"
:max="max"
x-model.number="minValue"
@input="onMinInput()"
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
/>
<input
id="pages-max"
type="range"
:min="min"
:max="max"
x-model.number="maxValue"
@input="onMaxInput()"
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
/>
</div>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-6"> <div class="bg-white p-4 rounded-lg shadow-md mb-6">
<h2 class="text-xl font-bold mb-4">Авторы</h2> <h2 class="text-xl font-bold mb-4">Авторы</h2>
<div <div
@@ -192,4 +268,34 @@
</template> </template>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/page/books.js"></script> <script src="/static/page/books.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('pagesSlider', (min, max, gap) => ({
min,
max,
gap,
minValue: min,
maxValue: max,
// проценты для заливки
get leftPercent() {
return (this.minValue - this.min) * 100 / (this.max - this.min);
},
get rightPercent() {
return 100 - (this.maxValue - this.min) * 100 / (this.max - this.min);
},
onMinInput() {
if (this.maxValue - this.minValue < this.gap) {
this.minValue = this.maxValue - this.gap;
}
},
onMaxInput() {
if (this.maxValue - this.minValue < this.gap) {
this.maxValue = this.minValue + this.gap;
}
}
}));
});
</script>
{% endblock %} {% endblock %}
-6
View File
@@ -1,6 +0,0 @@
def main():
print("Hello from libraryapi!")
if __name__ == "__main__":
main()
+2
View File
@@ -21,6 +21,8 @@ dependencies = [
"aiofiles>=25.1.0", "aiofiles>=25.1.0",
"qrcode[pil]>=8.2", "qrcode[pil]>=8.2",
"pyotp>=2.9.0", "pyotp>=2.9.0",
"slowapi>=0.1.9",
"limits>=5.6.0",
] ]
[dependency-groups] [dependency-groups]
Generated
+111
View File
@@ -278,6 +278,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
] ]
[[package]]
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]] [[package]]
name = "dill" name = "dill"
version = "0.4.0" version = "0.4.0"
@@ -627,6 +639,7 @@ dependencies = [
{ name = "fastapi", extra = ["all"] }, { name = "fastapi", extra = ["all"] },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "json-log-formatter" }, { name = "json-log-formatter" },
{ name = "limits" },
{ name = "passlib", extra = ["argon2"] }, { name = "passlib", extra = ["argon2"] },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
@@ -634,6 +647,7 @@ dependencies = [
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] }, { name = "python-jose", extra = ["cryptography"] },
{ name = "qrcode", extra = ["pil"] }, { name = "qrcode", extra = ["pil"] },
{ name = "slowapi" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
{ name = "toml" }, { name = "toml" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
@@ -655,6 +669,7 @@ requires-dist = [
{ name = "fastapi", extras = ["all"], specifier = ">=0.115.14" }, { name = "fastapi", extras = ["all"], specifier = ">=0.115.14" },
{ name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", specifier = ">=3.1.6" },
{ name = "json-log-formatter", specifier = ">=1.1.1" }, { name = "json-log-formatter", specifier = ">=1.1.1" },
{ name = "limits", specifier = ">=5.6.0" },
{ name = "passlib", extras = ["argon2"], specifier = ">=1.7.4" }, { name = "passlib", extras = ["argon2"], specifier = ">=1.7.4" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.5" }, { name = "pydantic", extras = ["email"], specifier = ">=2.12.5" },
@@ -662,6 +677,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=0.21.1" }, { name = "python-dotenv", specifier = ">=0.21.1" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
{ name = "slowapi", specifier = ">=0.1.9" },
{ name = "sqlmodel", specifier = ">=0.0.31" }, { name = "sqlmodel", specifier = ">=0.0.31" },
{ name = "toml", specifier = ">=0.10.2" }, { name = "toml", specifier = ">=0.10.2" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" },
@@ -676,6 +692,20 @@ dev = [
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },
] ]
[[package]]
name = "limits"
version = "5.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5", size = 182984, upload-time = "2025-09-29T17:15:22.689Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604, upload-time = "2025-09-29T17:15:18.419Z" },
]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.10" version = "1.3.10"
@@ -1451,6 +1481,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
] ]
[[package]]
name = "slowapi"
version = "0.1.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "limits" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" },
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.45" version = "2.0.45"
@@ -1796,3 +1838,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
] ]
[[package]]
name = "wrapt"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" },
{ url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" },
{ url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" },
{ url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" },
{ url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" },
{ url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" },
{ url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" },
{ url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" },
{ url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" },
{ url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" },
{ url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" },
{ url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" },
{ url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" },
{ url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" },
{ url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" },
{ url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" },
{ url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" },
{ url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" },
{ url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" },
{ url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" },
{ url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" },
{ url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" },
{ url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" },
{ url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" },
{ url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" },
{ url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" },
{ url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" },
{ url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" },
{ url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" },
{ url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" },
{ url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" },
{ url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" },
{ url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" },
{ url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" },
{ url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" },
{ url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" },
{ url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" },
{ url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" },
{ url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" },
{ url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" },
{ url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" },
{ url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" },
{ url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" },
{ url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" },
{ url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" },
{ url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" },
{ url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" },
{ url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" },
{ url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" },
{ url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" },
{ url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" },
{ url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" },
{ url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" },
{ url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" },
]