Compare commits

...

4 Commits

18 changed files with 758 additions and 105 deletions
Vendored
+1
View File
@@ -1,4 +1,5 @@
.env .env
library_service/static/books/
*.log *.log
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
+1
View File
@@ -184,6 +184,7 @@ if __name__ == "__main__":
"library_service.main:app", "library_service.main:app",
host="0.0.0.0", host="0.0.0.0",
port=8000, port=8000,
proxy_headers=True,
forwarded_allow_ips="*", forwarded_allow_ips="*",
log_config=LOGGING_CONFIG, log_config=LOGGING_CONFIG,
access_log=False, access_log=False,
+3 -1
View File
@@ -1,6 +1,7 @@
"""Модуль DB-моделей книг""" """Модуль DB-моделей книг"""
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from uuid import UUID
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, String from sqlalchemy import Column, String
@@ -26,7 +27,8 @@ class Book(BookBase, table=True):
sa_column=Column(String, nullable=False, default="active"), sa_column=Column(String, nullable=False, default="active"),
description="Статус", 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( authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink back_populates="books", link_model=AuthorBookLink
) )
+1
View File
@@ -46,6 +46,7 @@ class BookRead(BookBase):
id: int = Field(description="Идентификатор") id: int = Field(description="Идентификатор")
status: BookStatus = Field(description="Статус") status: BookStatus = Field(description="Статус")
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
class BookList(SQLModel): class BookList(SQLModel):
+3
View File
@@ -39,6 +39,7 @@ class BookWithAuthors(SQLModel):
description: str = Field(description="Описание") description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц") page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус") status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
authors: List[AuthorRead] = Field( authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов" default_factory=list, description="Список авторов"
) )
@@ -52,6 +53,7 @@ class BookWithGenres(SQLModel):
description: str = Field(description="Описание") description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц") page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, 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="Список жанров") genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
@@ -63,6 +65,7 @@ class BookWithAuthorsAndGenres(SQLModel):
description: str = Field(description="Описание") description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц") page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус") status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
authors: List[AuthorRead] = Field( authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов" default_factory=list, description="Список авторов"
) )
+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 pydantic import Field
from typing_extensions import Annotated from typing_extensions import Annotated
@@ -10,12 +13,12 @@ from sqlalchemy import text, case, distinct
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, status from fastapi import APIRouter, Depends, HTTPException, Path, Query, status, UploadFile, File
from ollama import Client from ollama import Client
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
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.enums import BookStatus
from library_service.models.db import ( from library_service.models.db import (
Author, Author,
@@ -47,9 +50,9 @@ def close_active_loan(session: Session, book_id: int) -> None:
"""Закрывает активную выдачу книги при изменении статуса""" """Закрывает активную выдачу книги при изменении статуса"""
active_loan = session.exec( active_loan = session.exec(
select(BookUserLink) select(BookUserLink)
.where(BookUserLink.book_id == book_id) .where(BookUserLink.book_id == book_id) # ty: ignore
.where(BookUserLink.returned_at == None) # noqa: E711 .where(BookUserLink.returned_at == None) # ty: ignore
).first() ).first() # ty: ignore
if active_loan: if active_loan:
active_loan.returned_at = datetime.now(timezone.utc) active_loan.returned_at = datetime.now(timezone.utc)
@@ -72,19 +75,19 @@ def filter_books(
size: int = Query(20, gt=0, le=100), size: int = Query(20, gt=0, le=100),
): ):
statement = select(Book).options( 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: 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: 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: if author_ids:
statement = statement.where( statement = statement.where(
exists().where( exists().where(
AuthorBookLink.book_id == Book.id, AuthorBookLink.book_id == Book.id, # ty: ignore
AuthorBookLink.author_id.in_(author_ids), AuthorBookLink.author_id.in_(author_ids), # ty: ignore
) )
) )
@@ -92,7 +95,7 @@ def filter_books(
for genre_id in genre_ids: for genre_id in genre_ids:
statement = statement.where( statement = statement.where(
exists().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: if q:
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"] emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"]
distance_col = Book.embedding.cosine_distance(emb) distance_col = Book.embedding.cosine_distance(emb) # ty: ignore
statement = statement.where(Book.embedding.is_not(None)) 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) statement = statement.order_by(keyword_match, distance_col)
else: else:
statement = statement.order_by(Book.id) statement = statement.order_by(Book.id) # ty: ignore
offset = (page - 1) * size offset = (page - 1) * size
statement = statement.offset(offset).limit(size) statement = statement.offset(offset).limit(size)
@@ -131,10 +134,18 @@ def create_book(
full_text = book.title + " " + book.description full_text = book.title + " " + book.description
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text) emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book = Book(**book.model_dump(), embedding=emb["embedding"]) db_book = Book(**book.model_dump(), embedding=emb["embedding"])
session.add(db_book) session.add(db_book)
session.commit() session.commit()
session.refresh(db_book) 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( @router.get(
@@ -145,9 +156,20 @@ def create_book(
) )
def read_books(session: Session = Depends(get_session)): 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( 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), total=len(books),
) )
@@ -170,18 +192,23 @@ def get_book(
) )
authors = session.scalars( 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() ).all()
author_reads = [AuthorRead(**author.model_dump()) for author in authors] author_reads = [AuthorRead(**author.model_dump()) for author in authors]
genres = session.scalars( 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() ).all()
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres] 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["authors"] = author_reads
book_data["genres"] = genre_reads book_data["genres"] = genre_reads
@@ -233,11 +260,21 @@ def update_book(
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text) emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book.embedding = emb["embedding"] 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.add(db_book)
session.commit() session.commit()
session.refresh(db_book) 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( @router.delete(
@@ -267,3 +304,69 @@ def delete_book(
session.delete(book) session.delete(book)
session.commit() session.commit()
return book_read 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": []}
+22 -20
View File
@@ -40,109 +40,110 @@ def get_info(app) -> Dict:
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
async def root(request: Request, app=Depends(lambda: get_app())): async def root(request: Request, app=Depends(lambda: get_app())):
"""Рендерит главную страницу""" """Рендерит главную страницу"""
return templates.TemplateResponse(request, "index.html", get_info(app) | {"title": "LiB - Библиотека"}) return templates.TemplateResponse(request, "index.html", get_info(app) | {"request": request, "title": "LiB - Библиотека"})
@router.get("/unknown", include_in_schema=False) @router.get("/unknown", include_in_schema=False)
async def unknown(request: Request, app=Depends(lambda: get_app())): async def unknown(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу 404 ошибки""" """Рендерит страницу 404 ошибки"""
return templates.TemplateResponse(request, "unknown.html", get_info(app) | {"title": "LiB - Страница не найдена"}) return templates.TemplateResponse(request, "unknown.html", get_info(app) | {"request": request, "title": "LiB - Страница не найдена"})
@router.get("/genre/create", include_in_schema=False) @router.get("/genre/create", include_in_schema=False)
async def create_genre(request: Request, app=Depends(lambda: get_app())): async def create_genre(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу создания жанра""" """Рендерит страницу создания жанра"""
return templates.TemplateResponse(request, "create_genre.html", get_info(app) | {"title": "LiB - Создать жанр"}) return templates.TemplateResponse(request, "create_genre.html", get_info(app) | {"request": request, "title": "LiB - Создать жанр"})
@router.get("/genre/{genre_id}/edit", include_in_schema=False) @router.get("/genre/{genre_id}/edit", include_in_schema=False)
async def edit_genre(request: Request, genre_id: int, app=Depends(lambda: get_app())): async def edit_genre(request: Request, genre_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования жанра""" """Рендерит страницу редактирования жанра"""
return templates.TemplateResponse(request, "edit_genre.html", get_info(app) | {"id": genre_id} | {"id": genre_id, "title": "LiB - Редактировать жанр"}) return templates.TemplateResponse(request, "edit_genre.html", get_info(app) | {"request": request, "title": "LiB - Редактировать жанр", "id": genre_id})
@router.get("/authors", include_in_schema=False) @router.get("/authors", include_in_schema=False)
async def authors(request: Request, app=Depends(lambda: get_app())): async def authors(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу списка авторов""" """Рендерит страницу списка авторов"""
return templates.TemplateResponse(request, "authors.html", get_info(app) | {"title": "LiB - Авторы"}) return templates.TemplateResponse(request, "authors.html", get_info(app) | {"request": request, "title": "LiB - Авторы"})
@router.get("/author/create", include_in_schema=False) @router.get("/author/create", include_in_schema=False)
async def create_author(request: Request, app=Depends(lambda: get_app())): async def create_author(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу создания автора""" """Рендерит страницу создания автора"""
return templates.TemplateResponse(request, "create_author.html", get_info(app) | {"title": "LiB - Создать автора"}) return templates.TemplateResponse(request, "create_author.html", get_info(app) | {"request": request, "title": "LiB - Создать автора"})
@router.get("/author/{author_id}/edit", include_in_schema=False) @router.get("/author/{author_id}/edit", include_in_schema=False)
async def edit_author(request: Request, author_id: int, app=Depends(lambda: get_app())): async def edit_author(request: Request, author_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования автора""" """Рендерит страницу редактирования автора"""
return templates.TemplateResponse(request, "edit_author.html", get_info(app) | {"id": author_id, "title": "LiB - Редактировать автора"}) 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) @router.get("/author/{author_id}", include_in_schema=False)
async def author(request: Request, author_id: int, app=Depends(lambda: get_app())): async def author(request: Request, author_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу просмотра автора""" """Рендерит страницу просмотра автора"""
return templates.TemplateResponse(request, "author.html", get_info(app) | {"id": author_id, "title": "LiB - Автор"}) return templates.TemplateResponse(request, "author.html", get_info(app) | {"request": request, "title": "LiB - Автор", "id": author_id})
@router.get("/books", include_in_schema=False) @router.get("/books", include_in_schema=False)
async def books(request: Request, app=Depends(lambda: get_app())): async def books(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу списка книг""" """Рендерит страницу списка книг"""
return templates.TemplateResponse(request, "books.html", get_info(app) | {"title": "LiB - Книги"}) return templates.TemplateResponse(request, "books.html", get_info(app) | {"request": request, "title": "LiB - Книги"})
@router.get("/book/create", include_in_schema=False) @router.get("/book/create", include_in_schema=False)
async def create_book(request: Request, app=Depends(lambda: get_app())): async def create_book(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу создания книги""" """Рендерит страницу создания книги"""
return templates.TemplateResponse(request, "create_book.html", get_info(app) | {"title": "LiB - Создать книгу"}) return templates.TemplateResponse(request, "create_book.html", get_info(app) | {"request": request, "title": "LiB - Создать книгу"})
@router.get("/book/{book_id}/edit", include_in_schema=False) @router.get("/book/{book_id}/edit", include_in_schema=False)
async def edit_book(request: Request, book_id: int, app=Depends(lambda: get_app())): async def edit_book(request: Request, book_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования книги""" """Рендерит страницу редактирования книги"""
return templates.TemplateResponse(request, "edit_book.html", get_info(app) | {"id": book_id, "title": "LiB - Редактировать книгу"}) 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) @router.get("/book/{book_id}", include_in_schema=False)
async def book(request: Request, book_id: int, app=Depends(lambda: get_app())): async def book(request: Request, book_id: int, app=Depends(lambda: get_app()), session=Depends(get_session)):
"""Рендерит страницу просмотра книги""" """Рендерит страницу просмотра книги"""
return templates.TemplateResponse(request, "book.html", get_info(app) | {"id": book_id, "title": "LiB - Книга"}) 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) @router.get("/auth", include_in_schema=False)
async def auth(request: Request, app=Depends(lambda: get_app())): async def auth(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу авторизации""" """Рендерит страницу авторизации"""
return templates.TemplateResponse(request, "auth.html", get_info(app) | {"title": "LiB - Авторизация"}) return templates.TemplateResponse(request, "auth.html", get_info(app) | {"request": request, "title": "LiB - Авторизация"})
@router.get("/2fa", include_in_schema=False) @router.get("/2fa", include_in_schema=False)
async def set2fa(request: Request, app=Depends(lambda: get_app())): async def set2fa(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу установки двухфакторной аутентификации""" """Рендерит страницу установки двухфакторной аутентификации"""
return templates.TemplateResponse(request, "2fa.html", get_info(app) | {"title": "LiB - Двухфакторная аутентификация"}) return templates.TemplateResponse(request, "2fa.html", get_info(app) | {"request": request, "title": "LiB - Двухфакторная аутентификация"})
@router.get("/profile", include_in_schema=False) @router.get("/profile", include_in_schema=False)
async def profile(request: Request, app=Depends(lambda: get_app())): async def profile(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу профиля пользователя""" """Рендерит страницу профиля пользователя"""
return templates.TemplateResponse(request, "profile.html", get_info(app) | {"title": "LiB - Профиль"}) return templates.TemplateResponse(request, "profile.html", get_info(app) | {"request": request, "title": "LiB - Профиль"})
@router.get("/users", include_in_schema=False) @router.get("/users", include_in_schema=False)
async def users(request: Request, app=Depends(lambda: get_app())): async def users(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу управления пользователями""" """Рендерит страницу управления пользователями"""
return templates.TemplateResponse(request, "users.html", get_info(app) | {"title": "LiB - Пользователи"}) return templates.TemplateResponse(request, "users.html", get_info(app) | {"request": request, "title": "LiB - Пользователи"})
@router.get("/my-books", include_in_schema=False) @router.get("/my-books", include_in_schema=False)
async def my_books(request: Request, app=Depends(lambda: get_app())): async def my_books(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу моих книг пользователя""" """Рендерит страницу моих книг пользователя"""
return templates.TemplateResponse(request, "my_books.html", get_info(app) | {"title": "LiB - Мои книги"}) return templates.TemplateResponse(request, "my_books.html", get_info(app) | {"request": request, "title": "LiB - Мои книги"})
@router.get("/analytics", include_in_schema=False) @router.get("/analytics", include_in_schema=False)
async def analytics(request: Request, app=Depends(lambda: get_app())): async def analytics(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу аналитики выдач""" """Рендерит страницу аналитики выдач"""
return templates.TemplateResponse(request, "analytics.html", get_info(app) | {"title": "LiB - Аналитика"}) return templates.TemplateResponse(request, "analytics.html", get_info(app) | {"request": request, "title": "LiB - Аналитика"})
@router.get("/favicon.ico", include_in_schema=False) @router.get("/favicon.ico", include_in_schema=False)
@@ -181,6 +182,7 @@ async def api_info(app=Depends(lambda: get_app())):
description="Возвращает схему базы данных с описаниями полей", description="Возвращает схему базы данных с описаниями полей",
) )
async def api_schema(): async def api_schema():
"""Возвращает информацию для создания er-диаграммы"""
return generator.generate() return generator.generate()
@@ -189,7 +191,7 @@ async def api_schema():
summary="Статистика сервиса", summary="Статистика сервиса",
description="Возвращает статистическую информацию о системе", 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) authors = select(func.count()).select_from(Author)
books = select(func.count()).select_from(Book) books = select(func.count()).select_from(Book)
+2
View File
@@ -13,6 +13,7 @@ from .captcha import (
prng, prng,
) )
from .describe_er import SchemaGenerator from .describe_er import SchemaGenerator
from .image_processing import transcode_image
__all__ = [ __all__ = [
"limiter", "limiter",
@@ -28,4 +29,5 @@ __all__ = [
"REDEEM_TTL", "REDEEM_TTL",
"prng", "prng",
"SchemaGenerator", "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() 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: with open("pyproject.toml", "r", encoding="utf-8") as f:
_pyproject = load(f) _pyproject = load(f)
+291
View File
@@ -34,6 +34,7 @@ $(document).ready(() => {
const pathParts = window.location.pathname.split("/"); const pathParts = window.location.pathname.split("/");
const bookId = parseInt(pathParts[pathParts.length - 1]); const bookId = parseInt(pathParts[pathParts.length - 1]);
let isDraggingOver = false;
let currentBook = null; let currentBook = null;
let cachedUsers = null; let cachedUsers = null;
let selectedLoanUserId = null; let selectedLoanUserId = null;
@@ -48,6 +49,28 @@ $(document).ready(() => {
} }
loadBookData(); loadBookData();
setupEventHandlers(); 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() { function setupEventHandlers() {
@@ -75,6 +98,270 @@ $(document).ready(() => {
$("#loan-due-date").val(future.toISOString().split("T")[0]); $("#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() { function loadBookData() {
Api.get(`/api/books/${bookId}`) Api.get(`/api/books/${bookId}`)
.then((book) => { .then((book) => {
@@ -234,12 +521,16 @@ $(document).ready(() => {
function renderBook(book) { function renderBook(book) {
$("#book-title").text(book.title); $("#book-title").text(book.title);
$("#book-id").text(`ID: ${book.id}`); $("#book-id").text(`ID: ${book.id}`);
renderBookCover(book);
if (book.page_count && book.page_count > 0) { if (book.page_count && book.page_count > 0) {
$("#book-page-count-value").text(book.page_count); $("#book-page-count-value").text(book.page_count);
$("#book-page-count-text").removeClass("hidden"); $("#book-page-count-text").removeClass("hidden");
} else { } else {
$("#book-page-count-text").addClass("hidden"); $("#book-page-count-text").addClass("hidden");
} }
$("#book-authors-text").text( $("#book-authors-text").text(
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен", book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
); );
+61
View File
@@ -160,6 +160,67 @@ const Api = {
body: formData.toString(), 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 = { const Auth = {
+2 -2
View File
@@ -9,8 +9,8 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-600">Период анализа:</label> <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"> <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="7" selected>7 дней</option>
<option value="30" selected>30 дней</option> <option value="30">30 дней</option>
<option value="90">90 дней</option> <option value="90">90 дней</option>
<option value="180">180 дней</option> <option value="180">180 дней</option>
<option value="365">365 дней</option> <option value="365">365 дней</option>
+83 -18
View File
@@ -7,9 +7,7 @@
<meta property="og:title" content="{{ title }}" /> <meta property="og:title" content="{{ title }}" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:description" content="Ваша персональная библиотека книг" /> <meta property="og:description" content="Ваша персональная библиотека книг" />
<meta property="og:url" content="//{{ domain }}/" /> <meta property="og:url" content="{{ request.url.scheme }}://{{ domain }}/" />
<!--<meta property="og:image" content="//{{ domain }}/img/{{ img }}.png" />-->
<script <script
defer defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js" src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
@@ -24,6 +22,7 @@
class="flex flex-col min-h-screen bg-gray-100" class="flex flex-col min-h-screen bg-gray-100"
x-data="{ x-data="{
user: null, user: null,
menuOpen: false,
async init() { async init() {
document.addEventListener('auth:login', async (e) => { document.addEventListener('auth:login', async (e) => {
this.user = e.detail; this.user = e.detail;
@@ -34,31 +33,43 @@
}" }"
> >
<header class="bg-gray-600 text-white p-4 shadow-md"> <header class="bg-gray-600 text-white p-4 shadow-md">
<div class="mx-auto pl-5 pr-3 flex justify-between items-center"> <div class="mx-auto px-3 md:pl-5 md:pr-3 flex justify-between items-center">
<a class="flex gap-4 items-center max-w-10 h-auto" href="/"> <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" /> <img class="invert" src="/static/logo.svg" />
<h1 class="text-2xl font-bold">LiB</h1> <h1 class="text-2xl font-bold">LiB</h1>
</a> </a>
<nav> </div>
<nav class="hidden md:block">
<ul class="flex space-x-4"> <ul class="flex space-x-4">
<li> <li>
<a href="/" class="hover:text-gray-200">Главная</a> <a href="/" class="hover:text-gray-200">Главная</a>
</li> </li>
<li> <li>
<a href="/books" class="hover:text-gray-200" <a href="/books" class="hover:text-gray-200">Книги</a>
>Книги</a
>
</li> </li>
<li> <li>
<a href="/authors" class="hover:text-gray-200" <a href="/authors" class="hover:text-gray-200">Авторы</a>
>Авторы</a
>
</li> </li>
<li> <li>
<a href="/api" class="hover:text-gray-200">API</a> <a href="/api" class="hover:text-gray-200">API</a>
</li> </li>
</ul> </ul>
</nav> </nav>
<div class="relative" x-data="{ open: false }"> <div class="relative" x-data="{ open: false }">
<template x-if="!user"> <template x-if="!user">
<a <a
@@ -110,7 +121,7 @@
<div <div
x-show="open" x-show="open"
x-transition 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" style="display: none"
> >
<div class="px-4 py-3 border-b border-gray-200"> <div class="px-4 py-3 border-b border-gray-200">
@@ -235,17 +246,71 @@
</template> </template>
</div> </div>
</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> </header>
<main class="flex-grow">{% block content %}{% endblock %}</main> <main class="flex-grow">{% block content %}{% endblock %}</main>
<div <div
id="toast-container" 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> ></div>
<footer class="bg-gray-800 text-white p-4 mt-8"> <footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center"> <div class="container mx-auto text-center text-sm md:text-base">
<p>&copy; 2026 LiB Library. Разработано в рамках дипломного проекта. <p>
Код открыт под лицензией <a href="https://github.com/wowlikon/LiB/blob/main/LICENSE">MIT</a>. &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> </p>
</div> </div>
</footer> </footer>
+10 -2
View File
@@ -66,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" class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
> >
<div <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 <svg
class="w-20 h-20 text-white opacity-80" class="w-20 h-20 text-white opacity-80"
fill="none" fill="none"
@@ -82,13 +84,14 @@
></path> ></path>
</svg> </svg>
</div> </div>
</div>
<input type="file" id="cover-file-input" class="hidden" accept="image/*" />
<div <div
id="book-status-container" id="book-status-container"
class="relative w-full flex justify-center z-10 mb-4" class="relative w-full flex justify-center z-10 mb-4"
></div> ></div>
<div id="book-actions-container" class="w-full"></div> <div id="book-actions-container" class="w-full"></div>
</div> </div>
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<div <div
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2" class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
@@ -300,3 +303,8 @@
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="/static/page/book.js"></script> <script src="/static/page/book.js"></script>
{% endblock %} {% endblock %}
{% block extra_head %}
{% if img %}
<meta property="og:image" content="{{ request.url.scheme }}://{{ domain }}/static/books/{{ img }}.jpg" />
{% endif %}
{% endblock %}
@@ -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] [project]
name = "LiB" name = "LiB"
version = "0.8.1" version = "0.9.0"
description = "Это простое API для управления авторами, книгами и их жанрами." description = "Это простое API для управления авторами, книгами и их жанрами."
authors = [{ name = "wowlikon" }] authors = [{ name = "wowlikon" }]
readme = "README.md" 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/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/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/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/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/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" }, { 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/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/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/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/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/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" }, { 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/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/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/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/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/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" }, { 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/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/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/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/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/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" }, { 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]] [[package]]
name = "lib" name = "lib"
version = "0.8.0" version = "0.9.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },