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:
2026-06-03 22:06:01 -05:00
parent 730c748f5f
commit 6b4c0c43f8
137 changed files with 20319 additions and 2340 deletions
+11 -3
View File
@@ -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),
),
]
+26 -10
View File
@@ -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}"
+48 -32
View File
@@ -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)
+66 -231
View File
@@ -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"
+10 -20
View File
@@ -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)
@@ -0,0 +1,105 @@
# Generated by Django 5.1.7 on 2026-06-03 22:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='ebook',
name='file_size',
field=models.BigIntegerField(default=0),
),
migrations.AddField(
model_name='ebook',
name='format',
field=models.CharField(blank=True, default='', editable=False, max_length=20),
),
migrations.AddField(
model_name='ebook',
name='metadata_json',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='ebook',
name='page_count',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='readingprogress',
name='device_id',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AddField(
model_name='readingprogress',
name='device_name',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AddField(
model_name='readingprogress',
name='version',
field=models.PositiveIntegerField(default=1),
),
migrations.AlterField(
model_name='ebook',
name='author',
field=models.CharField(blank=True, db_index=True, default='', max_length=256),
),
migrations.AlterField(
model_name='ebook',
name='title',
field=models.CharField(db_index=True, max_length=512),
),
migrations.CreateModel(
name='BookChapter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=512)),
('index', models.IntegerField(default=0)),
('href', models.CharField(blank=True, default='', max_length=1024)),
('children', models.JSONField(blank=True, default=list)),
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='books.ebook')),
],
options={
'verbose_name': 'Book Chapter',
'verbose_name_plural': 'Book Chapters',
'db_table': 'books_book_chapter',
'ordering': ['index'],
},
),
migrations.CreateModel(
name='DownloadRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file_size', models.BigIntegerField(default=0)),
('downloaded_at', models.DateTimeField(auto_now_add=True)),
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to='books.ebook')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Download Record',
'verbose_name_plural': 'Download Records',
'db_table': 'books_download_record',
'ordering': ['-downloaded_at'],
},
),
migrations.DeleteModel(
name='ReadingSettings',
),
migrations.AddIndex(
model_name='bookchapter',
index=models.Index(fields=['ebook', 'index'], name='books_book__ebook_i_464cd6_idx'),
),
migrations.AlterUniqueTogether(
name='downloadrecord',
unique_together={('user', 'ebook')},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more"),
]
operations = [
migrations.AddField(
model_name="readingprogress",
name="epub_location",
field=models.CharField(blank=True, default="", max_length=2048),
),
]
+1
View File
@@ -111,6 +111,7 @@ class ReadingProgress(models.Model):
ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress")
current_position = models.FloatField(default=0.0)
last_page = models.IntegerField(default=0)
epub_location = models.CharField(max_length=2048, blank=True, default="")
device_id = models.CharField(max_length=128, blank=True, default="")
device_name = models.CharField(max_length=128, blank=True, default="")
version = models.PositiveIntegerField(default=1)
+67 -9
View File
@@ -1,6 +1,25 @@
from rest_framework import serializers
import logging
from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord
from apps.books.services.metadata import enrich_ebook_metadata
from apps.books.services.process_ebook import apply_processing_to_ebook
from apps.reader.models import ReadingSettings
logger = logging.getLogger(__name__)
class BookReadingSettingsSerializer(serializers.ModelSerializer):
font_style = serializers.CharField(source="font_family")
class Meta:
model = ReadingSettings
fields = ["font_size", "font_style", "background_color"]
def validate_font_size(self, value: int) -> int:
if value < 12 or value > 36:
raise serializers.ValidationError("Font size must be between 12 and 36.")
return value
class BookChapterSerializer(serializers.ModelSerializer):
@@ -51,10 +70,14 @@ class EBookListSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
progress = serializers.SerializerMethodField()
started = serializers.SerializerMethodField()
class Meta:
model = EBook
fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "cover_image", "created_at", "progress"]
fields = [
"id", "title", "author", "filename", "format", "page_count", "file_size",
"cover_image", "created_at", "progress", "started",
]
def get_progress(self, obj):
try:
@@ -62,16 +85,30 @@ class EBookListSerializer(serializers.ModelSerializer):
except ReadingProgress.DoesNotExist:
return None
def get_started(self, obj):
"""True when the user has opened the reader and has a saved EPUB location."""
try:
rp = obj.reading_progress
except ReadingProgress.DoesNotExist:
return False
if rp.current_position >= 99:
return False
return bool((rp.epub_location or "").strip())
class EBookDetailSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
file_url = serializers.SerializerMethodField()
progress = serializers.SerializerMethodField()
metadata = serializers.JSONField(source="metadata_json", read_only=True)
class Meta:
model = EBook
fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "file_url", "cover_image", "created_at", "updated_at", "progress"]
fields = [
"id", "title", "author", "filename", "format", "page_count", "file_size",
"file_url", "cover_image", "metadata", "created_at", "updated_at", "progress",
]
def get_file_url(self, obj):
request = self.context.get("request")
@@ -82,7 +119,11 @@ class EBookDetailSerializer(serializers.ModelSerializer):
def get_progress(self, obj):
try:
rp = obj.reading_progress
return {"current_position": rp.current_position, "last_page": rp.last_page}
return {
"current_position": rp.current_position,
"last_page": rp.last_page,
"epub_location": rp.epub_location,
}
except ReadingProgress.DoesNotExist:
return None
@@ -103,22 +144,35 @@ class EBookUploadSerializer(serializers.ModelSerializer):
return value
def create(self, validated_data):
validated_data["user"] = self.context["request"].user
# Auto-detect format from file extension
import os
validated_data["user"] = self.context["request"].user
name = str(getattr(validated_data.get("file"), "name", ""))
ext = os.path.splitext(name)[1].lower().lstrip(".")
if ext:
validated_data["format"] = ext
return super().create(validated_data)
ebook = super().create(validated_data)
try:
apply_processing_to_ebook(ebook)
except Exception:
logger.exception("E-book processing failed for ebook %s", ebook.pk)
try:
enrich_ebook_metadata(ebook)
except Exception:
logger.exception("Metadata enrichment failed for ebook %s", ebook.pk)
ebook.refresh_from_db()
return ebook
class ReadingProgressSerializer(serializers.ModelSerializer):
class Meta:
model = ReadingProgress
fields = ["current_position", "last_page", "device_id", "device_name", "version", "updated_at"]
fields = [
"current_position", "last_page", "epub_location",
"device_id", "device_name", "version", "updated_at",
]
read_only_fields = ["version", "updated_at"]
extra_kwargs = {"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0}}
extra_kwargs = {"current_position": {"required": False, "min_value": 0.0, "max_value": 100.0}}
def validate_current_position(self, value):
if value < 0.0 or value > 100.0:
@@ -155,7 +209,11 @@ class DownloadRecordSerializer(serializers.ModelSerializer):
def get_progress(self, obj):
try:
rp = obj.ebook.reading_progress
return {"current_position": rp.current_position, "last_page": rp.last_page}
return {
"current_position": rp.current_position,
"last_page": rp.last_page,
"epub_location": rp.epub_location,
}
except ReadingProgress.DoesNotExist:
return None
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from django.core.files.base import ContentFile
from apps.books.models import EBook
from apps.books.services.openlibrary import (
HIGH_CONFIDENCE,
download_cover,
fetch_metadata,
)
logger = logging.getLogger(__name__)
def enrich_ebook_metadata(ebook: EBook) -> EBook:
"""Fetch Open Library metadata and update the ebook. Never raises to callers."""
user_title = ebook.title
user_author = ebook.author or ""
try:
result = fetch_metadata(user_title, user_author)
except Exception:
logger.exception("Open Library metadata fetch failed for ebook %s", ebook.pk)
return ebook
if result is None:
return ebook
now = datetime.now(timezone.utc).isoformat()
metadata = {
"source": "openlibrary",
"matched_at": now,
"match_language": result.match_language,
"match_score": result.match_score,
"match_status": result.match_status,
"user_input": {"title": user_title, "author": user_author},
"openlibrary": result.openlibrary,
}
ebook.metadata_json = metadata
update_fields = ["metadata_json", "updated_at"]
if result.match_status == "not_found":
ebook.save(update_fields=update_fields)
return ebook
ol = result.openlibrary
cover_id = ol.get("cover_id")
cover_missing = (
not ebook.cover_image
or not ebook.cover_image.name
or not ebook.cover_image.storage.exists(ebook.cover_image.name)
)
if cover_id and cover_missing:
if ebook.cover_image:
ebook.cover_image.delete(save=False)
cover_bytes = download_cover(int(cover_id))
if cover_bytes:
filename = f"ol_cover_{ebook.pk}_{cover_id}.jpg"
ebook.cover_image.save(filename, ContentFile(cover_bytes), save=False)
update_fields.append("cover_image")
if result.match_score >= HIGH_CONFIDENCE:
ol_title = ol.get("title")
ol_authors = ol.get("authors") or []
if ol_title:
ebook.title = ol_title[:512]
update_fields.append("title")
if ol_authors:
ebook.author = ol_authors[0][:256]
update_fields.append("author")
ebook.save(update_fields=list(dict.fromkeys(update_fields)))
return ebook
+304
View File
@@ -0,0 +1,304 @@
from __future__ import annotations
import logging
import re
import unicodedata
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Any
import httpx
from config.settings import settings
logger = logging.getLogger(__name__)
SEARCH_URL = "https://openlibrary.org/search.json"
COVERS_URL = "https://covers.openlibrary.org/b/id/{cover_id}-{size}.jpg"
SEARCH_FIELDS = (
"key,title,author_name,cover_i,first_publish_year,subject,language,"
"edition_key,number_of_pages_median,publisher"
)
HIGH_CONFIDENCE = 0.8
LOW_CONFIDENCE = 0.6
# EPUB release noise often copied from filenames, e.g. "Title [6494] (r2.3)"
_TITLE_NOISE_PATTERNS = (
re.compile(r"\s*\[\d+\]"), # [6494]
re.compile(r"\s*\([rv][\d.]+\)", re.IGNORECASE), # (r2.3), (v1.0)
re.compile(r"\s*\(rev[\d.]*\)", re.IGNORECASE), # (rev2)
)
def _sanitize_search_title(title: str) -> str:
cleaned = title.strip()
for pattern in _TITLE_NOISE_PATTERNS:
cleaned = pattern.sub("", cleaned)
return re.sub(r"\s+", " ", cleaned).strip()
@dataclass(frozen=True)
class OpenLibraryHit:
work_key: str
title: str
authors: list[str]
cover_id: int | None
first_publish_year: int | None
subjects: list[str]
languages: list[str]
publishers: list[str]
edition_key: str | None
number_of_pages_median: int | None
match_language: str
score: float
@dataclass(frozen=True)
class OpenLibraryMetadata:
match_language: str
match_score: float
match_status: str
openlibrary: dict[str, Any]
def _normalize(text: str) -> str:
cleaned = re.sub(r"[^\w\s]", " ", _strip_accents(text).lower())
return re.sub(r"\s+", " ", cleaned).strip()
def _strip_accents(text: str) -> str:
normalized = unicodedata.normalize("NFKD", text)
return "".join(ch for ch in normalized if not unicodedata.combining(ch))
def _build_search_params(
title: str,
author: str,
*,
lang: str | None,
search_mode: str,
) -> dict[str, str | int]:
title = title.strip()
author = author.strip()
params: dict[str, str | int] = {"limit": 10, "fields": SEARCH_FIELDS}
if search_mode == "spanish_q":
q_parts = ["language:spa", title]
if author:
q_parts.append(author)
params["q"] = " ".join(q_parts)
elif search_mode == "q":
params["q"] = " ".join(part for part in (title, author) if part)
elif search_mode == "q_unaccent":
params["q"] = " ".join(
part for part in (_strip_accents(title), _strip_accents(author) if author else "") if part
)
else:
params["title"] = title
if author:
params["author"] = author
if lang:
params["lang"] = lang
return params
def _title_similarity(a: str, b: str) -> float:
na, nb = _normalize(a), _normalize(b)
if not na or not nb:
return 0.0
if na in nb or nb in na:
return 1.0
return SequenceMatcher(None, na, nb).ratio()
def _author_overlap(user_author: str, ol_authors: list[str]) -> float:
if not user_author.strip():
return 0.5 if ol_authors else 0.0
user_tokens = set(_normalize(user_author).split())
if not user_tokens:
return 0.0
best = 0.0
for name in ol_authors:
name_tokens = set(_normalize(name).split())
if not name_tokens:
continue
overlap = len(user_tokens & name_tokens) / len(user_tokens)
best = max(best, overlap)
if user_tokens <= name_tokens or name_tokens <= user_tokens:
best = max(best, 0.95)
return best
def _score_hit(title: str, author: str, doc: dict[str, Any], *, lang: str) -> float:
ol_title = doc.get("title") or ""
ol_authors = doc.get("author_name") or []
title_score = _title_similarity(title, ol_title)
author_score = _author_overlap(author, ol_authors)
combined = (title_score * 0.6) + (author_score * 0.4)
if doc.get("cover_i"):
combined += 0.05
return min(combined, 1.0)
def _parse_hit(doc: dict[str, Any], *, lang: str, score: float) -> OpenLibraryHit:
edition_keys = doc.get("edition_key") or []
edition_key = edition_keys[0] if edition_keys else None
cover_id = doc.get("cover_i")
return OpenLibraryHit(
work_key=doc.get("key") or "",
title=doc.get("title") or "",
authors=list(doc.get("author_name") or []),
cover_id=int(cover_id) if cover_id else None,
first_publish_year=doc.get("first_publish_year"),
subjects=list(doc.get("subject") or [])[:10],
languages=list(doc.get("language") or []),
publishers=list(doc.get("publisher") or [])[:5],
edition_key=edition_key,
number_of_pages_median=doc.get("number_of_pages_median"),
match_language=lang,
score=score,
)
def build_cover_url(cover_id: int, size: str = "L") -> str:
return COVERS_URL.format(cover_id=cover_id, size=size)
def _client() -> httpx.Client:
read_timeout = settings.OPENLIBRARY_TIMEOUT_SECONDS
connect_timeout = settings.OPENLIBRARY_CONNECT_TIMEOUT_SECONDS
return httpx.Client(
timeout=httpx.Timeout(
connect=connect_timeout,
read=read_timeout,
write=read_timeout,
pool=connect_timeout,
),
headers={"User-Agent": settings.OPENLIBRARY_USER_AGENT},
follow_redirects=True,
)
def search_works(
title: str,
author: str,
*,
lang: str | None = None,
search_mode: str = "title",
client: httpx.Client | None = None,
) -> list[OpenLibraryHit]:
if not title.strip():
return []
params = _build_search_params(title, author, lang=lang, search_mode=search_mode)
try:
if client is not None:
response = client.get(SEARCH_URL, params=params)
response.raise_for_status()
docs = response.json().get("docs") or []
else:
with _client() as owned_client:
response = owned_client.get(SEARCH_URL, params=params)
response.raise_for_status()
docs = response.json().get("docs") or []
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Open Library search failed: %s", exc)
return []
hits: list[OpenLibraryHit] = []
for doc in docs:
score = _score_hit(title, author, doc, lang=lang or "")
if score < LOW_CONFIDENCE:
continue
hits.append(_parse_hit(doc, lang=lang or "", score=score))
hits.sort(key=lambda h: (h.score, h.cover_id is not None), reverse=True)
return hits
def pick_best_match(title: str, author: str, hits: list[OpenLibraryHit]) -> OpenLibraryHit | None:
return hits[0] if hits else None
def download_cover(cover_id: int) -> bytes | None:
url = build_cover_url(cover_id, size="L")
try:
with _client() as client:
response = client.get(url)
if response.status_code == 404:
return None
response.raise_for_status()
content_type = response.headers.get("content-type", "")
if not content_type.startswith("image/"):
return None
return response.content
except httpx.HTTPError as exc:
logger.warning("Open Library cover download failed for %s: %s", cover_id, exc)
return None
def _hit_to_openlibrary_dict(hit: OpenLibraryHit) -> dict[str, Any]:
return {
"work_key": hit.work_key,
"edition_key": hit.edition_key,
"title": hit.title,
"authors": hit.authors,
"cover_id": hit.cover_id,
"cover_url": build_cover_url(hit.cover_id) if hit.cover_id else None,
"first_publish_year": hit.first_publish_year,
"subjects": hit.subjects,
"languages": hit.languages,
"publishers": hit.publishers,
"number_of_pages_median": hit.number_of_pages_median,
}
def fetch_metadata(title: str, author: str) -> OpenLibraryMetadata | None:
if not settings.OPENLIBRARY_ENABLED:
return None
preferred = settings.OPENLIBRARY_PREFERRED_LANG
fallback = settings.OPENLIBRARY_FALLBACK_LANG
search_title = _sanitize_search_title(title)
if not search_title:
return OpenLibraryMetadata(
match_language=preferred,
match_score=0.0,
match_status="not_found",
openlibrary={},
)
search_plan: list[tuple[str, str | None]] = [
("spanish_q", preferred),
("title", preferred),
("q", None),
("q_unaccent", None),
]
if fallback != preferred:
search_plan.extend([("title", fallback), ("q", fallback)])
hits: list[OpenLibraryHit] = []
with _client() as client:
for search_mode, lang in search_plan:
hits = search_works(search_title, author, lang=lang, search_mode=search_mode, client=client)
if hits:
break
hit = pick_best_match(title, author, hits)
if not hit:
return OpenLibraryMetadata(
match_language=preferred,
match_score=0.0,
match_status="not_found",
openlibrary={},
)
status = "matched" if hit.score >= HIGH_CONFIDENCE else "partial"
return OpenLibraryMetadata(
match_language=hit.match_language,
match_score=hit.score,
match_status=status,
openlibrary=_hit_to_openlibrary_dict(hit),
)
@@ -0,0 +1,177 @@
"""Extract TOC, metadata, and page count from uploaded EPUB/PDF files."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
from apps.books.models import BookChapter, EBook
logger = logging.getLogger(__name__)
def process_ebook(file_path: str, original_filename: str | None = None) -> dict[str, Any]:
"""Parse an e-book file and return format, metadata, TOC, and page count."""
ext = Path(original_filename or file_path).suffix.lower()
if ext == ".epub" or file_path.lower().endswith(".epub"):
return _process_epub(file_path)
if ext == ".pdf" or file_path.lower().endswith(".pdf"):
return _process_pdf(file_path)
return {"format": ext.lstrip(".") or "unknown", "page_count": 0, "metadata": {}, "toc": []}
def store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None:
"""Recursively store TOC entries as BookChapter records."""
for idx, entry in enumerate(toc):
BookChapter.objects.create(
ebook=ebook,
title=entry.get("title", "Untitled"),
index=parent_index + idx,
href=entry.get("href", ""),
children=entry.get("children", []),
)
children = entry.get("children", [])
if children:
store_chapters(ebook, children, parent_index + idx + 1)
def apply_processing_to_ebook(ebook: EBook) -> dict[str, Any]:
"""Run processing on an EBook instance and persist chapters + metadata."""
if not ebook.file:
raise ValueError("No file found for this e-book.")
file_path = ebook.file.path
result = process_ebook(file_path, original_filename=ebook.filename())
ebook.format = result.get("format", ebook.format)
ebook.page_count = result.get("page_count", 0)
file_metadata = result.get("metadata") or {}
if file_metadata:
merged = dict(ebook.metadata_json or {})
merged["file"] = file_metadata
ebook.metadata_json = merged
ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"])
raw_toc: list[dict[str, Any]] = result.get("toc", [])
BookChapter.objects.filter(ebook=ebook).delete()
store_chapters(ebook, raw_toc)
return {
"format": ebook.format,
"page_count": ebook.page_count,
"metadata": ebook.metadata_json,
"toc_count": len(raw_toc),
"status": "processed",
}
def _process_epub(file_path: str) -> dict[str, Any]:
from ebooklib import epub, ITEM_DOCUMENT
book = epub.read_epub(file_path)
metadata = _extract_epub_metadata(book)
toc = _extract_epub_toc(book)
if not toc:
toc = _fallback_toc_from_spine(book, ITEM_DOCUMENT)
flat_count = _count_toc_entries(toc)
page_count = flat_count or len(book.spine)
return {
"format": "epub",
"page_count": page_count,
"metadata": metadata,
"toc": toc,
}
def _process_pdf(file_path: str) -> dict[str, Any]:
return {"format": "pdf", "page_count": 0, "metadata": {}, "toc": []}
def _extract_epub_metadata(book) -> dict[str, Any]:
metadata: dict[str, Any] = {}
def first(namespace: str, name: str) -> str:
values = book.get_metadata(namespace, name)
if values:
return str(values[0][0])
return ""
title = first("DC", "title")
if title:
metadata["title"] = title
creator = first("DC", "creator")
if creator:
metadata["author"] = creator
language = first("DC", "language")
if language:
metadata["language"] = language
identifier = first("DC", "identifier")
if identifier:
metadata["identifier"] = identifier
return metadata
def _parse_toc_item(item) -> dict[str, Any]:
from ebooklib import epub
if isinstance(item, epub.Link):
return {
"title": item.title or "Untitled",
"href": item.href or "",
"children": [],
}
if isinstance(item, tuple):
section, children = item
entry = {
"title": getattr(section, "title", None) or "Untitled",
"href": getattr(section, "href", None) or "",
"children": [],
}
for child in children:
entry["children"].append(_parse_toc_item(child))
return entry
if hasattr(item, "title"):
return {
"title": item.title or "Untitled",
"href": getattr(item, "href", "") or "",
"children": [],
}
return {"title": "Untitled", "href": "", "children": []}
def _extract_epub_toc(book) -> list[dict[str, Any]]:
return [_parse_toc_item(item) for item in book.toc]
def _fallback_toc_from_spine(book, item_document_type) -> list[dict[str, Any]]:
toc: list[dict[str, Any]] = []
seen: set[str] = set()
chapter_num = 0
for spine_entry in book.spine:
item_id = spine_entry[0] if isinstance(spine_entry, tuple) else spine_entry
item = book.get_item_with_id(item_id)
if not item or item.get_type() != item_document_type:
continue
href = item.get_name() or ""
if not href or href in seen:
continue
seen.add(href)
chapter_num += 1
title = os.path.splitext(os.path.basename(href))[0] or f"Chapter {chapter_num}"
toc.append({"title": title.replace("_", " ").replace("-", " "), "href": href, "children": []})
return toc
def _count_toc_entries(toc: list[dict[str, Any]]) -> int:
count = 0
for entry in toc:
count += 1
count += _count_toc_entries(entry.get("children", []))
return count
+4 -6
View File
@@ -1,15 +1,13 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from apps.books.views import BookViewSet, EBookViewSet
from apps.books.views import BookViewSet, EBookViewSet, book_reading_settings_view
router = DefaultRouter()
router.register(r"ebooks", EBookViewSet, basename="ebook")
router.register(r"", BookViewSet, basename="book")
ebook_router = DefaultRouter()
ebook_router.register(r"ebooks", EBookViewSet, basename="ebook")
urlpatterns = [
path("settings/", book_reading_settings_view, name="book-settings"),
path("", include(router.urls)),
path("", include(ebook_router.urls)),
]
]
+59 -41
View File
@@ -4,9 +4,10 @@ import logging
from typing import Any
from django.db.models import QuerySet, Q
from django.http import FileResponse
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import parsers, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
@@ -14,11 +15,13 @@ from rest_framework.response import Response
from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress
from apps.books.serializers import (
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookSerializer,
DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
ReadingProgressSerializer, StorageSummarySerializer,
)
from apps.reader.models import ReadingSettings
from apps.books.services.metadata import enrich_ebook_metadata
logger = logging.getLogger(__name__)
@@ -107,6 +110,22 @@ class EBookViewSet(viewsets.ModelViewSet):
serializer.save()
return Response(serializer.data)
@action(detail=True, methods=["post"], url_path="enrich-metadata")
def enrich_metadata(self, request: Request, pk: int | None = None) -> Response:
"""Re-fetch Open Library metadata and cover for this ebook."""
ebook = self.get_object()
try:
enrich_ebook_metadata(ebook)
except Exception:
logger.exception("Manual metadata enrichment failed for ebook %s", ebook.id)
return Response(
{"error": "Metadata enrichment failed."},
status=status.HTTP_502_BAD_GATEWAY,
)
ebook.refresh_from_db()
serializer = EBookDetailSerializer(ebook, context={"request": request})
return Response(serializer.data)
@action(detail=True, methods=["post"])
def process(self, request: Request, pk: int | None = None) -> Response:
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
@@ -115,33 +134,31 @@ class EBookViewSet(viewsets.ModelViewSet):
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
try:
from apps.books.services import process_ebook
from apps.books.services.process_ebook import apply_processing_to_ebook
file_path = ebook.file.path
result = process_ebook(file_path, original_filename=ebook.filename())
# Update ebook with extracted data
ebook.format = result.get("format", ebook.format)
ebook.page_count = result.get("page_count", 0)
ebook.metadata_json = result.get("metadata", {})
ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"])
# Store chapters in DB
raw_toc: list[dict[str, Any]] = result.get("toc", [])
BookChapter.objects.filter(ebook=ebook).delete()
_store_chapters(ebook, raw_toc)
return Response({
"format": ebook.format,
"page_count": ebook.page_count,
"metadata": ebook.metadata_json,
"toc_count": len(raw_toc),
"status": "processed",
})
result = apply_processing_to_ebook(ebook)
return Response(result)
except Exception as exc:
logger.exception("Failed to process ebook %s", ebook.id)
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=["get"])
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
"""Stream the EPUB file for authenticated in-browser reading."""
ebook = self.get_object()
if ebook.format != "epub":
return Response(
{"error": "Reader supports EPUB only."},
status=status.HTTP_400_BAD_REQUEST,
)
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
return FileResponse(
ebook.file.open("rb"),
content_type="application/epub+zip",
filename=ebook.filename(),
)
@action(detail=True, methods=["get"])
def toc(self, request: Request, pk: int | None = None) -> Response:
"""Return hierarchical table of contents."""
@@ -229,21 +246,6 @@ class EBookViewSet(viewsets.ModelViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def _store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None:
"""Recursively store TOC entries as BookChapter records."""
for idx, entry in enumerate(toc):
BookChapter.objects.create(
ebook=ebook,
title=entry.get("title", "Untitled"),
index=parent_index + idx,
href=entry.get("href", ""),
children=entry.get("children", []),
)
children = entry.get("children", [])
if children:
_store_chapters(ebook, children, parent_index + idx + 1)
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
"""Fetch HTML content for a chapter from the e-book file."""
if ebook.format == "epub":
@@ -276,4 +278,20 @@ def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
return ""
except Exception:
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
return ""
return ""
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def book_reading_settings_view(request: Request) -> Response:
"""Get or update reading settings via the books API contract."""
settings, _created = ReadingSettings.objects.get_or_create(user=request.user)
if request.method == "GET":
serializer = BookReadingSettingsSerializer(settings)
return Response(serializer.data)
serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@@ -0,0 +1,44 @@
# Generated by Django 5.1.7 on 2026-06-03 22:26
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'User',
'verbose_name_plural': 'Users',
'db_table': 'users_user',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
+93
View File
@@ -0,0 +1,93 @@
import re
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from rest_framework import serializers
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
User = get_user_model()
def _derive_username(email: str) -> str:
local = email.split("@", 1)[0]
candidate = re.sub(r"[^\w.@+-]", "_", local).strip("._")
return candidate[:150] if candidate else "user"
def _unique_username(base: str) -> str:
username = base[:150]
if not User.objects.filter(username=username).exists():
return username
suffix = 1
while User.objects.filter(username=f"{username[:140]}_{suffix}").exists():
suffix += 1
return f"{username[:140]}_{suffix}"
class RegisterSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(write_only=True, min_length=8)
def validate_email(self, value: str) -> str:
email = value.lower()
if User.objects.filter(email__iexact=email).exists():
raise serializers.ValidationError("A user with this email already exists.")
return email
def validate_password(self, value: str) -> str:
validate_password(value)
return value
def create(self, validated_data: dict) -> User:
email = validated_data["email"]
username = _unique_username(_derive_username(email))
return User.objects.create_user(
username=username,
email=email,
password=validated_data["password"],
)
def to_representation(self, instance: User) -> dict:
return {
"id": instance.id,
"email": instance.email,
"username": instance.username,
}
class EmailTokenObtainPairSerializer(TokenObtainPairSerializer):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.fields.pop(self.username_field, None)
self.fields["email"] = serializers.EmailField(required=True)
def validate(self, attrs: dict) -> dict:
email = attrs.get("email", "").lower()
password = attrs.get("password")
try:
user = User.objects.get(email__iexact=email)
except User.DoesNotExist as exc:
raise serializers.ValidationError(
{"detail": "No active account found with the given credentials."}
) from exc
if not user.check_password(password):
raise serializers.ValidationError(
{"detail": "No active account found with the given credentials."}
)
if not user.is_active:
raise serializers.ValidationError({"detail": "User account is disabled."})
refresh = self.get_token(user)
return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
@classmethod
def get_token(cls, user: User) -> object:
token = super().get_token(user)
token["email"] = user.email
return token
+8 -5
View File
@@ -1,8 +1,11 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from apps.users.views import EmailTokenObtainPairView, RegisterView
urlpatterns = [
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("register/", RegisterView.as_view(), name="register"),
path("token/", EmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
path("login/", EmailTokenObtainPairView.as_view(), name="login"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]
]
+14
View File
@@ -0,0 +1,14 @@
from rest_framework import generics
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.views import TokenObtainPairView
from apps.users.serializers import EmailTokenObtainPairSerializer, RegisterSerializer
class RegisterView(generics.CreateAPIView):
serializer_class = RegisterSerializer
permission_classes = [AllowAny]
class EmailTokenObtainPairView(TokenObtainPairView):
serializer_class = EmailTokenObtainPairSerializer