Archived
- 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
91 lines
3.3 KiB
Python
91 lines
3.3 KiB
Python
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,
|
|
}) |