Compare commits

..

9 Commits

43 changed files with 896 additions and 285 deletions
-43
View File
@@ -1,43 +0,0 @@
# Postgres
POSTGRES_HOST="localhost"
POSTGRES_PORT="5432"
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="lib"
# Ollama
OLLAMA_URL="http://localhost:11434"
OLLAMA_MAX_LOADED_MODELS=1
OLLAMA_NUM_THREADS=4
OLLAMA_KEEP_ALIVE=5m
# Default admin account
# DEFAULT_ADMIN_USERNAME="admin"
# DEFAULT_ADMIN_EMAIL="admin@example.com"
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
SECRET_KEY="your-secret-key-change-in-production"
# JWT
ALGORITHM="HS256"
REFRESH_TOKEN_EXPIRE_DAYS="7"
ACCESS_TOKEN_EXPIRE_MINUTES="15"
PARTIAL_TOKEN_EXPIRE_MINUTES="5"
# Hash
ARGON2_TYPE="id"
ARGON2_TIME_COST="3"
ARGON2_MEMORY_COST="65536"
ARGON2_PARALLELISM="4"
ARGON2_SALT_LENGTH="16"
ARGON2_HASH_LENGTH="48"
# Recovery codes
RECOVERY_CODES_COUNT="10"
RECOVERY_CODE_SEGMENTS="4"
RECOVERY_CODE_SEGMENT_BYTES="2"
RECOVERY_MIN_REMAINING_WARNING="3"
RECOVERY_MAX_AGE_DAYS="365"
# TOTP_2FA
TOTP_ISSUER="LiB"
TOTP_VALID_WINDOW="1"
Vendored
+1
View File
@@ -1,4 +1,5 @@
.env
library_service/static/books/
*.log
# Byte-compiled / optimized / DLL files
+23 -22
View File
@@ -1,9 +1,9 @@
# Postgres
POSTGRES_HOST="db"
POSTGRES_PORT="5432"
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="lib"
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=lib
REMOTE_HOST=
REMOTE_PORT=
NODE_ID=
@@ -19,28 +19,29 @@ DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_EMAIL="admin@example.com"
DEFAULT_ADMIN_PASSWORD="Password12345"
SECRET_KEY="your-secret-key-change-in-production"
DOMAIN="mydomain.com"
# JWT
ALGORITHM="HS256"
REFRESH_TOKEN_EXPIRE_DAYS="7"
ACCESS_TOKEN_EXPIRE_MINUTES="15"
PARTIAL_TOKEN_EXPIRE_MINUTES="5"
ALGORITHM=HS256
REFRESH_TOKEN_EXPIRE_DAYS=7
ACCESS_TOKEN_EXPIRE_MINUTES=15
PARTIAL_TOKEN_EXPIRE_MINUTES=5
# Hash
ARGON2_TYPE="id"
ARGON2_TIME_COST="3"
ARGON2_MEMORY_COST="65536"
ARGON2_PARALLELISM="4"
ARGON2_SALT_LENGTH="16"
ARGON2_HASH_LENGTH="48"
ARGON2_TYPE=id
ARGON2_TIME_COST=3
ARGON2_MEMORY_COST=65536
ARGON2_PARALLELISM=4
ARGON2_SALT_LENGTH=16
ARGON2_HASH_LENGTH=48
# Recovery codes
RECOVERY_CODES_COUNT="10"
RECOVERY_CODE_SEGMENTS="4"
RECOVERY_CODE_SEGMENT_BYTES="2"
RECOVERY_MIN_REMAINING_WARNING="3"
RECOVERY_MAX_AGE_DAYS="365"
RECOVERY_CODES_COUNT=10
RECOVERY_CODE_SEGMENTS=4
RECOVERY_CODE_SEGMENT_BYTES=2
RECOVERY_MIN_REMAINING_WARNING=3
RECOVERY_MAX_AGE_DAYS=365
# TOTP_2FA
TOTP_ISSUER="LiB"
TOTP_VALID_WINDOW="1"
TOTP_ISSUER=LiB
TOTP_VALID_WINDOW=1
+23 -22
View File
@@ -1,9 +1,9 @@
# Postgres
POSTGRES_HOST="localhost"
POSTGRES_PORT="5432"
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="lib"
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=lib
# Ollama
OLLAMA_URL="http://localhost:11434"
@@ -16,28 +16,29 @@ DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_EMAIL="admin@example.com"
DEFAULT_ADMIN_PASSWORD="Password12345"
SECRET_KEY="your-secret-key-change-in-production"
DOMAIN="mydomain.com"
# JWT
ALGORITHM="HS256"
REFRESH_TOKEN_EXPIRE_DAYS="7"
ACCESS_TOKEN_EXPIRE_MINUTES="15"
PARTIAL_TOKEN_EXPIRE_MINUTES="5"
ALGORITHM=HS256
REFRESH_TOKEN_EXPIRE_DAYS=7
ACCESS_TOKEN_EXPIRE_MINUTES=15
PARTIAL_TOKEN_EXPIRE_MINUTES=5
# Hash
ARGON2_TYPE="id"
ARGON2_TIME_COST="3"
ARGON2_MEMORY_COST="65536"
ARGON2_PARALLELISM="4"
ARGON2_SALT_LENGTH="16"
ARGON2_HASH_LENGTH="48"
ARGON2_TYPE=id
ARGON2_TIME_COST=3
ARGON2_MEMORY_COST=65536
ARGON2_PARALLELISM=4
ARGON2_SALT_LENGTH=16
ARGON2_HASH_LENGTH=48
# Recovery codes
RECOVERY_CODES_COUNT="10"
RECOVERY_CODE_SEGMENTS="4"
RECOVERY_CODE_SEGMENT_BYTES="2"
RECOVERY_MIN_REMAINING_WARNING="3"
RECOVERY_MAX_AGE_DAYS="365"
RECOVERY_CODES_COUNT=10
RECOVERY_CODE_SEGMENTS=4
RECOVERY_CODE_SEGMENT_BYTES=2
RECOVERY_MIN_REMAINING_WARNING=3
RECOVERY_MAX_AGE_DAYS=365
# TOTP_2FA
TOTP_ISSUER="LiB"
TOTP_VALID_WINDOW="1"
TOTP_ISSUER=LiB
TOTP_VALID_WINDOW=1
+2 -1
View File
@@ -81,7 +81,7 @@ async def custom_not_found_handler(request: Request, exc: HTTPException):
content={"detail": "API endpoint not found", "path": path},
)
return await unknown(request)
return await unknown(request, app)
@app.middleware("http")
@@ -184,6 +184,7 @@ if __name__ == "__main__":
"library_service.main:app",
host="0.0.0.0",
port=8000,
proxy_headers=True,
forwarded_allow_ips="*",
log_config=LOGGING_CONFIG,
access_log=False,
+3 -1
View File
@@ -1,6 +1,7 @@
"""Модуль DB-моделей книг"""
from typing import TYPE_CHECKING, List
from uuid import UUID
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, String
@@ -26,7 +27,8 @@ class Book(BookBase, table=True):
sa_column=Column(String, nullable=False, default="active"),
description="Статус",
)
embedding: list[float] | None = Field(sa_column=Column(Vector(1024)))
embedding: list[float] | None = Field(sa_column=Column(Vector(1024)), description="Эмбэдинг для векторного поиска")
preview_id: UUID | None = Field(default=None, unique=True, index=True, description="UUID файла изображения")
authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink
)
+1 -3
View File
@@ -7,7 +7,7 @@ from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
from .token import Token, TokenData, PartialToken
from .token import TokenData
from .misc import (
AuthorWithBooks,
GenreWithBooks,
@@ -62,9 +62,7 @@ __all__ = [
"RoleUpdate",
"RoleRead",
"RoleList",
"Token",
"TokenData",
"PartialToken",
"TOTPSetupResponse",
"TOTPVerifyRequest",
"TOTPDisableRequest",
+2 -2
View File
@@ -11,8 +11,8 @@ class AuthorBase(SQLModel):
name: str = Field(description="Псевдоним")
model_config = ConfigDict( # pyright: ignore
json_schema_extra={"example": {"name": "author_name"}}
model_config = ConfigDict(
json_schema_extra={"example": {"name": "John Doe"}}
)
+1
View File
@@ -46,6 +46,7 @@ class BookRead(BookBase):
id: int = Field(description="Идентификатор")
status: BookStatus = Field(description="Статус")
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
class BookList(SQLModel):
+3
View File
@@ -39,6 +39,7 @@ class BookWithAuthors(SQLModel):
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
@@ -52,6 +53,7 @@ class BookWithGenres(SQLModel):
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
@@ -63,6 +65,7 @@ class BookWithAuthorsAndGenres(SQLModel):
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
+11
View File
@@ -2,6 +2,7 @@
from typing import List
from pydantic import ConfigDict
from sqlmodel import SQLModel, Field
@@ -12,6 +13,16 @@ class RoleBase(SQLModel):
description: str | None = Field(None, description="Описание")
payroll: int = Field(0, description="Оплата")
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "admin",
"description": "system administrator",
"payroll": 500,
}
}
)
class RoleCreate(RoleBase):
"""Модель роли для создания"""
+1 -17
View File
@@ -1,24 +1,8 @@
"""Модуль DTO-моделей токенов"""
"""Модуль DTO-модели токена"""
from sqlmodel import SQLModel, Field
class Token(SQLModel):
"""Модель токена"""
access_token: str = Field(description="Токен доступа")
token_type: str = Field("bearer", description="Тип токена")
refresh_token: str | None = Field(None, description="Токен обновления")
class PartialToken(SQLModel):
"""Частичный токен — для подтверждения 2FA"""
partial_token: str = Field(description="Частичный токен")
token_type: str = Field("partial", description="Тип токена")
requires_2fa: bool = Field(True, description="Требуется TOTP-код")
class TokenData(SQLModel):
"""Модель содержимого токена"""
+23 -20
View File
@@ -12,15 +12,12 @@ from sqlmodel import Session, select
from library_service.services import require_captcha
from library_service.models.db import Role, User
from library_service.models.dto import (
Token,
UserCreate,
UserRead,
UserUpdate,
UserList,
RoleRead,
RoleList,
Token,
PartialToken,
LoginResponse,
RecoveryCodeUse,
RegisterResponse,
@@ -147,11 +144,14 @@ def login(
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return LoginResponse(
access_token=create_access_token(
new_access_token = create_access_token(
data=token_data, expires_delta=access_token_expires
),
refresh_token=create_refresh_token(data=token_data),
)
new_refresh_token = create_refresh_token(data=token_data)
return LoginResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
requires_2fa=False,
)
@@ -159,7 +159,7 @@ def login(
@router.post(
"/refresh",
response_model=Token,
response_model=LoginResponse,
summary="Обновление токена",
description="Получение новой пары токенов, используя действующий Refresh токен",
)
@@ -190,19 +190,18 @@ def refresh_token(
detail="User is inactive",
)
token_data = {"sub": user.username, "user_id": user.id}
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
new_access_token = create_access_token(
data={"sub": user.username, "user_id": user.id},
expires_delta=access_token_expires,
)
new_refresh_token = create_refresh_token(
data={"sub": user.username, "user_id": user.id}
data=token_data, expires_delta=access_token_expires
)
new_refresh_token = create_refresh_token(data=token_data)
return Token(
return LoginResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
requires_2fa=False,
)
@@ -343,7 +342,7 @@ def disable_2fa(
@router.post(
"/2fa/verify",
response_model=Token,
response_model=LoginResponse,
summary="Верификация 2FA",
description="Завершает аутентификацию с помощью TOTP кода или резервного кода",
)
@@ -374,12 +373,16 @@ def verify_2fa(
token_data = {"sub": user.username, "user_id": user.id}
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return Token(
access_token=create_access_token(
new_access_token = create_access_token(
data=token_data, expires_delta=access_token_expires
),
refresh_token=create_refresh_token(data=token_data),
)
new_refresh_token = create_refresh_token(data=token_data)
return LoginResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
requires_2fa=False,
)
+125 -22
View File
@@ -1,4 +1,7 @@
"""Модуль работы с книгами"""
from library_service.services import transcode_image
import shutil
from uuid import uuid4
from pydantic import Field
from typing_extensions import Annotated
@@ -10,12 +13,12 @@ from sqlalchemy import text, case, distinct
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status, UploadFile, File
from ollama import Client
from sqlmodel import Session, select, col, func
from library_service.auth import RequireStaff
from library_service.settings import get_session, OLLAMA_URL
from library_service.settings import get_session, OLLAMA_URL, BOOKS_PREVIEW_DIR
from library_service.models.enums import BookStatus
from library_service.models.db import (
Author,
@@ -47,9 +50,9 @@ def close_active_loan(session: Session, book_id: int) -> None:
"""Закрывает активную выдачу книги при изменении статуса"""
active_loan = session.exec(
select(BookUserLink)
.where(BookUserLink.book_id == book_id)
.where(BookUserLink.returned_at == None) # noqa: E711
).first()
.where(BookUserLink.book_id == book_id) # ty: ignore
.where(BookUserLink.returned_at == None) # ty: ignore
).first() # ty: ignore
if active_loan:
active_loan.returned_at = datetime.now(timezone.utc)
@@ -72,19 +75,19 @@ def filter_books(
size: int = Query(20, gt=0, le=100),
):
statement = select(Book).options(
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding)
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) # ty: ignore
)
if min_page_count:
statement = statement.where(Book.page_count >= min_page_count)
statement = statement.where(Book.page_count >= min_page_count) # ty: ignore
if max_page_count:
statement = statement.where(Book.page_count <= max_page_count)
statement = statement.where(Book.page_count <= max_page_count) # ty: ignore
if author_ids:
statement = statement.where(
exists().where(
AuthorBookLink.book_id == Book.id,
AuthorBookLink.author_id.in_(author_ids),
AuthorBookLink.book_id == Book.id, # ty: ignore
AuthorBookLink.author_id.in_(author_ids), # ty: ignore
)
)
@@ -92,7 +95,7 @@ def filter_books(
for genre_id in genre_ids:
statement = statement.where(
exists().where(
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id # ty: ignore
)
)
@@ -101,13 +104,13 @@ def filter_books(
if q:
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"]
distance_col = Book.embedding.cosine_distance(emb)
statement = statement.where(Book.embedding.is_not(None))
distance_col = Book.embedding.cosine_distance(emb) # ty: ignore
statement = statement.where(Book.embedding.is_not(None)) # ty: ignore
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1)
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1) # ty: ignore
statement = statement.order_by(keyword_match, distance_col)
else:
statement = statement.order_by(Book.id)
statement = statement.order_by(Book.id) # ty: ignore
offset = (page - 1) * size
statement = statement.offset(offset).limit(size)
@@ -131,10 +134,18 @@ def create_book(
full_text = book.title + " " + book.description
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book = Book(**book.model_dump(), embedding=emb["embedding"])
session.add(db_book)
session.commit()
session.refresh(db_book)
return BookRead(**db_book.model_dump(exclude={"embedding"}))
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{db_book.preview_id}.png",
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
"webp": f"/static/books/{db_book.preview_id}.webp",
} if db_book.preview_id else {}
return BookRead(**book_data)
@router.get(
@@ -145,9 +156,20 @@ def create_book(
)
def read_books(session: Session = Depends(get_session)):
"""Возвращает список всех книг"""
books = session.exec(select(Book)).all()
books = session.exec(select(Book)).all() # ty: ignore
books_data = []
for book in books:
book_data = book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{book.preview_id}.png",
"jpeg": f"/static/books/{book.preview_id}.jpg",
"webp": f"/static/books/{book.preview_id}.webp",
} if book.preview_id else {}
books_data.append(book_data)
return BookList(
books=[BookRead(**book.model_dump(exclude={"embedding"})) for book in books],
books=[BookRead(**book_data) for book_data in books_data],
total=len(books),
)
@@ -170,18 +192,23 @@ def get_book(
)
authors = session.scalars(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) # ty: ignore
).all()
author_reads = [AuthorRead(**author.model_dump()) for author in authors]
genres = session.scalars(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) # ty: ignore
).all()
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres]
book_data = book.model_dump(exclude={"embedding"})
book_data = book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{book.preview_id}.png",
"jpeg": f"/static/books/{book.preview_id}.jpg",
"webp": f"/static/books/{book.preview_id}.webp",
} if book.preview_id else {}
book_data["authors"] = author_reads
book_data["genres"] = genre_reads
@@ -233,11 +260,21 @@ def update_book(
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book.embedding = emb["embedding"]
if book_update.page_count is not None:
db_book.page_count = book_update.page_count
session.add(db_book)
session.commit()
session.refresh(db_book)
return BookRead(**db_book.model_dump(exclude={"embedding"}))
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{db_book.preview_id}.png",
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
"webp": f"/static/books/{db_book.preview_id}.webp",
} if db_book.preview_id else {}
return BookRead(**book_data)
@router.delete(
@@ -267,3 +304,69 @@ def delete_book(
session.delete(book)
session.commit()
return book_read
@router.post("/{book_id}/preview")
async def upload_book_preview(
current_user: RequireStaff,
file: UploadFile = File(...),
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
if not (file.content_type or "").startswith("image/"):
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Image required")
if (file.size or 0) > 32 * 1024 * 1024:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB")
file_uuid= uuid4()
tmp_path = BOOKS_PREVIEW_DIR / f"{file_uuid}.upload"
with open(tmp_path, "wb") as f:
shutil.copyfileobj(file.file, f)
book = session.get(Book, book_id)
if not book:
tmp_path.unlink()
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
transcode_image(tmp_path)
tmp_path.unlink()
if book.preview_id:
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
if path.exists():
path.unlink(missing_ok=True)
book.preview_id = file_uuid
session.add(book)
session.commit()
return {
"preview": {
"png": f"/static/books/{file_uuid}.png",
"jpeg": f"/static/books/{file_uuid}.jpg",
"webp": f"/static/books/{file_uuid}.webp",
}
}
@router.delete("/{book_id}/preview")
async def remove_book_preview(
current_user: RequireStaff,
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
if book.preview_id:
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
if path.exists():
path.unlink(missing_ok=True)
book.preview_id = None
session.add(book)
session.commit()
return {"preview_urls": []}
+42 -37
View File
@@ -1,4 +1,6 @@
"""Модуль прочих эндпоинтов и веб-страниц"""
import os
import sys
from datetime import datetime
from pathlib import Path
@@ -31,115 +33,117 @@ def get_info(app) -> Dict:
"description": app.description.rsplit("|", 1)[0],
},
"server_time": datetime.now().isoformat(),
"domain": os.getenv("DOMAIN", ""),
}
@router.get("/", include_in_schema=False)
async def root(request: Request):
async def root(request: Request, app=Depends(lambda: get_app())):
"""Рендерит главную страницу"""
return templates.TemplateResponse(request, "index.html")
return templates.TemplateResponse(request, "index.html", get_info(app) | {"request": request, "title": "LiB - Библиотека"})
@router.get("/unknown", include_in_schema=False)
async def unknown(request: Request):
async def unknown(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу 404 ошибки"""
return templates.TemplateResponse(request, "unknown.html")
return templates.TemplateResponse(request, "unknown.html", get_info(app) | {"request": request, "title": "LiB - Страница не найдена"})
@router.get("/genre/create", include_in_schema=False)
async def create_genre(request: Request):
async def create_genre(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу создания жанра"""
return templates.TemplateResponse(request, "create_genre.html")
return templates.TemplateResponse(request, "create_genre.html", get_info(app) | {"request": request, "title": "LiB - Создать жанр"})
@router.get("/genre/{genre_id}/edit", include_in_schema=False)
async def edit_genre(request: Request, genre_id: int):
async def edit_genre(request: Request, genre_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования жанра"""
return templates.TemplateResponse(request, "edit_genre.html")
return templates.TemplateResponse(request, "edit_genre.html", get_info(app) | {"request": request, "title": "LiB - Редактировать жанр", "id": genre_id})
@router.get("/authors", include_in_schema=False)
async def authors(request: Request):
async def authors(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу списка авторов"""
return templates.TemplateResponse(request, "authors.html")
return templates.TemplateResponse(request, "authors.html", get_info(app) | {"request": request, "title": "LiB - Авторы"})
@router.get("/author/create", include_in_schema=False)
async def create_author(request: Request):
async def create_author(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу создания автора"""
return templates.TemplateResponse(request, "create_author.html")
return templates.TemplateResponse(request, "create_author.html", get_info(app) | {"request": request, "title": "LiB - Создать автора"})
@router.get("/author/{author_id}/edit", include_in_schema=False)
async def edit_author(request: Request, author_id: int):
async def edit_author(request: Request, author_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования автора"""
return templates.TemplateResponse(request, "edit_author.html")
return templates.TemplateResponse(request, "edit_author.html", get_info(app) | {"request": request, "title": "LiB - Редактировать автора", "id": author_id})
@router.get("/author/{author_id}", include_in_schema=False)
async def author(request: Request, author_id: int):
async def author(request: Request, author_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу просмотра автора"""
return templates.TemplateResponse(request, "author.html")
return templates.TemplateResponse(request, "author.html", get_info(app) | {"request": request, "title": "LiB - Автор", "id": author_id})
@router.get("/books", include_in_schema=False)
async def books(request: Request):
async def books(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу списка книг"""
return templates.TemplateResponse(request, "books.html")
return templates.TemplateResponse(request, "books.html", get_info(app) | {"request": request, "title": "LiB - Книги"})
@router.get("/book/create", include_in_schema=False)
async def create_book(request: Request):
async def create_book(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу создания книги"""
return templates.TemplateResponse(request, "create_book.html")
return templates.TemplateResponse(request, "create_book.html", get_info(app) | {"request": request, "title": "LiB - Создать книгу"})
@router.get("/book/{book_id}/edit", include_in_schema=False)
async def edit_book(request: Request, book_id: int):
async def edit_book(request: Request, book_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования книги"""
return templates.TemplateResponse(request, "edit_book.html")
return templates.TemplateResponse(request, "edit_book.html", get_info(app) | {"request": request, "title": "LiB - Редактировать книгу", "id": book_id})
@router.get("/book/{book_id}", include_in_schema=False)
async def book(request: Request, book_id: int):
async def book(request: Request, book_id: int, app=Depends(lambda: get_app()), session=Depends(get_session)):
"""Рендерит страницу просмотра книги"""
return templates.TemplateResponse(request, "book.html")
book = session.get(Book, book_id)
return templates.TemplateResponse(request, "book.html", get_info(app) | {"request": request, "title": "LiB - Книга", "id": book_id, "img": book.preview_id})
@router.get("/auth", include_in_schema=False)
async def auth(request: Request):
async def auth(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу авторизации"""
return templates.TemplateResponse(request, "auth.html")
return templates.TemplateResponse(request, "auth.html", get_info(app) | {"request": request, "title": "LiB - Авторизация"})
@router.get("/2fa", include_in_schema=False)
async def set2fa(request: Request):
async def set2fa(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу установки двухфакторной аутентификации"""
return templates.TemplateResponse(request, "2fa.html")
return templates.TemplateResponse(request, "2fa.html", get_info(app) | {"request": request, "title": "LiB - Двухфакторная аутентификация"})
@router.get("/profile", include_in_schema=False)
async def profile(request: Request):
async def profile(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу профиля пользователя"""
return templates.TemplateResponse(request, "profile.html")
return templates.TemplateResponse(request, "profile.html", get_info(app) | {"request": request, "title": "LiB - Профиль"})
@router.get("/users", include_in_schema=False)
async def users(request: Request):
async def users(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу управления пользователями"""
return templates.TemplateResponse(request, "users.html")
return templates.TemplateResponse(request, "users.html", get_info(app) | {"request": request, "title": "LiB - Пользователи"})
@router.get("/my-books", include_in_schema=False)
async def my_books(request: Request):
async def my_books(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу моих книг пользователя"""
return templates.TemplateResponse(request, "my_books.html")
return templates.TemplateResponse(request, "my_books.html", get_info(app) | {"request": request, "title": "LiB - Мои книги"})
@router.get("/analytics", include_in_schema=False)
async def analytics(request: Request):
async def analytics(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу аналитики выдач"""
return templates.TemplateResponse(request, "analytics.html")
return templates.TemplateResponse(request, "analytics.html", get_info(app) | {"request": request, "title": "LiB - Аналитика"})
@router.get("/favicon.ico", include_in_schema=False)
@@ -178,6 +182,7 @@ async def api_info(app=Depends(lambda: get_app())):
description="Возвращает схему базы данных с описаниями полей",
)
async def api_schema():
"""Возвращает информацию для создания er-диаграммы"""
return generator.generate()
@@ -186,7 +191,7 @@ async def api_schema():
summary="Статистика сервиса",
description="Возвращает статистическую информацию о системе",
)
async def api_stats(session: Session = Depends(get_session)):
async def api_stats(session=Depends(get_session)):
"""Возвращает статистику системы"""
authors = select(func.count()).select_from(Author)
books = select(func.count()).select_from(Book)
+2
View File
@@ -13,6 +13,7 @@ from .captcha import (
prng,
)
from .describe_er import SchemaGenerator
from .image_processing import transcode_image
__all__ = [
"limiter",
@@ -28,4 +29,5 @@ __all__ = [
"REDEEM_TTL",
"prng",
"SchemaGenerator",
"transcode_image",
]
@@ -0,0 +1,81 @@
from pathlib import Path
from PIL import Image
TARGET_RATIO = 5 / 7
def crop_image(img: Image.Image, target_ratio: float = TARGET_RATIO) -> Image.Image:
w, h = img.size
current_ratio = w / h
if current_ratio > target_ratio:
new_w = int(h * target_ratio)
left = (w - new_w) // 2
right = left + new_w
top = 0
bottom = h
else:
new_h = int(w / target_ratio)
top = (h - new_h) // 2
bottom = top + new_h
left = 0
right = w
return img.crop((left, top, right, bottom))
def transcode_image(
src_path: str | Path,
*,
jpeg_quality: int = 85,
webp_quality: int = 80,
webp_lossless: bool = False,
resize_to: tuple[int, int] | None = None,
):
src_path = Path(src_path)
if not src_path.exists():
raise FileNotFoundError(src_path)
stem = src_path.stem
folder = src_path.parent
img = Image.open(src_path).convert("RGBA")
img = crop_image(img)
if resize_to:
img = img.resize(resize_to, Image.LANCZOS)
png_path = folder / f"{stem}.png"
img.save(
png_path,
format="PNG",
optimize=True,
interlace=1,
)
jpg_path = folder / f"{stem}.jpg"
img.convert("RGB").save(
jpg_path,
format="JPEG",
quality=jpeg_quality,
progressive=True,
optimize=True,
subsampling="4:2:0",
)
webp_path = folder / f"{stem}.webp"
img.save(
webp_path,
format="WEBP",
quality=webp_quality,
lossless=webp_lossless,
method=6,
)
return {
"png": png_path,
"jpeg": jpg_path,
"webp": webp_path,
}
+3
View File
@@ -10,6 +10,9 @@ from toml import load
load_dotenv()
BOOKS_PREVIEW_DIR = Path(__file__).parent / "static" / "books"
BOOKS_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
with open("pyproject.toml", "r", encoding="utf-8") as f:
_pyproject = load(f)
+5
View File
@@ -6,6 +6,11 @@ $(document).ready(() => {
let currentSort = "name_asc";
loadAuthors();
const USER_CAN_MANAGE =
typeof window.canManage === "function" && window.canManage();
if (USER_CAN_MANAGE) {
$("#add-author-btn").removeClass("hidden");
}
function loadAuthors() {
showLoadingState();
+291
View File
@@ -34,6 +34,7 @@ $(document).ready(() => {
const pathParts = window.location.pathname.split("/");
const bookId = parseInt(pathParts[pathParts.length - 1]);
let isDraggingOver = false;
let currentBook = null;
let cachedUsers = null;
let selectedLoanUserId = null;
@@ -48,6 +49,28 @@ $(document).ready(() => {
}
loadBookData();
setupEventHandlers();
setupCoverUpload();
}
function getPreviewUrl(book) {
if (!book.preview_urls) {
return null;
}
const priorities = ["webp", "jpeg", "jpg", "png"];
for (const format of priorities) {
if (book.preview_urls[format]) {
return book.preview_urls[format];
}
}
const availableFormats = Object.keys(book.preview_urls);
if (availableFormats.length > 0) {
return book.preview_urls[availableFormats[0]];
}
return null;
}
function setupEventHandlers() {
@@ -75,6 +98,270 @@ $(document).ready(() => {
$("#loan-due-date").val(future.toISOString().split("T")[0]);
}
function setupCoverUpload() {
const $container = $("#book-cover-container");
const $fileInput = $("#cover-file-input");
$fileInput.on("change", function (e) {
const file = e.target.files[0];
if (file) {
uploadCover(file);
}
$(this).val("");
});
$container.on("dragenter", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
isDraggingOver = true;
showDropOverlay();
});
$container.on("dragover", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
isDraggingOver = true;
});
$container.on("dragleave", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
const rect = this.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
isDraggingOver = false;
hideDropOverlay();
}
});
$container.on("drop", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
isDraggingOver = false;
hideDropOverlay();
const files = e.dataTransfer?.files || [];
if (files.length > 0) {
const file = files[0];
if (!file.type.startsWith("image/")) {
Utils.showToast("Пожалуйста, загрузите изображение", "error");
return;
}
uploadCover(file);
}
});
}
function showDropOverlay() {
const $container = $("#book-cover-container");
$container.find(".drop-overlay").remove();
const $overlay = $(`
<div class="drop-overlay absolute inset-0 flex flex-col items-center justify-center z-20 pointer-events-none">
<div class="absolute inset-2 border-2 border-dashed border-gray-600 rounded-lg"></div>
<svg class="w-10 h-10 text-gray-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
<span class="text-gray-700 text-sm font-medium text-center px-4">Отпустите для загрузки</span>
</div>
`);
$container.append($overlay);
}
function hideDropOverlay() {
$("#book-cover-container .drop-overlay").remove();
}
async function uploadCover(file) {
const $container = $("#book-cover-container");
const maxSize = 32 * 1024 * 1024;
if (file.size > maxSize) {
Utils.showToast("Файл слишком большой. Максимум 32 MB", "error");
return;
}
if (!file.type.startsWith("image/")) {
Utils.showToast("Пожалуйста, загрузите изображение", "error");
return;
}
const $loader = $(`
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex flex-col items-center justify-center z-20">
<svg class="animate-spin w-8 h-8 text-white mb-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-white text-sm">Загрузка...</span>
</div>
`);
$container.find(".upload-loader").remove();
$container.append($loader);
try {
const formData = new FormData();
formData.append("file", file);
const response = await Api.uploadFile(
`/api/books/${bookId}/preview`,
formData,
);
if (!response) {
return;
}
if (response.preview) {
currentBook.preview_urls = response.preview;
} else if (response.preview_urls) {
currentBook.preview_urls = response.preview_urls;
} else {
currentBook = response;
}
Utils.showToast("Обложка успешно загружена", "success");
renderBookCover(currentBook);
} catch (error) {
console.error("Upload error:", error);
Utils.showToast(error.message || "Ошибка загрузки обложки", "error");
} finally {
$container.find(".upload-loader").remove();
}
}
async function deleteCover() {
if (!confirm("Удалить обложку книги?")) {
return;
}
const $container = $("#book-cover-container");
const $loader = $(`
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-20">
<svg class="animate-spin w-8 h-8 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
`);
$container.find(".upload-loader").remove();
$container.append($loader);
try {
await Api.delete(`/api/books/${bookId}/preview`);
currentBook.preview_urls = null;
Utils.showToast("Обложка удалена", "success");
renderBookCover(currentBook);
} catch (error) {
console.error("Delete error:", error);
Utils.showToast(error.message || "Ошибка удаления обложки", "error");
} finally {
$container.find(".upload-loader").remove();
}
}
function renderBookCover(book) {
const $container = $("#book-cover-container");
const canManage = window.canManage();
const previewUrl = getPreviewUrl(book);
if (previewUrl) {
$container.html(`
<img
src="${Utils.escapeHtml(previewUrl)}"
alt="Обложка книги ${Utils.escapeHtml(book.title)}"
class="w-full h-full object-cover"
onerror="this.onerror=null; this.parentElement.querySelector('.cover-fallback').classList.remove('hidden'); this.classList.add('hidden');"
/>
<div class="cover-fallback hidden w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center absolute inset-0">
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
${
canManage
? `
<button
id="delete-cover-btn"
class="absolute top-2 right-2 w-7 h-7 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10"
title="Удалить обложку"
>
<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="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all flex flex-col items-center justify-center cursor-pointer z-0" id="cover-replace-overlay">
<svg class="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
<span class="text-white text-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-medium pointer-events-none px-2">
Заменить
</span>
</div>
`
: ""
}
`);
if (canManage) {
$("#delete-cover-btn").on("click", function (e) {
e.stopPropagation();
deleteCover();
});
$("#cover-replace-overlay").on("click", function () {
$("#cover-file-input").trigger("click");
});
}
} else {
if (canManage) {
$container.html(`
<div
id="cover-upload-zone"
class="w-full h-full bg-gray-100 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-200 transition-all text-center relative"
>
<div class="absolute inset-2 border-2 border-dashed border-gray-300 rounded-lg pointer-events-none"></div>
<svg class="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
<span class="text-gray-500 text-xs font-medium px-2">
Добавить обложку
</span>
<span class="text-gray-400 text-xs mt-1 px-2">
или перетащите
</span>
</div>
`);
$("#cover-upload-zone").on("click", function () {
$("#cover-file-input").trigger("click");
});
} else {
$container.html(`
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
`);
}
}
}
function loadBookData() {
Api.get(`/api/books/${bookId}`)
.then((book) => {
@@ -234,12 +521,16 @@ $(document).ready(() => {
function renderBook(book) {
$("#book-title").text(book.title);
$("#book-id").text(`ID: ${book.id}`);
renderBookCover(book);
if (book.page_count && book.page_count > 0) {
$("#book-page-count-value").text(book.page_count);
$("#book-page-count-text").removeClass("hidden");
} else {
$("#book-page-count-text").addClass("hidden");
}
$("#book-authors-text").text(
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
);
+61
View File
@@ -160,6 +160,67 @@ const Api = {
body: formData.toString(),
});
},
async uploadFile(endpoint, formData) {
const fullUrl = this.getBaseUrl() + endpoint;
const token = StorageHelper.get("access_token");
const headers = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
try {
const response = await fetch(fullUrl, {
method: "POST",
headers: headers,
body: formData,
credentials: "include",
});
if (response.status === 401) {
const refreshed = await Auth.tryRefresh();
if (refreshed) {
headers["Authorization"] =
`Bearer ${StorageHelper.get("access_token")}`;
const retryResponse = await fetch(fullUrl, {
method: "POST",
headers: headers,
body: formData,
credentials: "include",
});
if (retryResponse.ok) {
return retryResponse.json();
}
}
Auth.logout();
return null;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
let errorMessage = `Ошибка ${response.status}`;
if (typeof errorData.detail === "string") {
errorMessage = errorData.detail;
} else if (Array.isArray(errorData.detail)) {
errorMessage = errorData.detail.map((e) => e.msg || e).join(", ");
} else if (errorData.detail?.message) {
errorMessage = errorData.detail.message;
} else if (errorData.message) {
errorMessage = errorData.message;
}
const error = new Error(errorMessage);
error.status = response.status;
throw error;
}
return response.json();
} catch (error) {
throw error;
}
},
};
const Auth = {
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="flex flex-1 items-center justify-center p-4 bg-gray-100">
<div
class="w-full max-w-4xl bg-white rounded-lg shadow-md overflow-hidden flex flex-col md:flex-row"
+3 -4
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Аналитика{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-7xl">
<div class="mb-8">
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
@@ -10,8 +9,8 @@
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
<select id="period-select" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 transition bg-white">
<option value="7">7 дней</option>
<option value="30" selected>30 дней</option>
<option value="7" selected>7 дней</option>
<option value="30">30 дней</option>
<option value="90">90 дней</option>
<option value="180">180 дней</option>
<option value="365">365 дней</option>
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-4xl">
<div id="author-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
+7 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4">
<div
class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"
@@ -7,6 +6,12 @@
<h2 class="text-2xl font-bold text-gray-800">Авторы</h2>
<div class="flex flex-col sm:flex-row gap-4 w-full md:w-auto">
<a href="/author/create" id="add-author-btn" class="hidden flex justify-center items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition whitespace-nowrap">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Добавить автора
</a>
<div class="relative">
<input
type="text"
+87 -16
View File
@@ -1,9 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<title>{% block title %}LiB{% endblock %}</title>
<title>{{ title }}</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="og:title" content="{{ title }}" />
<meta property="og:type" content="website" />
<meta property="og:description" content="Ваша персональная библиотека книг" />
<meta property="og:url" content="{{ request.url.scheme }}://{{ domain }}/" />
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
@@ -18,6 +22,7 @@
class="flex flex-col min-h-screen bg-gray-100"
x-data="{
user: null,
menuOpen: false,
async init() {
document.addEventListener('auth:login', async (e) => {
this.user = e.detail;
@@ -28,31 +33,43 @@
}"
>
<header class="bg-gray-600 text-white p-4 shadow-md">
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
<div class="mx-auto px-3 md:pl-5 md:pr-3 flex justify-between items-center">
<div class="flex items-center">
<button
@click="menuOpen = !menuOpen"
class="md:hidden flex gap-2 items-center hover:opacity-80 transition focus:outline-none"
:aria-expanded="menuOpen"
aria-label="Меню навигации"
>
<img class="invert max-w-10 h-auto" src="/static/logo.svg" />
<h1 class="text-xl font-bold">
<span class="text-gray-300 mr-1"></span>LiB
</h1>
</button>
<a class="hidden md:flex gap-4 items-center max-w-10 h-auto" href="/">
<img class="invert" src="/static/logo.svg" />
<h1 class="text-2xl font-bold">LiB</h1>
</a>
<nav>
</div>
<nav class="hidden md:block">
<ul class="flex space-x-4">
<li>
<a href="/" class="hover:text-gray-200">Главная</a>
</li>
<li>
<a href="/books" class="hover:text-gray-200"
>Книги</a
>
<a href="/books" class="hover:text-gray-200">Книги</a>
</li>
<li>
<a href="/authors" class="hover:text-gray-200"
>Авторы</a
>
<a href="/authors" class="hover:text-gray-200">Авторы</a>
</li>
<li>
<a href="/api" class="hover:text-gray-200">API</a>
</li>
</ul>
</nav>
<div class="relative" x-data="{ open: false }">
<template x-if="!user">
<a
@@ -104,7 +121,7 @@
<div
x-show="open"
x-transition
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
class="absolute right-0 mt-2 w-56 max-w-[calc(100vw-2rem)] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
style="display: none"
>
<div class="px-4 py-3 border-b border-gray-200">
@@ -229,17 +246,71 @@
</template>
</div>
</div>
<nav
x-show="menuOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
@click.outside="menuOpen = false"
class="md:hidden mt-4 pb-2 border-t border-gray-500"
style="display: none"
>
<ul class="flex flex-col space-y-1 pt-3">
<li>
<a
href="/"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
Главная
</a>
</li>
<li>
<a
href="/books"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
Книги
</a>
</li>
<li>
<a
href="/authors"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
Авторы
</a>
</li>
<li>
<a
href="/api"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
API
</a>
</li>
</ul>
</nav>
</header>
<main class="flex-grow">{% block content %}{% endblock %}</main>
<div
id="toast-container"
class="fixed bottom-5 right-5 flex flex-col gap-2 z-50"
class="fixed bottom-5 left-4 right-4 md:left-auto md:right-5 flex flex-col gap-2 z-50 items-center md:items-end"
></div>
<footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center">
<p>&copy; 2026 LiB Library. Разработано в рамках дипломного проекта.
Код открыт под лицензией <a href="https://github.com/wowlikon/LiB/blob/main/LICENSE">MIT</a>.
<div class="container mx-auto text-center text-sm md:text-base">
<p>
&copy; 2026 LiB Library. Разработано в рамках дипломного проекта.
<br class="sm:hidden" />
Код открыт под лицензией
<a href="https://github.com/wowlikon/LiB/blob/main/LICENSE" class="underline hover:text-gray-300">MIT</a>.
</p>
</div>
</footer>
+11 -4
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-6xl">
<div id="book-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
@@ -67,8 +66,10 @@
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
>
<div
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4"
id="book-cover-container"
class="relative w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100 group"
>
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
<svg
class="w-20 h-20 text-white opacity-80"
fill="none"
@@ -83,13 +84,14 @@
></path>
</svg>
</div>
</div>
<input type="file" id="cover-file-input" class="hidden" accept="image/*" />
<div
id="book-status-container"
class="relative w-full flex justify-center z-10 mb-4"
></div>
<div id="book-actions-container" class="w-full"></div>
</div>
<div class="flex-1 w-full">
<div
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
@@ -301,3 +303,8 @@
{% endblock %} {% block scripts %}
<script src="/static/page/book.js"></script>
{% endblock %}
{% block extra_head %}
{% if img %}
<meta property="og:image" content="{{ request.url.scheme }}://{{ domain }}/static/books/{{ img }}.jpg" />
{% endif %}
{% endblock %}
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Книги{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<style>
.range-double {
height: 0;
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Создание автора{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Создание книги{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Создание жанра{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Редактирование автора{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Редактирование книги{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Редактирование жанра{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %}
{% extends "base.html" %}
{% block content %}
<div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-4xl">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Мои книги{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-6xl">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4 max-w-2xl"
x-data="{ showPasswordModal: false, showDisable2FAModal: false, showRecoveryCodesModal: false, is2FAEnabled: false, recoveryCodesRemaining: null }"
@update-2fa.window="is2FAEnabled = $event.detail"
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Страница не найдена{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="flex flex-1 items-center justify-center p-4 min-h-[70vh]">
<div class="w-full max-w-2xl">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
+1 -2
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}LiB - Пользователи{% endblock %}
{% block content %}
{% extends "base.html" %}{% block content %}
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">
@@ -0,0 +1,33 @@
"""Book preview
Revision ID: abbc38275032
Revises: 6c616cc9d1f0
Create Date: 2026-02-01 14:41:14.611420
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel, pgvector
# revision identifiers, used by Alembic.
revision: str = 'abbc38275032'
down_revision: Union[str, None] = '6c616cc9d1f0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('book', sa.Column('preview_id', sa.Uuid(), nullable=True))
op.create_index(op.f('ix_book_preview_id'), 'book', ['preview_id'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_book_preview_id'), table_name='book')
op.drop_column('book', 'preview_id')
# ### end Alembic commands ###
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "LiB"
version = "0.8.0"
version = "0.9.0"
description = "Это простое API для управления авторами, книгами и их жанрами."
authors = [{ name = "wowlikon" }]
readme = "README.md"
Generated
+1 -5
View File
@@ -479,7 +479,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -487,7 +486,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -495,7 +493,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -503,7 +500,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -631,7 +627,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
[[package]]
name = "lib"
version = "0.8.0"
version = "0.9.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },