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,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
|
||||
Reference in New Issue
Block a user