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
+173 -5
View File
@@ -13,12 +13,13 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress
from apps.books.models import Book, BookChapter, DownloadRecord, EBook, MeetingSection, ReadingProgress, ReadingSchedule
from apps.books.serializers import (
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
ReadingProgressSerializer, StorageSummarySerializer,
BookSerializer, ConfirmScheduleSerializer, DownloadRecordSerializer, EBookContentSerializer,
EBookDetailSerializer, EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
GenerateRecommendationsSerializer, MeetingSectionSerializer, MeetingSectionUpdateSerializer,
ReadingProgressSerializer, ReadingScheduleSerializer, StorageSummarySerializer,
)
from apps.reader.models import ReadingSettings
from apps.books.services.ebook_metadata import subjects_from_ebook
@@ -323,4 +324,171 @@ def book_reading_settings_view(request: Request) -> Response:
serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
return Response(serializer.data)
class IsScheduleOwner(permissions.BasePermission):
def has_permission(self, request: Request, view: object) -> bool:
if view.action == "create":
ebook_id = request.data.get("ebook") or request.query_params.get("ebook_id")
if ebook_id:
return EBook.objects.filter(id=ebook_id, user=request.user).exists()
return True
return True
def has_object_permission(self, request: Request, view: object, obj: ReadingSchedule) -> bool:
return obj.user == request.user
class ScheduleViewSet(viewsets.ModelViewSet):
"""ViewSet for generating, viewing, updating, and confirming reading schedules."""
permission_classes = [IsAuthenticated, IsScheduleOwner]
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
def get_serializer_class(self):
if self.action == "generate":
return GenerateRecommendationsSerializer
if self.action == "confirm":
return ConfirmScheduleSerializer
if self.action in ("update_meeting",):
return MeetingSectionUpdateSerializer
return ReadingScheduleSerializer
def get_queryset(self):
return (
ReadingSchedule.objects.filter(user=self.request.user)
.select_related("ebook")
.prefetch_related("meetings")
.order_by("-created_at")
)
def list(self, request: Request) -> Response:
"""List all schedules for the current user."""
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def retrieve(self, request: Request, pk: int | None = None) -> Response:
"""Get a single schedule with meetings."""
schedule = self.get_object()
serializer = self.get_serializer(schedule)
return Response(serializer.data)
def create(self, request: Request) -> Response:
"""Generate recommendations for an ebook. Expects {ebook_id, meeting_count?}."""
serializer = GenerateRecommendationsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
meeting_count = serializer.validated_data["meeting_count"]
ebook_id = request.data.get("ebook") or request.data.get("ebook_id")
if not ebook_id:
return Response(
{"error": "ebook (id) is required."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
ebook = EBook.objects.get(id=ebook_id, user=request.user)
except EBook.DoesNotExist:
return Response(
{"error": "EBook not found or not owned by you."},
status=status.HTTP_404_NOT_FOUND,
)
from apps.books.services.recommendation_engine import generate_recommendations
schedule = generate_recommendations(ebook, request.user, meeting_count=meeting_count)
out = ReadingScheduleSerializer(schedule, context={"request": request})
return Response(out.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
def generate(self, request: Request, pk: int | None = None) -> Response:
"""Re-generate recommendations for an existing schedule."""
schedule = self.get_object()
serializer = GenerateRecommendationsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
meeting_count = serializer.validated_data["meeting_count"]
from apps.books.services.recommendation_engine import regenerate_recommendations
schedule.meeting_count = meeting_count
schedule.save(update_fields=["meeting_count", "updated_at"])
schedule = regenerate_recommendations(schedule)
out = ReadingScheduleSerializer(schedule, context={"request": request})
return Response(out.data)
@action(detail=True, methods=["post"])
def confirm(self, request: Request, pk: int | None = None) -> Response:
"""Confirm/finalize a schedule."""
schedule = self.get_object()
serializer = ConfirmScheduleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
from apps.books.services.recommendation_engine import confirm_schedule
schedule = confirm_schedule(schedule)
out = ReadingScheduleSerializer(schedule, context={"request": request})
return Response(out.data)
@action(detail=True, methods=["patch"], url_path="meetings/(?P<meeting_pk>[^/.]+)")
def update_meeting(self, request: Request, pk: int | None = None,
meeting_pk: str | None = None) -> Response:
"""Update a single meeting section (drag-and-drop reassignment)."""
schedule = self.get_object()
try:
meeting = schedule.meetings.get(pk=meeting_pk)
except MeetingSection.DoesNotExist:
return Response(
{"error": "Meeting section not found."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = MeetingSectionUpdateSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
if "chapter_ids" in data:
meeting.chapter_ids = data["chapter_ids"]
if "title" in data:
meeting.title = data["title"]
if "meeting_index" in data:
meeting.meeting_index = data["meeting_index"]
# Recalculate estimated time
chapter_count = len(meeting.chapter_ids) if meeting.chapter_ids else 0
meeting.estimated_time_minutes = max(5, chapter_count * 5)
meeting.save(update_fields=["chapter_ids", "title", "meeting_index", "estimated_time_minutes"])
out = MeetingSectionSerializer(meeting, context={"request": request})
return Response(out.data)
@action(detail=True, methods=["patch"], url_path="reorder")
def reorder_meetings(self, request: Request, pk: int | None = None) -> Response:
"""Reorder meetings. Expects {meetings: [{id, meeting_index}, ...]}."""
schedule = self.get_object()
meetings_data = request.data.get("meetings", [])
if not isinstance(meetings_data, list):
return Response(
{"error": "meetings must be a list."},
status=status.HTTP_400_BAD_REQUEST,
)
meeting_map = {m.id: m for m in schedule.meetings.all()}
updated: list[MeetingSection] = []
for entry in meetings_data:
mid = entry.get("id")
new_index = entry.get("meeting_index")
if mid is None or new_index is None:
continue
m = meeting_map.get(mid)
if m is None:
continue
m.meeting_index = new_index
updated.append(m)
MeetingSection.objects.bulk_update(updated, ["meeting_index"])
schedule.refresh_from_db()
out = ReadingScheduleSerializer(schedule, context={"request": request})
return Response(out.data)