diff --git a/.gitignore b/.gitignore
index 3eda569..03acf24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.env
+library_service/static/books/
*.log
# Byte-compiled / optimized / DLL files
diff --git a/library_service/models/db/book.py b/library_service/models/db/book.py
index 265d98e..b74a28e 100644
--- a/library_service/models/db/book.py
+++ b/library_service/models/db/book.py
@@ -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
)
diff --git a/library_service/models/dto/book.py b/library_service/models/dto/book.py
index d1b0e5d..c8e014a 100644
--- a/library_service/models/dto/book.py
+++ b/library_service/models/dto/book.py
@@ -46,6 +46,7 @@ class BookRead(BookBase):
id: int = Field(description="Идентификатор")
status: BookStatus = Field(description="Статус")
+ preview_url: str | None = Field(None, description="URL изображения")
class BookList(SQLModel):
diff --git a/library_service/models/dto/misc.py b/library_service/models/dto/misc.py
index 12c3e95..ad89b65 100644
--- a/library_service/models/dto/misc.py
+++ b/library_service/models/dto/misc.py
@@ -39,6 +39,7 @@ class BookWithAuthors(SQLModel):
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
+ preview_url: str | None = Field(default=None, 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_url: 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_url: str | None = Field(default=None, description="URL изображения")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
diff --git a/library_service/routers/books.py b/library_service/routers/books.py
index aaf31bb..8b70f3d 100644
--- a/library_service/routers/books.py
+++ b/library_service/routers/books.py
@@ -1,4 +1,6 @@
"""Модуль работы с книгами"""
+import shutil
+from uuid import uuid4
from pydantic import Field
from typing_extensions import Annotated
@@ -10,12 +12,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 +49,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 +74,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 +94,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 +103,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 +133,16 @@ 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"})
+ if db_book.preview_id:
+ book_data["preview_url"] = f"/static/books/{db_book.preview_id}.png"
+
+ return BookRead(**book_data)
@router.get(
@@ -145,9 +153,17 @@ 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"})
+ if book.preview_id:
+ book_data["preview_url"] = f"/static/books/{book.preview_id}.png"
+ 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 +186,20 @@ 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"})
+ if book.preview_id:
+ book_data["preview_url"] = f"/static/books/{book.preview_id}.png"
book_data["authors"] = author_reads
book_data["genres"] = genre_reads
@@ -233,11 +251,18 @@ 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"})
+ if db_book.preview_id:
+ book_data["preview_url"] = f"/static/books/{db_book.preview_id}.png"
+
+ return BookRead(**book_data)
@router.delete(
@@ -267,3 +292,61 @@ 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 == "image/png":
+ raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "PNG required")
+
+ if (file.size or 0) > 10 * 1024 * 1024:
+ raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB")
+
+ file_uuid= uuid4()
+ filename = f"{file_uuid}.png"
+ file_path = BOOKS_PREVIEW_DIR / filename
+
+ with open(file_path, "wb") as f:
+ shutil.copyfileobj(file.file, f)
+
+ book = session.get(Book, book_id)
+ if not book:
+ file_path.unlink()
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
+
+ if book.preview_id:
+ old_path = BOOKS_PREVIEW_DIR / f"{book.preview_id}.png"
+ if old_path.exists():
+ old_path.unlink()
+
+ book.preview_id = file_uuid
+ session.add(book)
+ session.commit()
+
+ return {"preview_url": f"/static/books/{filename}"}
+
+
+@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:
+ old_path = BOOKS_PREVIEW_DIR / f"{book.preview_id}.png"
+ if old_path.exists():
+ old_path.unlink()
+
+ book.preview_id = None
+ session.add(book)
+ session.commit()
+
+ return {"preview_url": None}
diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py
index 169b432..9f1b0e7 100644
--- a/library_service/routers/misc.py
+++ b/library_service/routers/misc.py
@@ -181,6 +181,7 @@ async def api_info(app=Depends(lambda: get_app())):
description="Возвращает схему базы данных с описаниями полей",
)
async def api_schema():
+ """Возвращает информацию для создания er-диаграммы"""
return generator.generate()
diff --git a/library_service/settings.py b/library_service/settings.py
index 93d7376..62bf046 100644
--- a/library_service/settings.py
+++ b/library_service/settings.py
@@ -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)
diff --git a/library_service/static/page/book.js b/library_service/static/page/book.js
index 01ca640..926dda5 100644
--- a/library_service/static/page/book.js
+++ b/library_service/static/page/book.js
@@ -234,6 +234,7 @@ $(document).ready(() => {
function renderBook(book) {
$("#book-title").text(book.title);
$("#book-id").text(`ID: ${book.id}`);
+ const $coverContainer = $("#book-cover-container");
if (book.page_count && book.page_count > 0) {
$("#book-page-count-value").text(book.page_count);
$("#book-page-count-text").removeClass("hidden");
@@ -253,6 +254,30 @@ $(document).ready(() => {
$("#book-actions-container").empty();
}
+ if (book.preview_url) {
+ $coverContainer.html(`
+
+