Archived
feat: bookmarks and notes management
- Backend: Django REST Framework API with Bookmark and Note models - ViewSets with user-scoped querysets and select_related for N+1 prevention - Create/List/Detail/Update/Delete endpoints - Batch delete operations - Unique constraint on user+book+page for bookmarks - IsOwner permission class for object-level access control - Full serializer validation (page > 0, non-empty content, duplicate check) - 30+ pytest-django tests covering CRUD, auth, filtering, edge cases - Frontend: React TypeScript components - AnnotationsContext with useReducer for state management - BookmarkList, NoteList, AddAnnotationForm, AnnotationsDashboard - Inline note editing with immediate save - Batch delete support - API client with JWT auto-refresh interceptors - Paginated query hook for infinite scroll support - Responsive CSS with loading/empty states - Infrastructure: Django project with custom User model, JWT auth, CORS - PostgreSQL database models with proper FK and indexes - Django admin configuration for all models
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from books.models import Book, ReadingProgress, ReadingSettings
|
||||
|
||||
|
||||
@admin.register(Book)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "author", "user", "created_at"]
|
||||
list_filter = ["created_at", "user"]
|
||||
search_fields = ["title", "author"]
|
||||
|
||||
|
||||
@admin.register(ReadingProgress)
|
||||
class ReadingProgressAdmin(admin.ModelAdmin):
|
||||
list_display = ["book", "user", "current_position", "updated_at"]
|
||||
|
||||
|
||||
@admin.register(ReadingSettings)
|
||||
class ReadingSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "font_size", "font_style", "background_color"]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BooksConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "books"
|
||||
@@ -0,0 +1 @@
|
||||
# management/__init__.py
|
||||
@@ -0,0 +1 @@
|
||||
# management/commands/__init__.py
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Management command to seed sample book data for development and testing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from books.models import Book, ReadingStatus
|
||||
|
||||
SAMPLE_BOOKS: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "The Great Gatsby",
|
||||
"author": "F. Scott Fitzgerald",
|
||||
"genre": "Classic Literature",
|
||||
"description": "A story of the mysteriously wealthy Jay Gatsby and his love for the beautiful Daisy Buchanan.",
|
||||
"reading_status": ReadingStatus.FINISHED,
|
||||
},
|
||||
{
|
||||
"title": "To Kill a Mockingbird",
|
||||
"author": "Harper Lee",
|
||||
"genre": "Classic Literature",
|
||||
"description": "The unforgettable novel of a childhood in a sleepy Southern town and the crisis of conscience that rocked it.",
|
||||
"reading_status": ReadingStatus.FINISHED,
|
||||
},
|
||||
{
|
||||
"title": "1984",
|
||||
"author": "George Orwell",
|
||||
"genre": "Science Fiction",
|
||||
"description": "A dystopian social science fiction novel and cautionary tale about the future of totalitarianism.",
|
||||
"reading_status": ReadingStatus.READING,
|
||||
},
|
||||
{
|
||||
"title": "Dune",
|
||||
"author": "Frank Herbert",
|
||||
"genre": "Science Fiction",
|
||||
"description": "Set on the desert planet Arrakis, it is one of the world's best-selling science fiction novels.",
|
||||
"reading_status": ReadingStatus.FINISHED,
|
||||
},
|
||||
{
|
||||
"title": "The Hobbit",
|
||||
"author": "J.R.R. Tolkien",
|
||||
"genre": "Fantasy",
|
||||
"description": "Bilbo Baggins is swept into a quest to reclaim the lost Dwarf Kingdom of Erebor from the fearsome dragon Smaug.",
|
||||
"reading_status": ReadingStatus.FINISHED,
|
||||
},
|
||||
{
|
||||
"title": "Pride and Prejudice",
|
||||
"author": "Jane Austen",
|
||||
"genre": "Romance",
|
||||
"description": "A romantic novel of manners that follows the character development of Elizabeth Bennet.",
|
||||
"reading_status": ReadingStatus.WANT_TO_READ,
|
||||
},
|
||||
{
|
||||
"title": "The Martian",
|
||||
"author": "Andy Weir",
|
||||
"genre": "Science Fiction",
|
||||
"description": "An astronaut becomes stranded alone on Mars and must find a way to signal that he is alive.",
|
||||
"reading_status": ReadingStatus.READING,
|
||||
},
|
||||
{
|
||||
"title": "The Catcher in the Rye",
|
||||
"author": "J.D. Salinger",
|
||||
"genre": "Classic Literature",
|
||||
"description": "The story of Holden Caulfield's experiences in New York City after being expelled from prep school.",
|
||||
"reading_status": ReadingStatus.DNF,
|
||||
},
|
||||
{
|
||||
"title": "The Alchemist",
|
||||
"author": "Paulo Coelho",
|
||||
"genre": "Philosophy",
|
||||
"description": "A young Andalusian shepherd follows his dream to find treasure at the Egyptian pyramids.",
|
||||
"reading_status": ReadingStatus.WANT_TO_READ,
|
||||
},
|
||||
{
|
||||
"title": "Neuromancer",
|
||||
"author": "William Gibson",
|
||||
"genre": "Science Fiction",
|
||||
"description": "The novel that launched the cyberpunk genre, following washed-up computer hacker Henry Case.",
|
||||
"reading_status": ReadingStatus.FINISHED,
|
||||
},
|
||||
{
|
||||
"title": "The Name of the Wind",
|
||||
"author": "Patrick Rothfuss",
|
||||
"genre": "Fantasy",
|
||||
"description": "The tale of the magically gifted young man who grows to be the most notorious wizard his world has ever seen.",
|
||||
"reading_status": ReadingStatus.READING,
|
||||
},
|
||||
{
|
||||
"title": "Gone Girl",
|
||||
"author": "Gillian Flynn",
|
||||
"genre": "Thriller",
|
||||
"description": "A mystery thriller about a wife's disappearance on the day of her fifth wedding anniversary.",
|
||||
"reading_status": ReadingStatus.FINISHED,
|
||||
},
|
||||
{
|
||||
"title": "Sapiens",
|
||||
"author": "Yuval Noah Harari",
|
||||
"genre": "Non-Fiction",
|
||||
"description": "A brief history of humankind, exploring how biology and history have defined us.",
|
||||
"reading_status": ReadingStatus.FINISHED,
|
||||
},
|
||||
{
|
||||
"title": "The Road",
|
||||
"author": "Cormac McCarthy",
|
||||
"genre": "Post-Apocalyptic",
|
||||
"description": "A father and his young son walk alone through burned America heading toward the coast.",
|
||||
"reading_status": ReadingStatus.WANT_TO_READ,
|
||||
},
|
||||
{
|
||||
"title": "The Three-Body Problem",
|
||||
"author": "Cixin Liu",
|
||||
"genre": "Science Fiction",
|
||||
"description": "The first novel in the Remembrance of Earth's Past trilogy, a blend of physics and alien contact.",
|
||||
"reading_status": ReadingStatus.READING,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Seed the database with sample books."""
|
||||
|
||||
help = "Seeds the database with sample books for development and testing."
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> str | None:
|
||||
for i, book_data in enumerate(SAMPLE_BOOKS):
|
||||
Book.objects.get_or_create(
|
||||
title=book_data["title"],
|
||||
author=book_data["author"],
|
||||
defaults=book_data,
|
||||
)
|
||||
count = Book.objects.count()
|
||||
self.stdout.write(self.style.SUCCESS(f"Seeded {count} books into the database."))
|
||||
@@ -0,0 +1,56 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies: list = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Book",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(db_index=True, max_length=500)),
|
||||
("author", models.CharField(db_index=True, max_length=300)),
|
||||
("genre", models.CharField(db_index=True, max_length=100)),
|
||||
("description", models.TextField(blank=True, default="")),
|
||||
(
|
||||
"reading_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("want_to_read", "Want to Read"),
|
||||
("reading", "Reading"),
|
||||
("finished", "Finished"),
|
||||
("dnf", "Did Not Finish"),
|
||||
],
|
||||
db_index=True,
|
||||
default="want_to_read",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("cover_url", models.URLField(blank=True, default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["title"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["title", "author", "genre"],
|
||||
name="books_book_title_2cc25e_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
# migrations/__init__.py
|
||||
@@ -0,0 +1,111 @@
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
||||
class FontStyle(StrEnum):
|
||||
SANS_SERIF = "sans-serif"
|
||||
SERIF = "serif"
|
||||
MONOSPACE = "monospace"
|
||||
|
||||
|
||||
class BackgroundColor(StrEnum):
|
||||
WHITE = "#ffffff"
|
||||
SEPIA = "#f4e4c1"
|
||||
DARK = "#1a1a2e"
|
||||
GREEN = "#c7edcc"
|
||||
|
||||
|
||||
class Book(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="books",
|
||||
)
|
||||
title = models.CharField(max_length=512)
|
||||
author = models.CharField(max_length=256, blank=True, default="")
|
||||
file = models.FileField(
|
||||
upload_to="books/%Y/%m/%d/",
|
||||
validators=[FileExtensionValidator(allowed_extensions=["epub", "pdf"])],
|
||||
)
|
||||
cover_image = models.ImageField(upload_to="covers/%Y/%m/%d/", blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
def filename(self) -> str:
|
||||
return Path(self.file.name).name
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Book)
|
||||
def _auto_delete_file_on_delete(sender: type[Book], instance: Book, **kwargs: object) -> None:
|
||||
"""Delete the uploaded file when the Book record is deleted."""
|
||||
if instance.file:
|
||||
instance.file.delete(save=False)
|
||||
if instance.cover_image:
|
||||
instance.cover_image.delete(save=False)
|
||||
|
||||
|
||||
class ReadingProgress(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reading_progress",
|
||||
)
|
||||
book = models.OneToOneField(
|
||||
Book,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reading_progress",
|
||||
)
|
||||
current_position = models.FloatField(default=0.0)
|
||||
"""Position in the book as a percentage (0.0 to 100.0)"""
|
||||
last_page = models.IntegerField(default=0)
|
||||
"""Last page/paragraph index for granular tracking"""
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "reading progress"
|
||||
unique_together = [("user", "book")]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.book.title} — {self.current_position:.1f}%"
|
||||
|
||||
|
||||
class ReadingSettings(models.Model):
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reading_settings",
|
||||
)
|
||||
font_size = models.IntegerField(default=18)
|
||||
"""Font size in pixels (min 12, max 36)"""
|
||||
font_style = models.CharField(
|
||||
max_length=20,
|
||||
choices=[(s.value, s.name.replace("_", " ").title()) for s in FontStyle],
|
||||
default=FontStyle.SANS_SERIF.value,
|
||||
)
|
||||
background_color = models.CharField(
|
||||
max_length=7,
|
||||
choices=[(c.value, c.name.title()) for c in BackgroundColor],
|
||||
default=BackgroundColor.WHITE.value,
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "reading settings"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Settings for {self.user}"
|
||||
@@ -0,0 +1,136 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from books.models import Book, FontStyle, BackgroundColor, ReadingProgress, ReadingSettings
|
||||
|
||||
|
||||
class BookListSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight serializer for the library listing — no file or progress data."""
|
||||
|
||||
filename = serializers.CharField(read_only=True)
|
||||
progress = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"author",
|
||||
"filename",
|
||||
"cover_image",
|
||||
"created_at",
|
||||
"progress",
|
||||
]
|
||||
|
||||
def get_progress(self, obj: Book) -> float | None:
|
||||
try:
|
||||
return obj.reading_progress.current_position
|
||||
except ReadingProgress.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class BookDetailSerializer(serializers.ModelSerializer):
|
||||
"""Full serializer for the reader view — includes file URL and progress."""
|
||||
|
||||
filename = serializers.CharField(read_only=True)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
progress = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"author",
|
||||
"filename",
|
||||
"file_url",
|
||||
"cover_image",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"progress",
|
||||
]
|
||||
|
||||
def get_file_url(self, obj: Book) -> str:
|
||||
request = self.context.get("request")
|
||||
if request and obj.file:
|
||||
return request.build_absolute_uri(obj.file.url)
|
||||
return ""
|
||||
|
||||
def get_progress(self, obj: Book) -> dict | None:
|
||||
try:
|
||||
rp = obj.reading_progress
|
||||
return {
|
||||
"current_position": rp.current_position,
|
||||
"last_page": rp.last_page,
|
||||
}
|
||||
except ReadingProgress.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class BookUploadSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for uploading a new book."""
|
||||
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = ["title", "author", "file", "cover_image"]
|
||||
extra_kwargs = {
|
||||
"title": {"required": True},
|
||||
"file": {"required": True},
|
||||
}
|
||||
|
||||
def validate_file(self, value: object) -> object:
|
||||
import os
|
||||
if isinstance(value, type(None)):
|
||||
return value
|
||||
ext = os.path.splitext(str(getattr(value, 'name', '')))[1].lower()
|
||||
if ext not in (".epub", ".pdf"):
|
||||
raise serializers.ValidationError(
|
||||
"Only EPUB and PDF files are supported."
|
||||
)
|
||||
return value
|
||||
|
||||
def create(self, validated_data: dict) -> Book:
|
||||
validated_data["user"] = self.context["request"].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class ReadingProgressSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ReadingProgress
|
||||
fields = ["current_position", "last_page"]
|
||||
extra_kwargs = {
|
||||
"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0},
|
||||
}
|
||||
|
||||
def validate_current_position(self, value: float) -> float:
|
||||
if value < 0.0 or value > 100.0:
|
||||
raise serializers.ValidationError(
|
||||
"Position must be between 0.0 and 100.0."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class ReadingSettingsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ReadingSettings
|
||||
fields = ["font_size", "font_style", "background_color"]
|
||||
|
||||
def validate_font_size(self, value: int) -> int:
|
||||
if value < 12 or value > 36:
|
||||
raise serializers.ValidationError("Font size must be between 12 and 36.")
|
||||
return value
|
||||
|
||||
def validate_font_style(self, value: str) -> str:
|
||||
valid = [s.value for s in FontStyle]
|
||||
if value not in valid:
|
||||
raise serializers.ValidationError(
|
||||
f"Font style must be one of: {', '.join(valid)}"
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_background_color(self, value: str) -> str:
|
||||
valid = [c.value for c in BackgroundColor]
|
||||
if value not in valid:
|
||||
raise serializers.ValidationError(
|
||||
f"Background color must be one of: {', '.join(valid)}"
|
||||
)
|
||||
return value
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from books.views import BookViewSet, ReadingSettingsViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"books", BookViewSet, basename="book")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("settings/", ReadingSettingsViewSet.as_view({
|
||||
"get": "list",
|
||||
"patch": "partial_update",
|
||||
}), name="reading-settings"),
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import parsers, permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from books.models import Book, ReadingProgress, ReadingSettings
|
||||
from books.serializers import (
|
||||
BookDetailSerializer,
|
||||
BookListSerializer,
|
||||
BookUploadSerializer,
|
||||
ReadingProgressSerializer,
|
||||
ReadingSettingsSerializer,
|
||||
)
|
||||
|
||||
|
||||
class IsBookOwner(permissions.BasePermission):
|
||||
"""Only the owner of a book can access it."""
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: object, obj: Book
|
||||
) -> bool:
|
||||
return obj.user == request.user
|
||||
|
||||
|
||||
class BookViewSet(viewsets.ModelViewSet):
|
||||
"""API endpoint for managing user books."""
|
||||
|
||||
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
|
||||
permission_classes = [permissions.IsAuthenticated, IsBookOwner]
|
||||
|
||||
def get_serializer_class(self) -> type:
|
||||
if self.action == "create":
|
||||
return BookUploadSerializer
|
||||
if self.action == "list":
|
||||
return BookListSerializer
|
||||
return BookDetailSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Book.objects.filter(user=self.request.user)
|
||||
.select_related("reading_progress", "user")
|
||||
.prefetch_related()
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "patch"])
|
||||
def progress(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Get or update reading progress for a specific book."""
|
||||
book = self.get_object()
|
||||
progress, _created = ReadingProgress.objects.get_or_create(
|
||||
user=request.user,
|
||||
book=book,
|
||||
)
|
||||
|
||||
if request.method == "GET":
|
||||
serializer = ReadingProgressSerializer(progress)
|
||||
return Response(serializer.data)
|
||||
|
||||
serializer = ReadingProgressSerializer(progress, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ReadingSettingsViewSet(viewsets.GenericViewSet):
|
||||
"""API endpoint for user reading settings."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = ReadingSettingsSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return ReadingSettings.objects.filter(user=self.request.user)
|
||||
|
||||
def list(self, request: Request) -> Response:
|
||||
settings, _created = ReadingSettings.objects.get_or_create(
|
||||
user=request.user,
|
||||
)
|
||||
serializer = self.get_serializer(settings)
|
||||
return Response(serializer.data)
|
||||
|
||||
def partial_update(self, request: Request) -> Response:
|
||||
settings, _created = ReadingSettings.objects.get_or_create(
|
||||
user=request.user,
|
||||
)
|
||||
serializer = self.get_serializer(settings, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
Reference in New Issue
Block a user