This repository has been archived on 2026-07-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cloud-reader/backend/apps/books/views.py
Marko 4a7133465f 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
2026-06-20 19:31:39 +00:00

494 lines
21 KiB
Python

from __future__ import annotations
import logging
from typing import Any
from django.db.models import QuerySet, Q
from django.http import FileResponse
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import parsers, permissions, status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.filters import OrderingFilter, SearchFilter
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, MeetingSection, ReadingProgress, ReadingSchedule
from apps.books.serializers import (
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
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
from apps.books.services.metadata import enrich_ebook_metadata
logger = logging.getLogger(__name__)
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["author", "genre", "reading_status"]
search_fields = ["title", "author", "genre"]
ordering_fields = ["title", "author", "genre", "created_at"]
ordering = ["title"]
def get_serializer_class(self):
if self.action == "retrieve":
return BookDetailSerializer
if self.action == "list":
return BookListSerializer
return BookSerializer
def get_queryset(self) -> QuerySet[Book]:
qs = super().get_queryset()
query = self.request.query_params.get("q", "").strip()
if query:
qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query))
return qs
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
def genres(self, request: Request) -> Response:
genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre")
return Response([g for g in genre_list if g])
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
def authors(self, request: Request) -> Response:
author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author")
return Response([a for a in author_list if a])
@action(detail=False, methods=["get"])
def storage(self, request: Request) -> Response:
"""Return storage usage summary for the current user."""
download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook")
total_size = sum(r.file_size for r in download_records)
ebook_list = [
{"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size}
for r in download_records
]
serializer = StorageSummarySerializer(data={
"total_downloads": download_records.count(),
"total_size_bytes": total_size,
"ebooks": ebook_list,
})
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
class IsEBookOwner(permissions.BasePermission):
def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool:
return obj.user == request.user
class EBookViewSet(viewsets.ModelViewSet):
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
permission_classes = [IsAuthenticated, IsEBookOwner]
def get_serializer_class(self):
if self.action == "create":
return EBookUploadSerializer
if self.action in ("list",):
return EBookListSerializer
if self.action in ("toc",):
return BookChapterSerializer
return EBookDetailSerializer
def get_queryset(self):
qs = EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user")
query = self.request.query_params.get("q", "").strip()
if query and self.action == "list":
qs = qs.filter(
Q(title__icontains=query) | Q(author__icontains=query),
)
return qs
@action(detail=False, methods=["get"])
def genres(self, request: Request) -> Response:
genre_set: set[str] = set()
for ebook in self.get_queryset():
genre_set.update(subjects_from_ebook(ebook))
return Response(sorted(genre_set))
@action(detail=False, methods=["get"])
def authors(self, request: Request) -> Response:
author_list = (
self.get_queryset()
.values_list("author", flat=True)
.distinct()
.order_by("author")
)
return Response([a for a in author_list if a])
@action(detail=True, methods=["get", "patch"])
def progress(self, request: Request, pk: int | None = None) -> Response:
ebook = self.get_object()
progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook)
if request.method == "GET":
serializer = ReadingProgressSerializer(progress_obj)
return Response(serializer.data)
serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@action(detail=True, methods=["post"], url_path="enrich-metadata")
def enrich_metadata(self, request: Request, pk: int | None = None) -> Response:
"""Re-fetch Open Library metadata and cover for this ebook."""
ebook = self.get_object()
try:
enrich_ebook_metadata(ebook)
except Exception:
logger.exception("Manual metadata enrichment failed for ebook %s", ebook.id)
return Response(
{"error": "Metadata enrichment failed."},
status=status.HTTP_502_BAD_GATEWAY,
)
ebook.refresh_from_db()
serializer = EBookDetailSerializer(ebook, context={"request": request})
return Response(serializer.data)
@action(detail=True, methods=["post"])
def process(self, request: Request, pk: int | None = None) -> Response:
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
ebook = self.get_object()
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
try:
from apps.books.services.process_ebook import apply_processing_to_ebook
result = apply_processing_to_ebook(ebook)
return Response(result)
except Exception as exc:
logger.exception("Failed to process ebook %s", ebook.id)
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=["get"])
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
"""Stream the ebook file for authenticated in-browser reading."""
ebook = self.get_object()
content_types = {
"epub": "application/epub+zip",
"pdf": "application/pdf",
}
content_type = content_types.get(ebook.format)
if not content_type:
return Response(
{"error": "Unsupported format for in-browser reading."},
status=status.HTTP_400_BAD_REQUEST,
)
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
return FileResponse(
ebook.file.open("rb"),
content_type=content_type,
filename=ebook.filename(),
)
@action(detail=True, methods=["get"])
def toc(self, request: Request, pk: int | None = None) -> Response:
"""Return hierarchical table of contents."""
ebook = self.get_object()
chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
serializer = BookChapterSerializer(chapters, many=True)
return Response({
"chapters": serializer.data,
"format": ebook.format,
"page_count": ebook.page_count,
})
@action(detail=True, methods=["get"])
def content(self, request: Request, pk: int | None = None) -> Response:
"""Return paginated content for a given page number.
Query params:
page (int): page/chapter index to fetch (1-indexed, default: 1)
"""
ebook = self.get_object()
page = max(1, int(request.query_params.get("page", 1)))
chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook"))
total_pages = len(chapters) or ebook.page_count or 1
chapter: BookChapter | None = None
chapter_title = ""
content_html = ""
if chapters and 0 <= page - 1 < len(chapters):
ch = chapters[page - 1]
chapter_title = ch.title
content_html = _fetch_chapter_content(ebook, ch)
serializer = EBookContentSerializer(data={
"page": page,
"total_pages": total_pages,
"content": content_html,
"chapter_title": chapter_title,
"format": ebook.format,
})
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def download(self, request: Request, pk: int | None = None) -> Response:
"""Track download of an e-book. Creates a DownloadRecord and returns file info."""
ebook = self.get_object()
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
download, created = DownloadRecord.objects.get_or_create(
user=request.user,
ebook=ebook,
defaults={"file_size": ebook.file.size if ebook.file else 0},
)
if not created:
download.file_size = ebook.file.size if ebook.file else 0
download.save(update_fields=["file_size"])
serializer = DownloadRecordSerializer(download, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"])
def downloads(self, request: Request) -> Response:
"""List all e-books the current user has downloaded."""
records = DownloadRecord.objects.filter(user=request.user).select_related(
"ebook", "ebook__reading_progress"
).prefetch_related("ebook__chapters")
page = self.paginate_queryset(records)
if page is not None:
serializer = DownloadRecordSerializer(page, many=True, context={"request": request})
return self.get_paginated_response(serializer.data)
serializer = DownloadRecordSerializer(records, many=True, context={"request": request})
return Response(serializer.data)
@action(detail=False, methods=["delete"], url_path="downloads/(?P<download_pk>[^/.]+)")
def delete_download(self, request: Request, download_pk: str | None = None) -> Response:
"""Delete a download record."""
try:
download = DownloadRecord.objects.get(pk=download_pk, user=request.user)
except DownloadRecord.DoesNotExist:
return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND)
download.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
"""Fetch HTML content for a chapter from the e-book file."""
if ebook.format == "epub":
return _fetch_epub_chapter_content(ebook.file.path, chapter)
return ""
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
"""Extract HTML content of a specific EPUB chapter by href."""
try:
from ebooklib import epub
from bs4 import BeautifulSoup
except ImportError:
return ""
try:
book = epub.read_epub(file_path)
href = chapter.href or ""
# Find the item by href
for item in book.get_items():
item_name = item.get_name() or ""
if href and (item_name.endswith(href) or href.endswith(item_name)):
content = item.get_content()
soup = BeautifulSoup(content, "html.parser")
# Clean up — remove body/html/head wrappers, keep inner content
body = soup.find("body")
if body:
return str(body)
return str(soup)
return ""
except Exception:
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
return ""
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def book_reading_settings_view(request: Request) -> Response:
"""Get or update reading settings via the books API contract."""
settings, _created = ReadingSettings.objects.get_or_create(user=request.user)
if request.method == "GET":
serializer = BookReadingSettingsSerializer(settings)
return Response(serializer.data)
serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
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)