Archived
Backend: - Add ReadingSchedule and MeetingSection models for ebook reading schedules - Implement recommendation algorithm: greedy partition minimizing per-meeting time variance - Algorithm respects chapter boundaries, estimates time based on chapter weight - Add serializers for schedules (list/detail/generate/confirm/update) - Add ScheduleViewSet with endpoints: list, create (generate), retrieve, regenerate, confirm, update_meeting, reorder_meetings - Add URL routes for /api/books/schedules/ - Create migration 0004 for new models Frontend: - Add types: MeetingSection, ReadingSchedule, GenerateRecommendationsRequest - Add schedulesApi with methods: getSchedules, generateRecommendations, confirmSchedule, updateMeeting, reorderMeetings, deleteSchedule - Build RecommendationPage with 4-column meeting layout, drag-and-drop chapter reassignment, generate/regenerate/confirm flow - Add 'Schedule Reading' menu item to BookContextMenu - Add route: /books/:ebookId/schedule
291 lines
10 KiB
Python
291 lines
10 KiB
Python
from rest_framework import serializers
|
|
|
|
import logging
|
|
|
|
from apps.books.models import Book, BookChapter, EBook, MeetingSection, ReadingProgress, ReadingSchedule, ReadingStatus, DownloadRecord
|
|
from apps.books.services.ebook_metadata import subjects_from_ebook
|
|
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):
|
|
class Meta:
|
|
model = BookChapter
|
|
fields = ["id", "title", "index", "href", "children"]
|
|
|
|
|
|
class EBookContentSerializer(serializers.Serializer):
|
|
page = serializers.IntegerField()
|
|
total_pages = serializers.IntegerField()
|
|
content = serializers.CharField()
|
|
chapter_title = serializers.CharField()
|
|
format = serializers.CharField()
|
|
|
|
|
|
class EBookTocSerializer(serializers.Serializer):
|
|
chapters = serializers.ListField(child=BookChapterSerializer())
|
|
format = serializers.CharField()
|
|
page_count = serializers.IntegerField()
|
|
|
|
|
|
class BookListSerializer(serializers.ModelSerializer):
|
|
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
|
|
|
|
class Meta:
|
|
model = Book
|
|
fields = ["id", "title", "author", "genre", "reading_status", "reading_status_display", "cover_image"]
|
|
|
|
|
|
class BookDetailSerializer(serializers.ModelSerializer):
|
|
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
|
|
|
|
class Meta:
|
|
model = Book
|
|
fields = ["id", "title", "author", "genre", "description", "reading_status", "reading_status_display", "cover_image", "total_pages", "created_at", "updated_at"]
|
|
read_only_fields = ["id", "created_at", "updated_at"]
|
|
|
|
|
|
class BookSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Book
|
|
fields = ["id", "title", "author", "genre", "description", "reading_status", "cover_image", "total_pages", "created_at", "updated_at"]
|
|
read_only_fields = ["id", "created_at", "updated_at"]
|
|
|
|
|
|
class EBookListSerializer(serializers.ModelSerializer):
|
|
filename = serializers.CharField(read_only=True)
|
|
format = serializers.CharField(read_only=True)
|
|
progress = serializers.SerializerMethodField()
|
|
started = serializers.SerializerMethodField()
|
|
subjects = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = EBook
|
|
fields = [
|
|
"id", "title", "author", "filename", "format", "page_count", "file_size",
|
|
"cover_image", "created_at", "progress", "started", "subjects",
|
|
]
|
|
|
|
def get_subjects(self, obj: EBook) -> list[str]:
|
|
return subjects_from_ebook(obj)
|
|
|
|
def get_progress(self, obj):
|
|
try:
|
|
return obj.reading_progress.current_position
|
|
except ReadingProgress.DoesNotExist:
|
|
return None
|
|
|
|
def get_started(self, obj):
|
|
"""True when the user has opened the reader with saved progress."""
|
|
try:
|
|
rp = obj.reading_progress
|
|
except ReadingProgress.DoesNotExist:
|
|
return False
|
|
if rp.current_position >= 99:
|
|
return False
|
|
if obj.format == "pdf":
|
|
return rp.last_page > 0 or rp.current_position > 0
|
|
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", "metadata", "created_at", "updated_at", "progress",
|
|
]
|
|
|
|
def get_file_url(self, obj):
|
|
request = self.context.get("request")
|
|
if request and obj.file:
|
|
return request.build_absolute_uri(obj.file.url)
|
|
return ""
|
|
|
|
def get_progress(self, obj):
|
|
try:
|
|
rp = obj.reading_progress
|
|
return {
|
|
"current_position": rp.current_position,
|
|
"last_page": rp.last_page,
|
|
"epub_location": rp.epub_location,
|
|
}
|
|
except ReadingProgress.DoesNotExist:
|
|
return None
|
|
|
|
|
|
class EBookUploadSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = EBook
|
|
fields = ["title", "author", "file", "cover_image"]
|
|
extra_kwargs = {"title": {"required": True}, "file": {"required": True}}
|
|
|
|
def validate_file(self, value):
|
|
import os
|
|
if value is None:
|
|
return value
|
|
ext = os.path.splitext(str(getattr(value, "name", "")))[1].lower()
|
|
if ext not in (".epub", ".pdf"):
|
|
raise serializers.ValidationError("Only EPUB and PDF files are supported.")
|
|
return value
|
|
|
|
def create(self, validated_data):
|
|
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
|
|
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", "epub_location",
|
|
"device_id", "device_name", "version", "updated_at",
|
|
]
|
|
read_only_fields = ["version", "updated_at"]
|
|
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:
|
|
raise serializers.ValidationError("Position must be between 0.0 and 100.0.")
|
|
return value
|
|
|
|
|
|
class DownloadRecordSerializer(serializers.ModelSerializer):
|
|
ebook_id = serializers.IntegerField(source="ebook.id", read_only=True)
|
|
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
|
author = serializers.CharField(source="ebook.author", read_only=True)
|
|
filename = serializers.SerializerMethodField()
|
|
cover_image = serializers.ImageField(source="ebook.cover_image", read_only=True)
|
|
format = serializers.CharField(source="ebook.format", read_only=True)
|
|
progress = serializers.SerializerMethodField()
|
|
file_url = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = DownloadRecord
|
|
fields = [
|
|
"id", "ebook_id", "ebook_title", "author", "filename", "file_url",
|
|
"file_size", "cover_image", "format", "downloaded_at", "progress",
|
|
]
|
|
|
|
def get_filename(self, obj):
|
|
return obj.ebook.filename()
|
|
|
|
def get_file_url(self, obj):
|
|
request = self.context.get("request")
|
|
if request and obj.ebook.file:
|
|
return request.build_absolute_uri(obj.ebook.file.url)
|
|
return ""
|
|
|
|
def get_progress(self, obj):
|
|
try:
|
|
rp = obj.ebook.reading_progress
|
|
return {
|
|
"current_position": rp.current_position,
|
|
"last_page": rp.last_page,
|
|
"epub_location": rp.epub_location,
|
|
}
|
|
except ReadingProgress.DoesNotExist:
|
|
return None
|
|
|
|
|
|
class StorageSummarySerializer(serializers.Serializer):
|
|
total_downloads = serializers.IntegerField()
|
|
total_size_bytes = serializers.IntegerField()
|
|
ebooks = serializers.ListField(child=serializers.DictField())
|
|
|
|
|
|
class MeetingSectionSerializer(serializers.ModelSerializer):
|
|
chapters = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = MeetingSection
|
|
fields = [
|
|
"id", "meeting_index", "title", "estimated_time_minutes",
|
|
"chapter_ids", "chapters",
|
|
]
|
|
read_only_fields = ["id"]
|
|
|
|
def get_chapters(self, obj: MeetingSection) -> list[dict[str, str | int]]:
|
|
"""Resolve chapter_ids to chapter title/index/href for the frontend."""
|
|
if not obj.chapter_ids:
|
|
return []
|
|
chapters_qs = BookChapter.objects.filter(
|
|
id__in=obj.chapter_ids,
|
|
ebook=obj.schedule.ebook,
|
|
).order_by("index").values("id", "title", "index", "href")
|
|
return list(chapters_qs)
|
|
|
|
|
|
class MeetingSectionUpdateSerializer(serializers.Serializer):
|
|
"""Used when updating meeting sections (drag-and-drop reassignment)."""
|
|
meeting_id = serializers.IntegerField(required=False)
|
|
meeting_index = serializers.IntegerField(required=False)
|
|
chapter_ids = serializers.ListField(child=serializers.IntegerField(), required=False)
|
|
title = serializers.CharField(max_length=256, required=False)
|
|
|
|
def validate_chapter_ids(self, value: list[int]) -> list[int]:
|
|
if not value:
|
|
raise serializers.ValidationError("chapter_ids must not be empty.")
|
|
return value
|
|
|
|
|
|
class ReadingScheduleSerializer(serializers.ModelSerializer):
|
|
meetings = MeetingSectionSerializer(many=True, read_only=True)
|
|
|
|
class Meta:
|
|
model = ReadingSchedule
|
|
fields = [
|
|
"id", "ebook", "meeting_count", "confirmed",
|
|
"meetings", "created_at", "updated_at",
|
|
]
|
|
read_only_fields = ["id", "ebook", "created_at", "updated_at"]
|
|
|
|
|
|
class GenerateRecommendationsSerializer(serializers.Serializer):
|
|
meeting_count = serializers.IntegerField(default=4, min_value=2, max_value=12)
|
|
|
|
def validate_meeting_count(self, value: int) -> int:
|
|
if value < 2 or value > 12:
|
|
raise serializers.ValidationError("Meeting count must be between 2 and 12.")
|
|
return value
|
|
|
|
|
|
class ConfirmScheduleSerializer(serializers.Serializer):
|
|
confirm = serializers.BooleanField(default=True) |