Archived
feat: book search and discovery with real-time search, filters, and detail view
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
"""Tests for the Book search & discovery endpoints."""
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.books.models import Book, ReadingStatus
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(django_user_model):
|
||||
return django_user_model.objects.create_user(
|
||||
email="reader@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(api_client, user):
|
||||
api_client.force_authenticate(user=user)
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def books():
|
||||
books_data = [
|
||||
Book.objects.create(
|
||||
title="Dune",
|
||||
author="Frank Herbert",
|
||||
genre="Science Fiction",
|
||||
reading_status=ReadingStatus.FINISHED,
|
||||
total_pages=688,
|
||||
description="A desert planet saga.",
|
||||
),
|
||||
Book.objects.create(
|
||||
title="Neuromancer",
|
||||
author="William Gibson",
|
||||
genre="Science Fiction",
|
||||
reading_status=ReadingStatus.READING,
|
||||
total_pages=271,
|
||||
description="Cyberpunk classic.",
|
||||
),
|
||||
Book.objects.create(
|
||||
title="The Hobbit",
|
||||
author="J.R.R. Tolkien",
|
||||
genre="Fantasy",
|
||||
reading_status=ReadingStatus.WANT_TO_READ,
|
||||
total_pages=310,
|
||||
description="A hobbit's adventure.",
|
||||
),
|
||||
Book.objects.create(
|
||||
title="1984",
|
||||
author="George Orwell",
|
||||
genre="Dystopian",
|
||||
reading_status=ReadingStatus.FINISHED,
|
||||
total_pages=328,
|
||||
description="Big Brother is watching.",
|
||||
),
|
||||
]
|
||||
return books_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBookSearch:
|
||||
"""Verify the search endpoint returns correct results."""
|
||||
|
||||
def test_search_by_title(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Dune"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Dune" in titles
|
||||
assert "Neuromancer" not in titles
|
||||
|
||||
def test_search_by_author(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Tolkien"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "The Hobbit" in titles
|
||||
|
||||
def test_search_by_genre(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Fantasy"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "The Hobbit" in titles
|
||||
|
||||
def test_search_case_insensitive(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "dune"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert any(b["title"] == "Dune" for b in response.data["results"])
|
||||
|
||||
def test_search_partial_match(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Neu"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Neuromancer" in titles
|
||||
|
||||
def test_search_empty_query_returns_all(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": ""})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 4
|
||||
|
||||
def test_search_no_results(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "zzzznotfound"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBookFilters:
|
||||
"""Verify filters for genre, author, and reading_status."""
|
||||
|
||||
def test_filter_by_genre(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"genre": "Science Fiction"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Dune" in titles
|
||||
assert "Neuromancer" in titles
|
||||
assert "The Hobbit" not in titles
|
||||
|
||||
def test_filter_by_author(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"author": "George Orwell"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "1984" in titles
|
||||
assert "Dune" not in titles
|
||||
|
||||
def test_filter_by_reading_status(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"reading_status": ReadingStatus.FINISHED})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Dune" in titles
|
||||
assert "1984" in titles
|
||||
assert "Neuromancer" not in titles
|
||||
assert "The Hobbit" not in titles
|
||||
|
||||
def test_filter_combined_with_search(self, auth_client, books):
|
||||
"""Search + filter should intersect results."""
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Dune", "reading_status": ReadingStatus.FINISHED})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Dune" in titles
|
||||
# 1984 matches reading_status but not search
|
||||
assert "1984" not in titles
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBookDiscovery:
|
||||
"""Verify genre and author discovery endpoints."""
|
||||
|
||||
def test_genres_endpoint(self, auth_client, books):
|
||||
url = reverse("book-genres")
|
||||
response = auth_client.get(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert isinstance(response.data, list)
|
||||
assert "Science Fiction" in response.data
|
||||
assert "Fantasy" in response.data
|
||||
assert "Dystopian" in response.data
|
||||
# No duplicate genres
|
||||
assert response.data.count("Science Fiction") == 1
|
||||
|
||||
def test_authors_endpoint(self, auth_client, books):
|
||||
url = reverse("book-authors")
|
||||
response = auth_client.get(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert isinstance(response.data, list)
|
||||
assert "Frank Herbert" in response.data
|
||||
assert "J.R.R. Tolkien" in response.data
|
||||
|
||||
def test_genres_requires_auth(self, api_client, books):
|
||||
url = reverse("book-genres")
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_authors_requires_auth(self, api_client, books):
|
||||
url = reverse("book-authors")
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detail view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBookDetail:
|
||||
"""Verify the book detail endpoint."""
|
||||
|
||||
def test_retrieve_book(self, auth_client, books):
|
||||
book = books[0]
|
||||
url = reverse("book-detail", kwargs={"pk": book.pk})
|
||||
response = auth_client.get(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["title"] == "Dune"
|
||||
assert response.data["author"] == "Frank Herbert"
|
||||
assert response.data["description"] == "A desert planet saga."
|
||||
assert response.data["total_pages"] == 688
|
||||
|
||||
def test_retrieve_nonexistent_returns_404(self, auth_client, books):
|
||||
url = reverse("book-detail", kwargs={"pk": 99999})
|
||||
response = auth_client.get(url)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_pagination(self, auth_client, books):
|
||||
"""List with small page size should paginate."""
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(f"{url}?page_size=2")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "count" in response.data
|
||||
assert "results" in response.data
|
||||
assert response.data["count"] == 4
|
||||
|
||||
def test_ordering(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"ordering": "title"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert titles == sorted(titles)
|
||||
+140
-3
@@ -1,3 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import QuerySet, Q
|
||||
@@ -9,13 +12,16 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.books.models import Book, EBook, ReadingProgress, ReadingSettings
|
||||
from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingSettings
|
||||
from apps.books.serializers import (
|
||||
BookDetailSerializer, BookListSerializer, BookSerializer,
|
||||
EBookDetailSerializer, EBookListSerializer, EBookUploadSerializer,
|
||||
BookChapterSerializer, EBookContentSerializer, EBookDetailSerializer,
|
||||
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
|
||||
ReadingProgressSerializer, ReadingSettingsSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookViewSet(viewsets.ModelViewSet):
|
||||
queryset = Book.objects.all()
|
||||
@@ -63,8 +69,10 @@ class EBookViewSet(viewsets.ModelViewSet):
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return EBookUploadSerializer
|
||||
if self.action == "list":
|
||||
if self.action in ("list",):
|
||||
return EBookListSerializer
|
||||
if self.action in ("toc",):
|
||||
return BookChapterSerializer
|
||||
return EBookDetailSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -82,6 +90,135 @@ class EBookViewSet(viewsets.ModelViewSet):
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def process(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
|
||||
ebook = self.get_object()
|
||||
if not ebook.file:
|
||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
from apps.books.services import process_ebook
|
||||
|
||||
file_path = ebook.file.path
|
||||
result = process_ebook(file_path, original_filename=ebook.filename())
|
||||
|
||||
# Update ebook with extracted data
|
||||
ebook.format = result.get("format", ebook.format)
|
||||
ebook.page_count = result.get("page_count", 0)
|
||||
ebook.metadata_json = result.get("metadata", {})
|
||||
ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"])
|
||||
|
||||
# Store chapters in DB
|
||||
raw_toc: list[dict[str, Any]] = result.get("toc", [])
|
||||
BookChapter.objects.filter(ebook=ebook).delete()
|
||||
_store_chapters(ebook, raw_toc)
|
||||
|
||||
return Response({
|
||||
"format": ebook.format,
|
||||
"page_count": ebook.page_count,
|
||||
"metadata": ebook.metadata_json,
|
||||
"toc_count": len(raw_toc),
|
||||
"status": "processed",
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to process ebook %s", ebook.id)
|
||||
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def toc(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Return hierarchical table of contents."""
|
||||
ebook = self.get_object()
|
||||
chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
|
||||
serializer = BookChapterSerializer(chapters, many=True)
|
||||
return Response({
|
||||
"chapters": serializer.data,
|
||||
"format": ebook.format,
|
||||
"page_count": ebook.page_count,
|
||||
})
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def content(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Return paginated content for a given page number.
|
||||
|
||||
Query params:
|
||||
page (int): page/chapter index to fetch (1-indexed, default: 1)
|
||||
"""
|
||||
ebook = self.get_object()
|
||||
page = max(1, int(request.query_params.get("page", 1)))
|
||||
|
||||
chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook"))
|
||||
total_pages = len(chapters) or ebook.page_count or 1
|
||||
|
||||
chapter: BookChapter | None = None
|
||||
chapter_title = ""
|
||||
content_html = ""
|
||||
|
||||
if chapters and 0 <= page - 1 < len(chapters):
|
||||
ch = chapters[page - 1]
|
||||
chapter_title = ch.title
|
||||
content_html = _fetch_chapter_content(ebook, ch)
|
||||
|
||||
serializer = EBookContentSerializer(data={
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"content": content_html,
|
||||
"chapter_title": chapter_title,
|
||||
"format": ebook.format,
|
||||
})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
def _store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None:
|
||||
"""Recursively store TOC entries as BookChapter records."""
|
||||
for idx, entry in enumerate(toc):
|
||||
BookChapter.objects.create(
|
||||
ebook=ebook,
|
||||
title=entry.get("title", "Untitled"),
|
||||
index=parent_index + idx,
|
||||
href=entry.get("href", ""),
|
||||
children=entry.get("children", []),
|
||||
)
|
||||
children = entry.get("children", [])
|
||||
if children:
|
||||
_store_chapters(ebook, children, parent_index + idx + 1)
|
||||
|
||||
|
||||
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
|
||||
"""Fetch HTML content for a chapter from the e-book file."""
|
||||
if ebook.format == "epub":
|
||||
return _fetch_epub_chapter_content(ebook.file.path, chapter)
|
||||
return ""
|
||||
|
||||
|
||||
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
|
||||
"""Extract HTML content of a specific EPUB chapter by href."""
|
||||
try:
|
||||
from ebooklib import epub
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
try:
|
||||
book = epub.read_epub(file_path)
|
||||
href = chapter.href or ""
|
||||
# Find the item by href
|
||||
for item in book.get_items():
|
||||
item_name = item.get_name() or ""
|
||||
if href and (item_name.endswith(href) or href.endswith(item_name)):
|
||||
content = item.get_content()
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
# Clean up — remove body/html/head wrappers, keep inner content
|
||||
body = soup.find("body")
|
||||
if body:
|
||||
return str(body)
|
||||
return str(soup)
|
||||
return ""
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
|
||||
return ""
|
||||
|
||||
|
||||
class ReadingSettingsViewSet(viewsets.GenericViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
Reference in New Issue
Block a user