mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Добавление catpcha при регистрации, фильтрация по количеству страниц
This commit is contained in:
+11
-2
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -64,13 +69,14 @@ def get_related(
|
|||||||
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
apiParams.append("q", searchQuery);
|
||||||
browserParams.append("q", searchQuery);
|
browserParams.append("q", searchQuery);
|
||||||
selectedAuthors.forEach((_, id) => browserParams.append("author_id", id));
|
}
|
||||||
selectedGenres.forEach((_, id) => browserParams.append("genre_id", id));
|
|
||||||
|
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() {
|
fragment.appendChild(clone);
|
||||||
$("#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" : ""}>←</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" : ""}>→</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>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#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" : ""
|
||||||
|
}>←</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" : ""
|
||||||
|
}>→</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">
|
|
||||||
|
STATE.selectedAuthors.forEach((name, id) => {
|
||||||
|
const wrapper = document.createElement("span");
|
||||||
|
wrapper.className =
|
||||||
|
"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";
|
||||||
|
wrapper.innerHTML = `
|
||||||
${Utils.escapeHtml(name)}
|
${Utils.escapeHtml(name)}
|
||||||
<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}">
|
<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">
|
<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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</span>`).appendTo($container);
|
`;
|
||||||
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
def main():
|
|
||||||
print("Hello from libraryapi!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user