Archived
- 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
111 lines
3.3 KiB
Python
111 lines
3.3 KiB
Python
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}" |