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:
@@ -0,0 +1,52 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0003_readingprogress_epub_location"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ReadingSchedule",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("meeting_count", models.PositiveIntegerField(default=4)),
|
||||
("confirmed", models.BooleanField(default=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("ebook", models.ForeignKey(on_delete=models.CASCADE, related_name="schedules", to="books.ebook")),
|
||||
("user", models.ForeignKey(on_delete=models.CASCADE, related_name="schedules", to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
"db_table": "books_reading_schedule",
|
||||
"verbose_name": "Reading Schedule",
|
||||
"verbose_name_plural": "Reading Schedules",
|
||||
"ordering": ["-created_at"],
|
||||
"unique_together": {("ebook", "user")},
|
||||
"indexes": [models.Index(fields=["ebook", "user"], name="books_reading_schedule_ebook_user_idx")],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MeetingSection",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("meeting_index", models.PositiveIntegerField()),
|
||||
("title", models.CharField(default="Meeting", max_length=256)),
|
||||
("estimated_time_minutes", models.PositiveIntegerField(default=0)),
|
||||
("chapter_ids", models.JSONField(default=list)),
|
||||
("schedule", models.ForeignKey(on_delete=models.CASCADE, related_name="meetings", to="books.readingschedule")),
|
||||
],
|
||||
options={
|
||||
"db_table": "books_meeting_section",
|
||||
"verbose_name": "Meeting Section",
|
||||
"verbose_name_plural": "Meeting Sections",
|
||||
"ordering": ["meeting_index"],
|
||||
"unique_together": {("schedule", "meeting_index")},
|
||||
"indexes": [models.Index(fields=["schedule", "meeting_index"], name="books_meeting_section_schedule_meeting_idx")],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -153,4 +153,47 @@ class ReadingProgress(models.Model):
|
||||
"current_position", "last_page",
|
||||
"device_id", "device_name", "version", "updated_at",
|
||||
])
|
||||
return self, True
|
||||
return self, True
|
||||
|
||||
|
||||
class ReadingSchedule(models.Model):
|
||||
"""A reading schedule that groups sections into weekly meetings for an ebook."""
|
||||
|
||||
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="schedules")
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="schedules")
|
||||
meeting_count = models.PositiveIntegerField(default=4)
|
||||
confirmed = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "books_reading_schedule"
|
||||
verbose_name = "Reading Schedule"
|
||||
verbose_name_plural = "Reading Schedules"
|
||||
ordering = ["-created_at"]
|
||||
unique_together = [("ebook", "user")]
|
||||
indexes = [models.Index(fields=["ebook", "user"])]
|
||||
|
||||
def __str__(self):
|
||||
return f"Schedule for {self.ebook.title} ({self.meeting_count} meetings)"
|
||||
|
||||
|
||||
class MeetingSection(models.Model):
|
||||
"""A single meeting within a reading schedule, containing assigned chapters."""
|
||||
|
||||
schedule = models.ForeignKey(ReadingSchedule, on_delete=models.CASCADE, related_name="meetings")
|
||||
meeting_index = models.PositiveIntegerField()
|
||||
title = models.CharField(max_length=256, default="Meeting")
|
||||
estimated_time_minutes = models.PositiveIntegerField(default=0)
|
||||
chapter_ids = models.JSONField(default=list)
|
||||
|
||||
class Meta:
|
||||
db_table = "books_meeting_section"
|
||||
verbose_name = "Meeting Section"
|
||||
verbose_name_plural = "Meeting Sections"
|
||||
ordering = ["meeting_index"]
|
||||
unique_together = [("schedule", "meeting_index")]
|
||||
indexes = [models.Index(fields=["schedule", "meeting_index"])]
|
||||
|
||||
def __str__(self):
|
||||
return f"Meeting {self.meeting_index + 1}: {self.title}"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Recommendation engine for splitting book chapters into balanced weekly meetings.
|
||||
|
||||
Algorithm:
|
||||
1. Gather all BookChapters for an ebook (flat list, respecting hierarchy).
|
||||
2. For each chapter, estimate reading time based on content length or default weight.
|
||||
3. Partition chapters into N meetings (default 4) minimizing per-meeting time variance
|
||||
while respecting chapter boundaries (never split a chapter).
|
||||
4. Optionally prefer natural boundaries: if a chapter's children exist,
|
||||
keep siblings together where possible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from apps.books.models import BookChapter, EBook, MeetingSection, ReadingSchedule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MEETING_COUNT = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChapterWithWeight:
|
||||
chapter_id: int
|
||||
title: str
|
||||
href: str
|
||||
index: int
|
||||
weight: float # estimated reading time weight
|
||||
children: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
def _collect_chapters(ebook: EBook) -> list[ChapterWithWeight]:
|
||||
"""Collect all chapters for an ebook as a flat, ordered list with weights."""
|
||||
chapters_qs = (
|
||||
BookChapter.objects.filter(ebook=ebook)
|
||||
.order_by("index")
|
||||
.prefetch_related("ebook")
|
||||
)
|
||||
chapters = list(chapters_qs)
|
||||
|
||||
# Build a map of chapter id -> ChapterWithWeight
|
||||
result: list[ChapterWithWeight] = []
|
||||
for ch in chapters:
|
||||
weight = _estimate_chapter_weight(ch)
|
||||
result.append(
|
||||
ChapterWithWeight(
|
||||
chapter_id=ch.id,
|
||||
title=ch.title,
|
||||
href=ch.href or "",
|
||||
index=ch.index,
|
||||
weight=weight,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _estimate_chapter_weight(chapter: BookChapter) -> float:
|
||||
"""Estimate reading time weight for a chapter.
|
||||
|
||||
Defaults to 1.0 per chapter. Sub-chapters (those with non-empty children)
|
||||
get a weight proportional to their child count + 1.
|
||||
"""
|
||||
children = chapter.children or []
|
||||
child_count = len(children)
|
||||
if child_count > 0:
|
||||
return float(child_count + 1)
|
||||
return 1.0
|
||||
|
||||
|
||||
def _partition_chapters(
|
||||
chapters: list[ChapterWithWeight],
|
||||
num_meetings: int = DEFAULT_MEETING_COUNT,
|
||||
) -> list[list[ChapterWithWeight]]:
|
||||
"""Partition chapters into `num_meetings` groups minimizing weight variance.
|
||||
|
||||
Uses a greedy approach: compute target weight per meeting, then place
|
||||
chapters sequentially, starting a new meeting when cumulative weight
|
||||
exceeds the target and adding the overflow to the next meeting.
|
||||
|
||||
Never splits a chapter — boundaries are always at chapter edges.
|
||||
"""
|
||||
if not chapters:
|
||||
return [[] for _ in range(num_meetings)]
|
||||
|
||||
total_weight = sum(ch.weight for ch in chapters)
|
||||
target_weight = total_weight / num_meetings
|
||||
|
||||
meetings: list[list[ChapterWithWeight]] = []
|
||||
current_meeting: list[ChapterWithWeight] = []
|
||||
current_weight = 0.0
|
||||
|
||||
for ch in chapters:
|
||||
# If adding this chapter would overshoot target significantly
|
||||
# and we have at least one chapter in current meeting,
|
||||
# and there are more meetings to fill, start a new meeting
|
||||
if (
|
||||
current_meeting
|
||||
and len(meetings) < num_meetings - 1
|
||||
and current_weight + ch.weight > target_weight * 1.4
|
||||
and current_weight >= target_weight * 0.5
|
||||
):
|
||||
meetings.append(current_meeting)
|
||||
current_meeting = []
|
||||
current_weight = 0.0
|
||||
|
||||
current_meeting.append(ch)
|
||||
current_weight += ch.weight
|
||||
|
||||
# Add the last meeting
|
||||
if current_meeting:
|
||||
meetings.append(current_meeting)
|
||||
|
||||
# Pad or balance: if we have fewer meetings than requested, split the largest
|
||||
while len(meetings) < num_meetings and len(meetings) >= 1:
|
||||
# Find the largest meeting to split
|
||||
largest_idx = max(
|
||||
range(len(meetings)),
|
||||
key=lambda i: sum(c.weight for c in meetings[i]),
|
||||
)
|
||||
largest = meetings[largest_idx]
|
||||
if len(largest) <= 1:
|
||||
break
|
||||
mid = len(largest) // 2
|
||||
meetings[largest_idx] = largest[:mid]
|
||||
meetings.insert(largest_idx + 1, largest[mid:])
|
||||
|
||||
# If we have more meetings than requested (shouldn't normally happen),
|
||||
# merge the smallest adjacent pair
|
||||
while len(meetings) > num_meetings:
|
||||
best_merge = min(
|
||||
range(len(meetings) - 1),
|
||||
key=lambda i: sum(c.weight for c in meetings[i])
|
||||
+ sum(c.weight for c in meetings[i + 1]),
|
||||
)
|
||||
merged = meetings[best_merge] + meetings[best_merge + 1]
|
||||
meetings[best_merge] = merged
|
||||
del meetings[best_merge + 1]
|
||||
|
||||
return meetings
|
||||
|
||||
|
||||
def generate_recommendations(
|
||||
ebook: EBook,
|
||||
user,
|
||||
meeting_count: int = DEFAULT_MEETING_COUNT,
|
||||
) -> ReadingSchedule:
|
||||
"""Generate section-based reading recommendations for an ebook.
|
||||
|
||||
Creates (or replaces) a ReadingSchedule with MeetingSections.
|
||||
|
||||
Args:
|
||||
ebook: The EBook to generate recommendations for.
|
||||
user: The requesting user.
|
||||
meeting_count: Number of weekly meetings (default 4).
|
||||
|
||||
Returns:
|
||||
The created ReadingSchedule instance.
|
||||
"""
|
||||
chapters = _collect_chapters(ebook)
|
||||
|
||||
# Delete any existing schedule for this ebook/user
|
||||
ReadingSchedule.objects.filter(ebook=ebook, user=user).delete()
|
||||
|
||||
schedule = ReadingSchedule.objects.create(
|
||||
ebook=ebook,
|
||||
user=user,
|
||||
meeting_count=meeting_count,
|
||||
)
|
||||
|
||||
if not chapters:
|
||||
# Create empty meetings
|
||||
for i in range(meeting_count):
|
||||
MeetingSection.objects.create(
|
||||
schedule=schedule,
|
||||
meeting_index=i,
|
||||
title=f"Week {i + 1}",
|
||||
)
|
||||
return schedule
|
||||
|
||||
partition = _partition_chapters(chapters, meeting_count)
|
||||
|
||||
for meeting_idx, meeting_chapters in enumerate(partition):
|
||||
chapter_ids = [ch.chapter_id for ch in meeting_chapters]
|
||||
total_weight = sum(ch.weight for ch in meeting_chapters)
|
||||
|
||||
# Build a descriptive title from first and last chapter
|
||||
first = meeting_chapters[0].title if meeting_chapters else "No chapters"
|
||||
last = meeting_chapters[-1].title if len(meeting_chapters) > 1 else ""
|
||||
if last and last != first:
|
||||
title = f"{first} → {last}"
|
||||
else:
|
||||
title = first
|
||||
|
||||
# Truncate title if too long
|
||||
if len(title) > 200:
|
||||
title = title[:197] + "..."
|
||||
|
||||
# Estimate time: ~5 minutes per weight point (adjustable heuristic)
|
||||
estimated_time = max(5, int(total_weight * 5))
|
||||
|
||||
MeetingSection.objects.create(
|
||||
schedule=schedule,
|
||||
meeting_index=meeting_idx,
|
||||
title=title,
|
||||
estimated_time_minutes=estimated_time,
|
||||
chapter_ids=chapter_ids,
|
||||
)
|
||||
|
||||
return schedule
|
||||
|
||||
|
||||
def regenerate_recommendations(schedule: ReadingSchedule) -> ReadingSchedule:
|
||||
"""Regenerate recommendations for an existing schedule."""
|
||||
return generate_recommendations(
|
||||
ebook=schedule.ebook,
|
||||
user=schedule.user,
|
||||
meeting_count=schedule.meeting_count,
|
||||
)
|
||||
|
||||
|
||||
def confirm_schedule(schedule: ReadingSchedule) -> ReadingSchedule:
|
||||
"""Confirm/finalize a reading schedule."""
|
||||
schedule.confirmed = True
|
||||
schedule.save(update_fields=["confirmed", "updated_at"])
|
||||
return schedule
|
||||
@@ -1,10 +1,11 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.books.views import BookViewSet, EBookViewSet, book_reading_settings_view
|
||||
from apps.books.views import BookViewSet, EBookViewSet, ScheduleViewSet, book_reading_settings_view
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"ebooks", EBookViewSet, basename="ebook")
|
||||
router.register(r"schedules", ScheduleViewSet, basename="schedule")
|
||||
router.register(r"", BookViewSet, basename="book")
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
+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