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,8 @@
|
||||
# Backend environment (example – never commit real secrets)
|
||||
DJANGO_SECRET_KEY=django-insecure-change-me-in-production
|
||||
DJANGO_DEBUG=True
|
||||
DB_NAME=cloud_reader
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.annotations.models import Bookmark, Note
|
||||
|
||||
|
||||
@admin.register(Bookmark)
|
||||
class BookmarkAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "book", "page", "created_at")
|
||||
list_select_related = ("user", "book")
|
||||
search_fields = ("user__email", "book__title", "location_text")
|
||||
list_filter = ("created_at",)
|
||||
|
||||
|
||||
@admin.register(Note)
|
||||
class NoteAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "book", "page", "created_at", "updated_at")
|
||||
list_select_related = ("user", "book")
|
||||
search_fields = ("user__email", "book__title", "content", "location_text")
|
||||
list_filter = ("created_at",)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnnotationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.annotations"
|
||||
label = "annotations"
|
||||
@@ -0,0 +1,84 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Bookmark(models.Model):
|
||||
"""A saved location in a book that the user can return to."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="bookmarks",
|
||||
db_index=True,
|
||||
)
|
||||
book = models.ForeignKey(
|
||||
"books.Book",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="bookmarks",
|
||||
db_index=True,
|
||||
)
|
||||
page = models.PositiveIntegerField()
|
||||
location_text = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="The selected passage text at this location",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "annotations_bookmark"
|
||||
verbose_name = "Bookmark"
|
||||
verbose_name_plural = "Bookmarks"
|
||||
ordering = ["-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "book", "page"],
|
||||
name="uq_bookmark_user_book_page",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} @ {self.book} p.{self.page}"
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
"""A user-written note attached to a specific location in a book."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notes",
|
||||
db_index=True,
|
||||
)
|
||||
book = models.ForeignKey(
|
||||
"books.Book",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notes",
|
||||
db_index=True,
|
||||
)
|
||||
page = models.PositiveIntegerField()
|
||||
location_text = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="The selected passage text this note refers to",
|
||||
)
|
||||
content = models.TextField(
|
||||
help_text="The note body content"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "annotations_note"
|
||||
verbose_name = "Note"
|
||||
verbose_name_plural = "Notes"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
preview = self.content[:50]
|
||||
return f"{self.user} @ {self.book} p.{self.page}: {preview}"
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class IsOwner(permissions.BasePermission):
|
||||
"""Grant access only if the requesting user owns the object."""
|
||||
|
||||
def has_object_permission(self, request, view, obj) -> bool:
|
||||
return obj.user == request.user
|
||||
@@ -0,0 +1,108 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.annotations.models import Bookmark, Note
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Bookmark data with full details."""
|
||||
|
||||
book_title = serializers.CharField(source="book.title", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"id",
|
||||
"book",
|
||||
"book_title",
|
||||
"page",
|
||||
"location_text",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "book_title"]
|
||||
|
||||
def validate_page(self, value: int) -> int:
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Page must be a positive integer.")
|
||||
return value
|
||||
|
||||
|
||||
class BookmarkCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer used for creating bookmarks. Sets user from request context."""
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = ["book", "page", "location_text"]
|
||||
|
||||
def validate_page(self, value: int) -> int:
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Page must be a positive integer.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
user = self.context["request"].user
|
||||
if Bookmark.objects.filter(
|
||||
user=user, book=attrs["book"], page=attrs["page"]
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"page": "A bookmark already exists at this page for this book."}
|
||||
)
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["user"] = self.context["request"].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class NoteSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Note data with full details."""
|
||||
|
||||
book_title = serializers.CharField(source="book.title", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = [
|
||||
"id",
|
||||
"book",
|
||||
"book_title",
|
||||
"page",
|
||||
"location_text",
|
||||
"content",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "book_title"]
|
||||
|
||||
def validate_page(self, value: int) -> int:
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Page must be a positive integer.")
|
||||
return value
|
||||
|
||||
def validate_content(self, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise serializers.ValidationError("Note content cannot be empty.")
|
||||
return stripped
|
||||
|
||||
|
||||
class NoteCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer used for creating notes. Sets user from request context."""
|
||||
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = ["book", "page", "location_text", "content"]
|
||||
|
||||
def validate_page(self, value: int) -> int:
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Page must be a positive integer.")
|
||||
return value
|
||||
|
||||
def validate_content(self, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise serializers.ValidationError("Note content cannot be empty.")
|
||||
return stripped
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["user"] = self.context["request"].user
|
||||
return super().create(validated_data)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Tests for the annotations app – Bookmarks & Notes API."""
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.annotations.models import Bookmark, Note
|
||||
from apps.books.models import Book
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def api_client() -> APIClient:
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user() -> User:
|
||||
return User.objects.create_user(
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_user() -> User:
|
||||
return User.objects.create_user(
|
||||
username="other",
|
||||
email="other@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(api_client: APIClient, user: User) -> APIClient:
|
||||
api_client.force_authenticate(user=user)
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def book() -> Book:
|
||||
return Book.objects.create(
|
||||
title="Test Book",
|
||||
author="Test Author",
|
||||
total_pages=300,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bookmark(auth_client, user: User, book: Book) -> Bookmark:
|
||||
return Bookmark.objects.create(
|
||||
user=user,
|
||||
book=book,
|
||||
page=42,
|
||||
location_text="important passage",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def note(auth_client, user: User, book: Book) -> Note:
|
||||
return Note.objects.create(
|
||||
user=user,
|
||||
book=book,
|
||||
page=15,
|
||||
location_text="highlighted section",
|
||||
content="This is my note about this section.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bookmark tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBookmarkList:
|
||||
url = reverse("bookmark-list")
|
||||
|
||||
def test_unauthenticated_user_cannot_list(self, api_client: APIClient):
|
||||
response = api_client.get(self.url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_list_returns_user_bookmarks_only(
|
||||
self, auth_client: APIClient, user: User, other_user: User, book: Book
|
||||
):
|
||||
Bookmark.objects.create(user=user, book=book, page=1)
|
||||
Bookmark.objects.create(user=other_user, book=book, page=2)
|
||||
|
||||
response = auth_client.get(self.url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.data["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["page"] == 1
|
||||
|
||||
def test_list_returns_empty_when_no_bookmarks(
|
||||
self, auth_client: APIClient
|
||||
):
|
||||
response = auth_client.get(self.url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["count"] == 0
|
||||
|
||||
def test_list_orders_by_newest_first(
|
||||
self, auth_client: APIClient, user: User, book: Book
|
||||
):
|
||||
b1 = Bookmark.objects.create(user=user, book=book, page=1)
|
||||
b2 = Bookmark.objects.create(user=user, book=book, page=2)
|
||||
response = auth_client.get(self.url)
|
||||
results = response.data["results"]
|
||||
assert results[0]["page"] == 2
|
||||
assert results[1]["page"] == 1
|
||||
|
||||
def test_list_includes_book_title(
|
||||
self, auth_client: APIClient, bookmark: Bookmark
|
||||
):
|
||||
response = auth_client.get(self.url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["results"][0]["book_title"] == "Test Book"
|
||||
|
||||
|
||||
class TestBookmarkCreate:
|
||||
url = reverse("bookmark-list")
|
||||
|
||||
def test_create_bookmark(self, auth_client: APIClient, book: Book):
|
||||
data = {"book": str(book.id), "page": 10, "location_text": "key insight"}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["page"] == 10
|
||||
|
||||
def test_create_bookmark_without_location_text(
|
||||
self, auth_client: APIClient, book: Book
|
||||
):
|
||||
data = {"book": str(book.id), "page": 5}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["page"] == 5
|
||||
|
||||
def test_duplicate_bookmark_page_is_rejected(
|
||||
self, auth_client: APIClient, bookmark: Bookmark
|
||||
):
|
||||
data = {"book": str(bookmark.book.id), "page": bookmark.page}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_unauthenticated_user_cannot_create(
|
||||
self, api_client: APIClient, book: Book
|
||||
):
|
||||
data = {"book": str(book.id), "page": 10}
|
||||
response = api_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_invalid_page_rejected(
|
||||
self, auth_client: APIClient, book: Book
|
||||
):
|
||||
data = {"book": str(book.id), "page": 0}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
class TestBookmarkDetail:
|
||||
def test_get_bookmark(
|
||||
self, auth_client: APIClient, bookmark: Bookmark
|
||||
):
|
||||
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||||
response = auth_client.get(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["page"] == bookmark.page
|
||||
|
||||
def test_cannot_access_other_users_bookmark(
|
||||
self, api_client: APIClient, other_user: User, bookmark: Bookmark
|
||||
):
|
||||
api_client.force_authenticate(user=other_user)
|
||||
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
class TestBookmarkDelete:
|
||||
def test_delete_bookmark(
|
||||
self, auth_client: APIClient, bookmark: Bookmark
|
||||
):
|
||||
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||||
response = auth_client.delete(url)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert Bookmark.objects.count() == 0
|
||||
|
||||
def test_cannot_delete_other_users_bookmark(
|
||||
self, api_client: APIClient, other_user: User, bookmark: Bookmark
|
||||
):
|
||||
api_client.force_authenticate(user=other_user)
|
||||
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||||
response = api_client.delete(url)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
class TestBookmarkFilterByBook:
|
||||
def test_filter_by_book(
|
||||
self, auth_client: APIClient, user: User, book: Book
|
||||
):
|
||||
other_book = Book.objects.create(title="Other", author="Other")
|
||||
Bookmark.objects.create(user=user, book=book, page=1)
|
||||
Bookmark.objects.create(user=user, book=other_book, page=2)
|
||||
|
||||
url = reverse("bookmark-list")
|
||||
response = auth_client.get(url, {"book": str(book.id)})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["page"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Note tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNoteList:
|
||||
url = reverse("note-list")
|
||||
|
||||
def test_unauthenticated_user_cannot_list(self, api_client: APIClient):
|
||||
response = api_client.get(self.url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_list_returns_user_notes_only(
|
||||
self, auth_client: APIClient, user: User, other_user: User, book: Book
|
||||
):
|
||||
Note.objects.create(user=user, book=book, page=1, content="My note")
|
||||
Note.objects.create(user=other_user, book=book, page=2, content="Other's note")
|
||||
|
||||
response = auth_client.get(self.url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.data["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["content"] == "My note"
|
||||
|
||||
def test_list_includes_book_title(
|
||||
self, auth_client: APIClient, note: Note
|
||||
):
|
||||
response = auth_client.get(self.url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["results"][0]["book_title"] == "Test Book"
|
||||
|
||||
|
||||
class TestNoteCreate:
|
||||
url = reverse("note-list")
|
||||
|
||||
def test_create_note(self, auth_client: APIClient, book: Book):
|
||||
data = {
|
||||
"book": str(book.id),
|
||||
"page": 20,
|
||||
"location_text": "interesting part",
|
||||
"content": "This is a thoughtful note.",
|
||||
}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["content"] == "This is a thoughtful note."
|
||||
|
||||
def test_create_note_without_location_text(
|
||||
self, auth_client: APIClient, book: Book
|
||||
):
|
||||
data = {"book": str(book.id), "page": 20, "content": "A note."}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_empty_content_rejected(
|
||||
self, auth_client: APIClient, book: Book
|
||||
):
|
||||
data = {"book": str(book.id), "page": 20, "content": " "}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_unauthenticated_user_cannot_create(
|
||||
self, api_client: APIClient, book: Book
|
||||
):
|
||||
data = {"book": str(book.id), "page": 20, "content": "Note"}
|
||||
response = api_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class TestNoteUpdate:
|
||||
def test_update_note_content(
|
||||
self, auth_client: APIClient, note: Note
|
||||
):
|
||||
url = reverse("note-detail", args=[str(note.id)])
|
||||
data = {"content": "Updated note content."}
|
||||
response = auth_client.patch(url, data, format="json")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["content"] == "Updated note content."
|
||||
|
||||
def test_cannot_update_other_users_note(
|
||||
self, api_client: APIClient, other_user: User, note: Note
|
||||
):
|
||||
api_client.force_authenticate(user=other_user)
|
||||
url = reverse("note-detail", args=[str(note.id)])
|
||||
data = {"content": "Hacked!"}
|
||||
response = api_client.patch(url, data, format="json")
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
class TestNoteDelete:
|
||||
def test_delete_note(self, auth_client: APIClient, note: Note):
|
||||
url = reverse("note-detail", args=[str(note.id)])
|
||||
response = auth_client.delete(url)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert Note.objects.count() == 0
|
||||
|
||||
def test_batch_delete_notes(
|
||||
self, auth_client: APIClient, user: User, book: Book
|
||||
):
|
||||
n1 = Note.objects.create(user=user, book=book, page=1, content="A")
|
||||
n2 = Note.objects.create(user=user, book=book, page=2, content="B")
|
||||
url = reverse("note-batch-delete")
|
||||
response = auth_client.delete(url, {"ids": [str(n1.id), str(n2.id)]}, format="json")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["deleted"] == 2
|
||||
|
||||
|
||||
class TestNoteFilterByBook:
|
||||
def test_filter_by_book(
|
||||
self, auth_client: APIClient, user: User, book: Book
|
||||
):
|
||||
other_book = Book.objects.create(title="Other", author="Other")
|
||||
Note.objects.create(user=user, book=book, page=1, content="In book")
|
||||
Note.objects.create(user=user, book=other_book, page=2, content="In other")
|
||||
|
||||
url = reverse("note-list")
|
||||
response = auth_client.get(url, {"book": str(book.id)})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["content"] == "In book"
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.annotations.views import BookmarkViewSet, NoteViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
|
||||
router.register(r"notes", NoteViewSet, basename="note")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.annotations.models import Bookmark, Note
|
||||
from apps.annotations.permissions import IsOwner
|
||||
from apps.annotations.serializers import (
|
||||
BookmarkCreateSerializer,
|
||||
BookmarkSerializer,
|
||||
NoteCreateSerializer,
|
||||
NoteSerializer,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD for user bookmarks. Users can only manage their own bookmarks."""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsOwner]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["book"]
|
||||
search_fields = ["location_text"]
|
||||
ordering_fields = ["created_at", "page"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return BookmarkCreateSerializer
|
||||
return BookmarkSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Bookmark.objects.filter(user=self.request.user).select_related(
|
||||
"book"
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=["delete"], url_path="batch-delete")
|
||||
def batch_delete(self, request):
|
||||
"""Delete multiple bookmarks by id list."""
|
||||
ids = request.data.get("ids", [])
|
||||
if not ids:
|
||||
return Response(
|
||||
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
deleted, _ = Bookmark.objects.filter(
|
||||
id__in=ids, user=request.user
|
||||
).delete()
|
||||
return Response(
|
||||
{"deleted": deleted}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class NoteViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD for user notes. Users can only manage their own notes."""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsOwner]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["book"]
|
||||
search_fields = ["content", "location_text"]
|
||||
ordering_fields = ["created_at", "page"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return NoteCreateSerializer
|
||||
return NoteSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Note.objects.filter(user=self.request.user).select_related(
|
||||
"book"
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=["delete"], url_path="batch-delete")
|
||||
def batch_delete(self, request):
|
||||
"""Delete multiple notes by id list."""
|
||||
ids = request.data.get("ids", [])
|
||||
if not ids:
|
||||
return Response(
|
||||
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
deleted, _ = Note.objects.filter(
|
||||
id__in=ids, user=request.user
|
||||
).delete()
|
||||
return Response(
|
||||
{"deleted": deleted}, status=status.HTTP_200_OK
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.books.models import Book
|
||||
|
||||
|
||||
@admin.register(Book)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "author", "total_pages", "created_at")
|
||||
search_fields = ("title", "author")
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BooksConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.books"
|
||||
label = "books"
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Book(models.Model):
|
||||
"""Represents a book in the user's library."""
|
||||
|
||||
title = models.CharField(max_length=512)
|
||||
author = models.CharField(max_length=256, blank=True, default="")
|
||||
total_pages = models.PositiveIntegerField(default=0)
|
||||
cover_image = models.URLField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "books_book"
|
||||
verbose_name = "Book"
|
||||
verbose_name_plural = "Books"
|
||||
ordering = ["title"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
@@ -0,0 +1,28 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.books.models import Book
|
||||
|
||||
|
||||
class BookSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Book data."""
|
||||
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"author",
|
||||
"total_pages",
|
||||
"cover_image",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class BookListSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight serializer for list views (excludes heavy fields)."""
|
||||
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = ["id", "title", "author", "total_pages", "cover_image"]
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.books.views import BookViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"", BookViewSet, basename="book")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from apps.books.models import Book
|
||||
from apps.books.serializers import BookListSerializer, BookSerializer
|
||||
|
||||
|
||||
class BookViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD for books."""
|
||||
|
||||
queryset = Book.objects.all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["author"]
|
||||
search_fields = ["title", "author"]
|
||||
ordering_fields = ["title", "author", "created_at"]
|
||||
ordering = ["title"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return BookListSerializer
|
||||
return BookSerializer
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
"""Admin config for the custom User model."""
|
||||
list_display = ("email", "username", "is_staff", "is_active", "date_joined")
|
||||
search_fields = ("email", "username")
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.users"
|
||||
label = "users"
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Custom user model. Uses email as the unique identifier field."""
|
||||
|
||||
class Meta:
|
||||
db_table = "users_user"
|
||||
verbose_name = "User"
|
||||
verbose_name_plural = "Users"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.email or self.username
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
|
||||
urlpatterns = [
|
||||
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||
]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Django settings for cloud-reader backend.
|
||||
|
||||
Generated using Django 5.1. Customised with pydantic-settings integration.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build paths
|
||||
# ---------------------------------------------------------------------------
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security
|
||||
# ---------------------------------------------------------------------------
|
||||
SECRET_KEY = settings.DJANGO_SECRET_KEY
|
||||
DEBUG = settings.DJANGO_DEBUG
|
||||
ALLOWED_HOSTS = settings.DJANGO_ALLOWED_HOSTS
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application definition
|
||||
# ---------------------------------------------------------------------------
|
||||
INSTALLED_APPS = [
|
||||
# Django built-in
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Third-party
|
||||
"rest_framework",
|
||||
"rest_framework_simplejwt",
|
||||
"corsheaders",
|
||||
"django_filters",
|
||||
# Local apps
|
||||
"apps.users",
|
||||
"apps.books",
|
||||
"apps.annotations",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database
|
||||
# ---------------------------------------------------------------------------
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": settings.DB_NAME,
|
||||
"USER": settings.DB_USER,
|
||||
"PASSWORD": settings.DB_PASSWORD,
|
||||
"HOST": settings.DB_HOST,
|
||||
"PORT": settings.DB_PORT,
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DRF
|
||||
# ---------------------------------------------------------------------------
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
),
|
||||
"DEFAULT_PERMISSION_CLASSES": (
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
),
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 50,
|
||||
"DEFAULT_FILTER_BACKENDS": [
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
"rest_framework.filters.SearchFilter",
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SimpleJWT
|
||||
# ---------------------------------------------------------------------------
|
||||
from datetime import timedelta
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=settings.JWT_REFRESH_TOKEN_LIFETIME_DAYS),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CORS
|
||||
# ---------------------------------------------------------------------------
|
||||
CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# i18n
|
||||
# ---------------------------------------------------------------------------
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static / Media
|
||||
# ---------------------------------------------------------------------------
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
MEDIA_URL = "media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default primary key
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
@@ -0,0 +1,42 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings via pydantic-settings. Reads from env vars and .env."""
|
||||
|
||||
# Django
|
||||
DJANGO_SECRET_KEY: str = "django-insecure-change-me-in-production"
|
||||
DJANGO_DEBUG: bool = False
|
||||
DJANGO_ALLOWED_HOSTS: list[str] = ["*"]
|
||||
|
||||
# PostgreSQL
|
||||
DB_NAME: str = "cloud_reader"
|
||||
DB_USER: str = "postgres"
|
||||
DB_PASSWORD: str = "postgres"
|
||||
DB_HOST: str = "localhost"
|
||||
DB_PORT: int = 5432
|
||||
|
||||
# JWT
|
||||
JWT_ACCESS_TOKEN_LIFETIME_MINUTES: int = 60
|
||||
JWT_REFRESH_TOKEN_LIFETIME_DAYS: int = 7
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS: list[str] = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
]
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
return (
|
||||
f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}"
|
||||
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||
)
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/auth/", include("apps.users.urls")),
|
||||
path("api/books/", include("apps.books.urls")),
|
||||
path("api/annotations/", include("apps.annotations.urls")),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
[tool:pytest]
|
||||
DJANGO_SETTINGS_MODULE = config.django
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
testpaths = apps
|
||||
|
||||
[coverage:run]
|
||||
source = apps
|
||||
omit = */tests/*,*/migrations/*,*/admin.py,*/apps.py
|
||||
@@ -0,0 +1,14 @@
|
||||
Django==5.1.7
|
||||
djangorestframework==3.15.2
|
||||
djangorestframework-simplejwt==5.4.0
|
||||
django-filter==25.1
|
||||
django-cors-headers==4.6.0
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic==2.10.5
|
||||
pydantic-settings==2.7.1
|
||||
python-dotenv==1.0.1
|
||||
gunicorn==23.0.0
|
||||
pytest==8.3.4
|
||||
pytest-django==4.9.0
|
||||
pytest-cov==6.0.0
|
||||
coverage==7.6.10
|
||||
Reference in New Issue
Block a user