Archived
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:
@@ -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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BooksConfig(AppConfig):
|
||||
name = 'books'
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for config project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Django settings for config project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.5.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from config.settings_app import settings
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = settings.SECRET_KEY
|
||||
DEBUG = settings.DEBUG
|
||||
ALLOWED_HOSTS = settings.ALLOWED_HOSTS
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Third-party
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
# Local apps
|
||||
"books",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS
|
||||
CORS_ALLOW_CREDENTIALS = settings.CORS_ALLOW_CREDENTIALS
|
||||
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.BasicAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
DEBUG: bool = True
|
||||
SECRET_KEY: str = "django-insecure-change-me-in-production"
|
||||
DATABASE_URL: str = "sqlite:///db.sqlite3"
|
||||
ALLOWED_HOSTS: list[str] = ["*"]
|
||||
CORS_ALLOWED_ORIGINS: list[str] = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CR_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api-auth/", include("rest_framework.urls")),
|
||||
path("api/", include("books.urls")),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for config project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = config.settings
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
@@ -0,0 +1,6 @@
|
||||
django>=5.0,<7.0
|
||||
djangorestframework>=3.15.0
|
||||
django-cors-headers>=4.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
pytest-django>=4.5.0
|
||||
Reference in New Issue
Block a user