Archived
feat: uv config other feats
- add uv configuration for the backend - update frontend to make auth work - add new auth endpoints - add bookmars feat - add reader feat
This commit is contained in:
@@ -5,9 +5,17 @@ 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_display = (
|
||||
"user",
|
||||
"ebook",
|
||||
"chapter_index",
|
||||
"chapter_title",
|
||||
"page",
|
||||
"highlight_color",
|
||||
"created_at",
|
||||
)
|
||||
list_select_related = ("user", "ebook")
|
||||
search_fields = ("user__email", "ebook__title", "location_text", "content")
|
||||
list_filter = ("created_at",)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.1.7 on 2026-06-03 22:27
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('books', '0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Note',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('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)),
|
||||
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='books.book')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Note',
|
||||
'verbose_name_plural': 'Notes',
|
||||
'db_table': 'annotations_note',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Bookmark',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('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)),
|
||||
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.book')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Bookmark',
|
||||
'verbose_name_plural': 'Bookmarks',
|
||||
'db_table': 'annotations_bookmark',
|
||||
'ordering': ['-created_at'],
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'book', 'page'), name='uq_bookmark_user_book_page')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def clear_legacy_bookmarks(apps, schema_editor):
|
||||
Bookmark = apps.get_model("annotations", "Bookmark")
|
||||
Bookmark.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0003_readingprogress_epub_location"),
|
||||
("annotations", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(clear_legacy_bookmarks, migrations.RunPython.noop),
|
||||
migrations.RemoveConstraint(
|
||||
model_name="bookmark",
|
||||
name="uq_bookmark_user_book_page",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="bookmark",
|
||||
name="book",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="ebook",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="bookmarks",
|
||||
to="books.ebook",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="epub_cfi",
|
||||
field=models.CharField(db_index=True, default="", max_length=2048),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="chapter_index",
|
||||
field=models.PositiveIntegerField(db_index=True, default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="chapter_title",
|
||||
field=models.CharField(blank=True, default="", max_length=512),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="content",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bookmark",
|
||||
name="page",
|
||||
field=models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Legacy/display page; derived from chapter_index + 1",
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="bookmark",
|
||||
options={
|
||||
"ordering": ["chapter_index", "epub_cfi"],
|
||||
"verbose_name": "Bookmark",
|
||||
"verbose_name_plural": "Bookmarks",
|
||||
},
|
||||
),
|
||||
# ebook was added nullable for SQLite; enforce NOT NULL via AlterField
|
||||
migrations.AlterField(
|
||||
model_name="bookmark",
|
||||
name="ebook",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="bookmarks",
|
||||
to="books.ebook",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="bookmark",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user", "ebook", "epub_cfi"),
|
||||
name="uq_bookmark_user_ebook_cfi",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("annotations", "0002_bookmark_ebook_epub_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="highlight_color",
|
||||
field=models.CharField(default="#fde047", max_length=7),
|
||||
),
|
||||
]
|
||||
@@ -5,7 +5,7 @@ from django.db import models
|
||||
|
||||
|
||||
class Bookmark(models.Model):
|
||||
"""A saved location in a book that the user can return to."""
|
||||
"""A saved passage anchor in an uploaded ebook (EPUB CFI + optional thought)."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
@@ -14,18 +14,34 @@ class Bookmark(models.Model):
|
||||
related_name="bookmarks",
|
||||
db_index=True,
|
||||
)
|
||||
book = models.ForeignKey(
|
||||
"books.Book",
|
||||
ebook = models.ForeignKey(
|
||||
"books.EBook",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="bookmarks",
|
||||
db_index=True,
|
||||
)
|
||||
page = models.PositiveIntegerField()
|
||||
epub_cfi = models.CharField(max_length=2048, db_index=True)
|
||||
chapter_index = models.PositiveIntegerField(default=0, db_index=True)
|
||||
chapter_title = models.CharField(max_length=512, blank=True, default="")
|
||||
page = models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Legacy/display page; derived from chapter_index + 1",
|
||||
)
|
||||
location_text = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="The selected passage text at this location",
|
||||
)
|
||||
content = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Optional user thought; empty means bookmark-only",
|
||||
)
|
||||
highlight_color = models.CharField(
|
||||
max_length=7,
|
||||
default="#fde047",
|
||||
help_text="Hex color for in-book passage highlight (e.g. #fde047)",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -33,20 +49,20 @@ class Bookmark(models.Model):
|
||||
db_table = "annotations_bookmark"
|
||||
verbose_name = "Bookmark"
|
||||
verbose_name_plural = "Bookmarks"
|
||||
ordering = ["-created_at"]
|
||||
ordering = ["chapter_index", "epub_cfi"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "book", "page"],
|
||||
name="uq_bookmark_user_book_page",
|
||||
fields=["user", "ebook", "epub_cfi"],
|
||||
name="uq_bookmark_user_ebook_cfi",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} @ {self.book} p.{self.page}"
|
||||
return f"{self.user} @ {self.ebook} ch.{self.chapter_index}"
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
"""A user-written note attached to a specific location in a book."""
|
||||
"""Legacy note model; new UX uses Bookmark.content instead."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
@@ -81,4 +97,4 @@ class Note(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
preview = self.content[:50]
|
||||
return f"{self.user} @ {self.book} p.{self.page}: {preview}"
|
||||
return f"{self.user} @ {self.book} p.{self.page}: {preview}"
|
||||
|
||||
@@ -1,62 +1,86 @@
|
||||
import re
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.annotations.models import Bookmark, Note
|
||||
|
||||
_HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
def validate_highlight_color(value: str) -> str:
|
||||
stripped = (value or "").strip()
|
||||
if not _HEX_COLOR_RE.match(stripped):
|
||||
raise serializers.ValidationError("highlight_color must be a hex color like #fde047.")
|
||||
return stripped.lower()
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Bookmark data with full details."""
|
||||
|
||||
book_title = serializers.CharField(source="book.title", read_only=True)
|
||||
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"id",
|
||||
"book",
|
||||
"book_title",
|
||||
"ebook",
|
||||
"ebook_title",
|
||||
"epub_cfi",
|
||||
"chapter_index",
|
||||
"chapter_title",
|
||||
"page",
|
||||
"location_text",
|
||||
"content",
|
||||
"highlight_color",
|
||||
"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
|
||||
read_only_fields = ["id", "created_at", "updated_at", "ebook_title", "page"]
|
||||
|
||||
|
||||
class BookmarkCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer used for creating bookmarks. Sets user from request context."""
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = ["book", "page", "location_text"]
|
||||
fields = [
|
||||
"ebook",
|
||||
"epub_cfi",
|
||||
"chapter_index",
|
||||
"chapter_title",
|
||||
"location_text",
|
||||
"content",
|
||||
"highlight_color",
|
||||
]
|
||||
|
||||
def validate_page(self, value: int) -> int:
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Page must be a positive integer.")
|
||||
def validate_highlight_color(self, value: str) -> str:
|
||||
return validate_highlight_color(value)
|
||||
|
||||
def validate_epub_cfi(self, value: str) -> str:
|
||||
stripped = (value or "").strip()
|
||||
if not stripped:
|
||||
raise serializers.ValidationError("epub_cfi is required.")
|
||||
return stripped
|
||||
|
||||
def validate_chapter_index(self, value: int) -> int:
|
||||
if value < 0:
|
||||
raise serializers.ValidationError("chapter_index must be non-negative.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
user = self.context["request"].user
|
||||
if Bookmark.objects.filter(
|
||||
user=user, book=attrs["book"], page=attrs["page"]
|
||||
).exists():
|
||||
ebook = attrs["ebook"]
|
||||
epub_cfi = attrs["epub_cfi"]
|
||||
if Bookmark.objects.filter(user=user, ebook=ebook, epub_cfi=epub_cfi).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"page": "A bookmark already exists at this page for this book."}
|
||||
{"epub_cfi": "A marker already exists for this passage."}
|
||||
)
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["user"] = self.context["request"].user
|
||||
validated_data["page"] = validated_data.get("chapter_index", 0) + 1
|
||||
validated_data["content"] = (validated_data.get("content") or "").strip()
|
||||
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:
|
||||
@@ -78,16 +102,8 @@ class NoteSerializer(serializers.ModelSerializer):
|
||||
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"]
|
||||
@@ -105,4 +121,4 @@ class NoteCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["user"] = self.context["request"].user
|
||||
return super().create(validated_data)
|
||||
return super().create(validated_data)
|
||||
|
||||
@@ -6,14 +6,10 @@ 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.books.models import Book, EBook
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def api_client() -> APIClient:
|
||||
return APIClient()
|
||||
@@ -53,12 +49,26 @@ def book() -> Book:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bookmark(auth_client, user: User, book: Book) -> Bookmark:
|
||||
def ebook(user: User) -> EBook:
|
||||
return EBook.objects.create(
|
||||
user=user,
|
||||
title="Test Ebook",
|
||||
author="Test Author",
|
||||
format="epub",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bookmark(auth_client, user: User, ebook: EBook) -> Bookmark:
|
||||
return Bookmark.objects.create(
|
||||
user=user,
|
||||
book=book,
|
||||
page=42,
|
||||
ebook=ebook,
|
||||
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:10)",
|
||||
chapter_index=2,
|
||||
chapter_title="Chapter 3",
|
||||
page=3,
|
||||
location_text="important passage",
|
||||
content="",
|
||||
)
|
||||
|
||||
|
||||
@@ -73,259 +83,84 @@ def note(auth_client, user: User, book: Book) -> Note:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bookmark tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBookmarkList:
|
||||
url = reverse("bookmark-list")
|
||||
|
||||
def test_unauthenticated_user_cannot_list(self, api_client: APIClient):
|
||||
def test_list_requires_auth(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
|
||||
self, auth_client: APIClient, user: User, other_user: User, ebook: EBook
|
||||
):
|
||||
Bookmark.objects.create(user=user, book=book, page=1)
|
||||
Bookmark.objects.create(user=other_user, book=book, page=2)
|
||||
|
||||
Bookmark.objects.create(
|
||||
user=user,
|
||||
ebook=ebook,
|
||||
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:5)",
|
||||
chapter_index=0,
|
||||
page=1,
|
||||
)
|
||||
other_ebook = EBook.objects.create(user=other_user, title="Other", format="epub")
|
||||
Bookmark.objects.create(
|
||||
user=other_user,
|
||||
ebook=other_ebook,
|
||||
epub_cfi="epubcfi(/6/4!/4/2,/2:0,/2:5)",
|
||||
chapter_index=0,
|
||||
page=1,
|
||||
)
|
||||
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)
|
||||
def test_filter_by_ebook(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
||||
response = auth_client.get(self.url, {"ebook": str(ebook.id)})
|
||||
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"
|
||||
assert len(response.data["results"]) == 1
|
||||
|
||||
|
||||
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"}
|
||||
def test_create_marker(self, auth_client: APIClient, ebook: EBook):
|
||||
data = {
|
||||
"ebook": ebook.id,
|
||||
"epub_cfi": "epubcfi(/6/4!/4/2,/1:0,/1:20)",
|
||||
"chapter_index": 1,
|
||||
"chapter_title": "Chapter 2",
|
||||
"location_text": "Selected text",
|
||||
"content": "My thought",
|
||||
}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["page"] == 10
|
||||
assert response.data["content"] == "My thought"
|
||||
assert response.data["ebook"] == ebook.id
|
||||
|
||||
def test_create_bookmark_without_location_text(
|
||||
self, auth_client: APIClient, book: Book
|
||||
):
|
||||
data = {"book": str(book.id), "page": 5}
|
||||
def test_create_bookmark_only_empty_content(self, auth_client: APIClient, ebook: EBook):
|
||||
data = {
|
||||
"ebook": ebook.id,
|
||||
"epub_cfi": "epubcfi(/6/4!/4/2,/3:0,/3:8)",
|
||||
"chapter_index": 0,
|
||||
"location_text": "Quote only",
|
||||
"content": "",
|
||||
}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["page"] == 5
|
||||
assert response.data["content"] == ""
|
||||
|
||||
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}
|
||||
def test_duplicate_cfi_rejected(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
||||
data = {
|
||||
"ebook": ebook.id,
|
||||
"epub_cfi": bookmark.epub_cfi,
|
||||
"chapter_index": 0,
|
||||
"location_text": "dup",
|
||||
}
|
||||
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
|
||||
):
|
||||
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"
|
||||
@@ -16,14 +16,14 @@ from apps.annotations.serializers import (
|
||||
|
||||
|
||||
class BookmarkViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD for user bookmarks. Users can only manage their own bookmarks."""
|
||||
"""CRUD for user ebook markers (passage anchors + optional thoughts)."""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsOwner]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["book"]
|
||||
search_fields = ["location_text"]
|
||||
ordering_fields = ["created_at", "page"]
|
||||
ordering = ["-created_at"]
|
||||
filterset_fields = ["ebook"]
|
||||
search_fields = ["location_text", "content", "chapter_title"]
|
||||
ordering_fields = ["chapter_index", "epub_cfi", "created_at"]
|
||||
ordering = ["chapter_index", "epub_cfi"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
@@ -31,16 +31,13 @@ class BookmarkViewSet(viewsets.ModelViewSet):
|
||||
return BookmarkSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Bookmark.objects.filter(user=self.request.user).select_related(
|
||||
"book"
|
||||
)
|
||||
return Bookmark.objects.filter(user=self.request.user).select_related("ebook")
|
||||
|
||||
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(
|
||||
@@ -49,13 +46,11 @@ class BookmarkViewSet(viewsets.ModelViewSet):
|
||||
deleted, _ = Bookmark.objects.filter(
|
||||
id__in=ids, user=request.user
|
||||
).delete()
|
||||
return Response(
|
||||
{"deleted": deleted}, status=status.HTTP_200_OK
|
||||
)
|
||||
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class NoteViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD for user notes. Users can only manage their own notes."""
|
||||
"""Legacy notes API (catalog Book FK)."""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsOwner]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
@@ -70,16 +65,13 @@ class NoteViewSet(viewsets.ModelViewSet):
|
||||
return NoteSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Note.objects.filter(user=self.request.user).select_related(
|
||||
"book"
|
||||
)
|
||||
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(
|
||||
@@ -88,6 +80,4 @@ class NoteViewSet(viewsets.ModelViewSet):
|
||||
deleted, _ = Note.objects.filter(
|
||||
id__in=ids, user=request.user
|
||||
).delete()
|
||||
return Response(
|
||||
{"deleted": deleted}, status=status.HTTP_200_OK
|
||||
)
|
||||
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user