feat: US #32 Section-based Reading Recommendations

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
This commit is contained in:
Marko
2026-06-20 19:31:39 +00:00
parent 26c5f6f06b
commit 4a7133465f
11 changed files with 1266 additions and 9 deletions
+62 -2
View File
@@ -2,7 +2,7 @@ from rest_framework import serializers
import logging
from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord
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
@@ -228,4 +228,64 @@ class DownloadRecordSerializer(serializers.ModelSerializer):
class StorageSummarySerializer(serializers.Serializer):
total_downloads = serializers.IntegerField()
total_size_bytes = serializers.IntegerField()
ebooks = serializers.ListField(child=serializers.DictField())
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)