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)
|
||||
|
||||
+105
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user