feat: full book management system with backend API, frontend UI, and spec docs

- Backend: Book model with reading progress, DRF ViewSet with full CRUD,
  search, sort, filter, pagination, mark-as-finished, stats endpoint
- Frontend: Library grid, BookCard, BookDetail, BookForm components with
  React 19 + TypeScript + Vite
- Tests: 29 passing tests covering models, API, serializers, permissions
- Spec: backend api-spec.md and frontend component-spec.md in docs/

Closes crisleo-hermes/cloud-reader#3
This commit is contained in:
Marko (Hermes Implementer)
2026-05-26 04:35:13 +00:00
commit 84d8fed3f2
49 changed files with 4205 additions and 0 deletions
View File
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from django.contrib import admin
from books.models import Book
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
"""Admin configuration for the Book model."""
list_display = ["title", "author", "genre", "reading_status", "reading_progress", "owner", "updated_at"]
list_filter = ["reading_status", "genre"]
search_fields = ["title", "author"]
readonly_fields = ["reading_progress", "created_at", "updated_at"]
raw_id_fields = ["owner"]
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class BooksConfig(AppConfig):
name = 'books'
+41
View File
@@ -0,0 +1,41 @@
# Generated by Django 6.0.5 on 2026-05-26 00:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Book',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=500, verbose_name='Title')),
('author', models.CharField(max_length=500, verbose_name='Author')),
('genre', models.CharField(blank=True, default='', max_length=200, verbose_name='Genre')),
('description', models.TextField(blank=True, default='', verbose_name='Description')),
('cover_image_url', models.URLField(blank=True, default='', verbose_name='Cover Image URL')),
('isbn', models.CharField(blank=True, default='', max_length=20, verbose_name='ISBN')),
('total_pages', models.PositiveIntegerField(default=0, verbose_name='Total Pages')),
('current_page', models.PositiveIntegerField(default=0, verbose_name='Current Page')),
('reading_status', models.CharField(choices=[('not_started', 'Not Started'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], default='not_started', max_length=20, verbose_name='Reading Status')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='books', to=settings.AUTH_USER_MODEL, verbose_name='Owner')),
],
options={
'verbose_name': 'Book',
'verbose_name_plural': 'Books',
'ordering': ['-updated_at'],
'indexes': [models.Index(fields=['owner', 'reading_status'], name='books_book_owner_i_424622_idx'), models.Index(fields=['owner', 'title'], name='books_book_owner_i_350794_idx'), models.Index(fields=['owner', 'author'], name='books_book_owner_i_f90626_idx')],
},
),
]
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from django.db import models
from django.utils.translation import gettext_lazy as _
class ReadingStatus(models.TextChoices):
"""Enumeration of possible reading statuses for a book."""
NOT_STARTED = "not_started", _("Not Started")
READING = "reading", _("Reading")
FINISHED = "finished", _("Finished")
DNF = "dnf", _("Did Not Finish")
class Book(models.Model):
"""Represents a book in the user's library."""
title = models.CharField(max_length=500, verbose_name=_("Title"))
author = models.CharField(max_length=500, verbose_name=_("Author"))
genre = models.CharField(max_length=200, blank=True, default="", verbose_name=_("Genre"))
description = models.TextField(blank=True, default="", verbose_name=_("Description"))
cover_image_url = models.URLField(blank=True, default="", verbose_name=_("Cover Image URL"))
isbn = models.CharField(max_length=20, blank=True, default="", verbose_name=_("ISBN"))
total_pages = models.PositiveIntegerField(default=0, verbose_name=_("Total Pages"))
current_page = models.PositiveIntegerField(default=0, verbose_name=_("Current Page"))
reading_status = models.CharField(
max_length=20,
choices=ReadingStatus.choices,
default=ReadingStatus.NOT_STARTED,
verbose_name=_("Reading Status"),
)
owner = models.ForeignKey(
"auth.User",
on_delete=models.CASCADE,
related_name="books",
verbose_name=_("Owner"),
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
class Meta:
ordering = ["-updated_at"]
verbose_name = _("Book")
verbose_name_plural = _("Books")
indexes = [
models.Index(fields=["owner", "reading_status"]),
models.Index(fields=["owner", "title"]),
models.Index(fields=["owner", "author"]),
]
def __str__(self) -> str:
return f"{self.title} by {self.author}"
@property
def reading_progress(self) -> float:
"""Calculate reading progress as a percentage (0.0 - 100.0)."""
if self.total_pages == 0:
return 0.0
return round((self.current_page / self.total_pages) * 100, 1)
def mark_as_finished(self) -> None:
"""Mark the book as finished, setting progress to 100%."""
self.reading_status = ReadingStatus.FINISHED
self.current_page = self.total_pages
self.save(update_fields=["reading_status", "current_page", "updated_at"])
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from rest_framework import serializers
from books.models import Book, ReadingStatus
class BookListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for list views — avoids heavy field serialization."""
reading_progress = serializers.FloatField(read_only=True)
class Meta:
model = Book
fields = [
"id",
"title",
"author",
"genre",
"reading_status",
"reading_progress",
"cover_image_url",
"created_at",
"updated_at",
]
class BookDetailSerializer(serializers.ModelSerializer):
"""Full serializer for book detail views including all metadata."""
reading_progress = serializers.FloatField(read_only=True)
owner = serializers.ReadOnlyField(source="owner.username")
class Meta:
model = Book
fields = [
"id",
"title",
"author",
"genre",
"description",
"cover_image_url",
"isbn",
"total_pages",
"current_page",
"reading_status",
"reading_progress",
"owner",
"created_at",
"updated_at",
]
read_only_fields = ["owner", "created_at", "updated_at", "reading_progress"]
def validate_title(self, value: str) -> str:
"""Ensure title is not just whitespace."""
stripped = value.strip()
if not stripped:
msg = "Title cannot be empty."
raise serializers.ValidationError(msg)
return stripped
def validate_author(self, value: str) -> str:
"""Ensure author is not just whitespace."""
stripped = value.strip()
if not stripped:
msg = "Author cannot be empty."
raise serializers.ValidationError(msg)
return stripped
def validate(self, attrs: dict) -> dict:
"""Business rules — handles both full creates and partial updates."""
# Resolve effective values: use provided attrs, fall back to instance
current_total = getattr(self.instance, "total_pages", None)
current_current = getattr(self.instance, "current_page", None)
total_pages = attrs.get("total_pages", current_total) or 0
current_page = attrs.get("current_page", current_current) or 0
reading_status = attrs.get("reading_status", None)
if current_page > total_pages > 0:
msg = "Current page cannot exceed total pages."
raise serializers.ValidationError({"current_page": msg})
if reading_status == ReadingStatus.FINISHED:
if total_pages > 0:
attrs["current_page"] = total_pages
elif self.instance and self.instance.total_pages > 0:
attrs["current_page"] = self.instance.total_pages
return attrs
class BookWriteSerializer(BookDetailSerializer):
"""Alias for detail serializer — used for write operations with full validation."""
pass
+325
View File
@@ -0,0 +1,325 @@
"""Tests for the books app — API endpoints, models, serializers, and permissions."""
from __future__ import annotations
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.test import APIClient
from books.models import Book, ReadingStatus
class BookModelTests(TestCase):
"""Tests for the Book model."""
def setUp(self) -> None:
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.book = Book.objects.create(
title="Test Book",
author="Test Author",
total_pages=200,
current_page=50,
reading_status=ReadingStatus.READING,
owner=self.user,
)
def test_reading_progress_calculates_correctly(self) -> None:
"""Reading progress should be (current_page / total_pages) * 100."""
assert self.book.reading_progress == 25.0
def test_reading_progress_returns_zero_when_no_pages(self) -> None:
"""When total_pages is 0, reading_progress should be 0.0."""
book = Book.objects.create(
title="No Pages",
author="Author",
total_pages=0,
current_page=50,
owner=self.user,
)
assert book.reading_progress == 0.0
def test_mark_as_finished_sets_progress_to_100(self) -> None:
"""mark_as_finished should set status to FINISHED and progress to 100%."""
self.book.mark_as_finished()
self.book.refresh_from_db()
assert self.book.reading_status == ReadingStatus.FINISHED
assert self.book.current_page == self.book.total_pages
def test_mark_as_finished_with_zero_pages(self) -> None:
"""mark_as_finished with total_pages=0 should set 0/0."""
book = Book.objects.create(
title="Empty",
author="Author",
total_pages=0,
current_page=0,
owner=self.user,
)
book.mark_as_finished()
book.refresh_from_db()
assert book.reading_status == ReadingStatus.FINISHED
assert book.current_page == 0
def test_str_method(self) -> None:
"""__str__ should return 'Title by Author'."""
assert str(self.book) == "Test Book by Test Author"
class BookAPITests(TestCase):
"""Tests for the Book API endpoints."""
def setUp(self) -> None:
self.client = APIClient()
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.other_user = User.objects.create_user(username="other", password="testpass123")
self.client.force_authenticate(user=self.user)
self.book = Book.objects.create(
title="My Book",
author="My Author",
genre="Fiction",
description="A great book",
total_pages=200,
current_page=50,
reading_status=ReadingStatus.READING,
owner=self.user,
)
# Other user's book (should not be visible)
Book.objects.create(
title="Other Book",
author="Other Author",
total_pages=100,
owner=self.other_user,
)
# --- List ---
def test_list_books_returns_owned_books_only(self) -> None:
"""GET /api/books/ should only return books owned by the current user."""
resp = self.client.get("/api/books/")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 1
assert resp.data["results"][0]["title"] == "My Book"
def test_list_books_requires_authentication(self) -> None:
"""GET /api/books/ without auth should return 403."""
self.client.force_authenticate(user=None)
resp = self.client.get("/api/books/")
assert resp.status_code == status.HTTP_403_FORBIDDEN
# --- Create ---
def test_create_book_sets_owner(self) -> None:
"""POST /api/books/ should create a book owned by the current user."""
resp = self.client.post("/api/books/", {
"title": "New Book",
"author": "New Author",
}, format="json")
assert resp.status_code == status.HTTP_201_CREATED
assert resp.data["owner"] == "testuser"
assert Book.objects.filter(title="New Book", owner=self.user).exists()
def test_create_book_validates_required_fields(self) -> None:
"""POST /api/books/ with missing title should fail."""
resp = self.client.post("/api/books/", {
"author": "Author Only",
}, format="json")
assert resp.status_code == status.HTTP_400_BAD_REQUEST
# --- Retrieve ---
def test_retrieve_book(self) -> None:
"""GET /api/books/{id}/ should return full book details."""
resp = self.client.get(f"/api/books/{self.book.id}/")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["title"] == "My Book"
assert resp.data["reading_progress"] == 25.0
assert resp.data["owner"] == "testuser"
def test_retrieve_other_users_book_returns_404(self) -> None:
"""GET /api/books/{other_id}/ should return 404 for another user's book."""
other_book = Book.objects.get(title="Other Book")
resp = self.client.get(f"/api/books/{other_book.id}/")
assert resp.status_code == status.HTTP_404_NOT_FOUND
# --- Update ---
def test_update_book(self) -> None:
"""PATCH /api/books/{id}/ should update book fields."""
resp = self.client.patch(f"/api/books/{self.book.id}/", {
"title": "Updated Title",
"current_page": 100,
}, format="json")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["title"] == "Updated Title"
assert resp.data["reading_progress"] == 50.0
def test_update_current_page_exceeds_total(self) -> None:
"""PATCH with current_page > total_pages should fail."""
resp = self.client.patch(f"/api/books/{self.book.id}/", {
"current_page": 999,
}, format="json")
assert resp.status_code == status.HTTP_400_BAD_REQUEST
# --- Delete ---
def test_delete_book(self) -> None:
"""DELETE /api/books/{id}/ should delete the book."""
resp = self.client.delete(f"/api/books/{self.book.id}/")
assert resp.status_code == status.HTTP_204_NO_CONTENT
assert not Book.objects.filter(id=self.book.id).exists()
# --- Mark Finished ---
def test_mark_finished_sets_100_percent(self) -> None:
"""POST /api/books/{id}/mark_finished/ should set progress to 100%."""
resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["reading_status"] == ReadingStatus.FINISHED
assert resp.data["reading_progress"] == 100.0
def test_mark_finished_idempotent(self) -> None:
"""Calling mark_finished twice should be safe."""
self.client.post(f"/api/books/{self.book.id}/mark_finished/")
resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/")
assert resp.status_code == status.HTTP_200_OK
def test_mark_finished_other_users_book(self) -> None:
"""POST mark_finished on another user's book should return 404."""
other_book = Book.objects.get(title="Other Book")
resp = self.client.post(f"/api/books/{other_book.id}/mark_finished/")
assert resp.status_code == status.HTTP_404_NOT_FOUND
# --- Stats ---
def test_stats_returns_counts(self) -> None:
"""GET /api/books/stats/ should return aggregate counts."""
resp = self.client.get("/api/books/stats/")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["total_books"] == 1
assert resp.data["finished"] == 0
assert resp.data["reading"] == 1
assert resp.data["not_started"] == 0
# --- Filtering & Sorting ---
def test_filter_by_reading_status(self) -> None:
"""GET /api/books/?reading_status=reading should filter correctly."""
resp = self.client.get("/api/books/", {"reading_status": "reading"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 1
resp = self.client.get("/api/books/", {"reading_status": "finished"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 0
def test_search_by_title(self) -> None:
"""GET /api/books/?search=My should filter by title."""
resp = self.client.get("/api/books/", {"search": "My"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 1
resp = self.client.get("/api/books/", {"search": "Nonexistent"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 0
def test_search_by_author(self) -> None:
"""GET /api/books/?search=My should filter by author."""
resp = self.client.get("/api/books/", {"search": "My Author"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 1
def test_sort_by_title(self) -> None:
"""GET /api/books/?sort_by=title should sort alphabetically."""
Book.objects.create(title="Aardvark", author="Author", owner=self.user)
Book.objects.create(title="Zebra", author="Author", owner=self.user)
resp = self.client.get("/api/books/", {"sort_by": "title"})
assert resp.status_code == status.HTTP_200_OK
titles = [b["title"] for b in resp.data["results"]]
assert titles == sorted(titles)
def test_sort_by_author_desc(self) -> None:
"""GET /api/books/?sort_by=-author should sort by author desc."""
resp = self.client.get("/api/books/", {"sort_by": "-author"})
assert resp.status_code == status.HTTP_200_OK
# --- Pagination ---
def test_pagination_default_page_size(self) -> None:
"""Books should be paginated with default page size."""
for i in range(25):
Book.objects.create(
title=f"Book {i}",
author="Author",
owner=self.user,
)
resp = self.client.get("/api/books/")
assert resp.status_code == status.HTTP_200_OK
assert len(resp.data["results"]) == 20 # PAGE_SIZE = 20
assert resp.data["count"] == 26 # 1 original + 25 new
def test_pagination_second_page(self) -> None:
"""GET /api/books/?page=2 should return remaining items."""
for i in range(25):
Book.objects.create(
title=f"Book {i}",
author="Author",
owner=self.user,
)
resp = self.client.get("/api/books/", {"page": 2})
assert resp.status_code == status.HTTP_200_OK
assert len(resp.data["results"]) == 6
class BookSerializerTests(TestCase):
"""Tests for serializers — validation rules."""
def setUp(self) -> None:
self.user = User.objects.create_user(username="testuser", password="testpass123")
def test_current_page_cannot_exceed_total(self) -> None:
"""Serializer should reject current_page > total_pages."""
from books.serializers import BookDetailSerializer
data = {
"title": "Test",
"author": "Author",
"total_pages": 100,
"current_page": 150,
}
serializer = BookDetailSerializer(data=data)
assert not serializer.is_valid()
assert "current_page" in serializer.errors
def test_title_cannot_be_blank(self) -> None:
"""Serializer should reject blank title."""
from books.serializers import BookDetailSerializer
data = {
"title": " ",
"author": "Author",
}
serializer = BookDetailSerializer(data=data)
assert not serializer.is_valid()
assert "title" in serializer.errors
def test_author_cannot_be_blank(self) -> None:
"""Serializer should reject blank author."""
from books.serializers import BookDetailSerializer
data = {
"title": "Test",
"author": "",
}
serializer = BookDetailSerializer(data=data)
assert not serializer.is_valid()
assert "author" in serializer.errors
def test_list_serializer_has_minimal_fields(self) -> None:
"""BookListSerializer should exclude sensitive/fluff fields."""
from books.serializers import BookListSerializer
serializer = BookListSerializer()
fields = set(serializer.fields.keys())
assert "id" in fields
assert "title" in fields
assert "author" in fields
assert "reading_progress" in fields
assert "description" not in fields
assert "isbn" not in fields
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from books.views import BookViewSet
router = DefaultRouter()
router.register(r"books", BookViewSet, basename="book")
urlpatterns = [
path("", include(router.urls)),
]
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from django.db import models
from django.db.models import QuerySet
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from books.models import Book, ReadingStatus
from books.serializers import BookDetailSerializer, BookListSerializer
class BookViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing books in the user's library.
Provides:
- list / retrieve / create / update / partial_update / destroy
- `mark_finished` action to set a book as 100% complete
- `stats` action for library overview counts
"""
permission_classes = [IsAuthenticated]
def get_serializer_class(self) -> type:
if self.action == "list":
return BookListSerializer
return BookDetailSerializer
def get_queryset(self) -> QuerySet[Book]:
"""Return books owned by the current user with optimised queries."""
qs = Book.objects.filter(owner=self.request.user).select_related("owner")
# Sorting
sort_by = self.request.query_params.get("sort_by", "-updated_at")
allowed_sorts = {
"title": "title",
"-title": "-title",
"author": "author",
"-author": "-author",
"created_at": "created_at",
"-created_at": "-created_at",
"updated_at": "updated_at",
"-updated_at": "-updated_at",
"reading_progress": "current_page", # approximate sort by pages read
"-reading_progress": "-current_page",
}
if sort_by in allowed_sorts:
qs = qs.order_by(allowed_sorts[sort_by])
# Filtering
status_filter = self.request.query_params.get("reading_status", None)
if status_filter in ReadingStatus.values:
qs = qs.filter(reading_status=status_filter)
search = self.request.query_params.get("search", "").strip()
if search:
qs = qs.filter(
models.Q(title__icontains=search) | models.Q(author__icontains=search)
)
return qs
def perform_create(self, serializer: BookDetailSerializer) -> None:
"""Set the owner to the current user on creation."""
serializer.save(owner=self.request.user)
@action(detail=True, methods=["post"])
def mark_finished(self, request: Request, pk: int | None = None) -> Response:
"""Mark a book as finished (100% progress)."""
book: Book = self.get_object()
book.mark_as_finished()
serializer = self.get_serializer(book)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"])
def stats(self, request: Request) -> Response:
"""Return aggregate stats about the user's library."""
qs = self.get_queryset()
total = qs.count()
finished = qs.filter(reading_status=ReadingStatus.FINISHED).count()
reading = qs.filter(reading_status=ReadingStatus.READING).count()
not_started = qs.filter(reading_status=ReadingStatus.NOT_STARTED).count()
return Response({
"total_books": total,
"finished": finished,
"reading": reading,
"not_started": not_started,
})