Archived
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:
+173
-5
@@ -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)
|
||||
Reference in New Issue
Block a user