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