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:
Marko (Hermes Implementer)
2026-05-26 00:50:06 +00:00
commit 3b5b301e42
94 changed files with 6086 additions and 0 deletions
+8
View File
@@ -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
View File
+19
View File
@@ -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",)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AnnotationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.annotations"
label = "annotations"
+84
View File
@@ -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}"
+8
View File
@@ -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
+108
View File
@@ -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)
+331
View File
@@ -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"
+12
View File
@@ -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)),
]
+93
View File
@@ -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
)
View File
+9
View File
@@ -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")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BooksConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.books"
label = "books"
+21
View File
@@ -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
+28
View File
@@ -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"]
+11
View File
@@ -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)),
]
+24
View File
@@ -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
View File
+11
View File
@@ -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")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.users"
label = "users"
+13
View File
@@ -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
+8
View File
@@ -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"),
]
+153
View File
@@ -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"
+42
View File
@@ -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()
+9
View File
@@ -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")),
]
+7
View File
@@ -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()
+23
View File
@@ -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()
+8
View File
@@ -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
+14
View File
@@ -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