Archived
feat: implement group EPUB upload and section splitting (US #29)
Backend: - Create hermes Django app with models: ReadingGroup, GroupBook, Section, ReadingSchedule, MemberProgress - EPUB section splitting service with automatic detection and reading time estimation - Section recommendation engine for 4-week meeting schedule - REST API endpoints for groups, books, sections, schedule, and member progress - Manual section adjustment (merge/split) support Frontend: - GroupsPage: list/create reading groups - GroupDetailPage: manage members, upload EPUB to group, view group books - GroupBookPage: section breakdown with merge/split controls, reading schedule, member progress - API client and TypeScript types for all group operations - i18n keys for English and Spanish Shared: - Group-related types and API endpoint constants in packages/shared
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.books.models import EBook
|
||||
from apps.groups.models import (
|
||||
GroupBook,
|
||||
MemberProgress,
|
||||
ReadingGroup,
|
||||
ReadingGroupMembership,
|
||||
ReadingSchedule,
|
||||
Section,
|
||||
)
|
||||
from apps.groups.serializers import (
|
||||
AddMemberSerializer,
|
||||
GroupBookCreateSerializer,
|
||||
GroupBookDetailSerializer,
|
||||
GroupBookListSerializer,
|
||||
MemberProgressSerializer,
|
||||
ReadingGroupCreateSerializer,
|
||||
ReadingGroupDetailSerializer,
|
||||
ReadingGroupListSerializer,
|
||||
ReadingScheduleSerializer,
|
||||
SectionAdjustSerializer,
|
||||
SectionSerializer,
|
||||
)
|
||||
from apps.groups.services.section_splitting import (
|
||||
apply_schedule,
|
||||
apply_sections,
|
||||
detect_sections,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IsGroupAdmin(permissions.BasePermission):
|
||||
"""Only the group admin can modify group resources."""
|
||||
|
||||
def has_permission(self, request: Request, view: object) -> bool:
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
if view.action in ("list", "retrieve"):
|
||||
return True
|
||||
group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk")
|
||||
if group_id:
|
||||
return ReadingGroup.objects.filter(id=group_id, admin=request.user).exists()
|
||||
return True
|
||||
|
||||
def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool:
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return obj.admin == request.user
|
||||
|
||||
|
||||
class IsGroupMember(permissions.BasePermission):
|
||||
"""Only group members (including admin) can view group content."""
|
||||
|
||||
def has_permission(self, request: Request, view: object) -> bool:
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk")
|
||||
if group_id:
|
||||
return ReadingGroupMembership.objects.filter(
|
||||
group_id=group_id, user=request.user
|
||||
).exists()
|
||||
return True
|
||||
|
||||
|
||||
class ReadingGroupViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated, IsGroupAdmin]
|
||||
queryset = ReadingGroup.objects.prefetch_related("memberships__user")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return ReadingGroupCreateSerializer
|
||||
if self.action == "retrieve":
|
||||
return ReadingGroupDetailSerializer
|
||||
return ReadingGroupListSerializer
|
||||
|
||||
def get_queryset(self) -> QuerySet[ReadingGroup]:
|
||||
user = self.request.user
|
||||
return ReadingGroup.objects.filter(
|
||||
memberships__user=user
|
||||
).prefetch_related("memberships__user").distinct()
|
||||
|
||||
def perform_create(self, serializer: ReadingGroupCreateSerializer) -> ReadingGroup:
|
||||
group = serializer.save(admin=self.request.user)
|
||||
ReadingGroupMembership.objects.create(
|
||||
user=self.request.user, group=group, role=ReadingGroupMembership.Role.ADMIN
|
||||
)
|
||||
return group
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin])
|
||||
def add_member(self, request: Request, pk: int | None = None) -> Response:
|
||||
group = self.get_object()
|
||||
member_serializer = AddMemberSerializer(data=request.data)
|
||||
member_serializer.is_valid(raise_exception=True)
|
||||
|
||||
user_id = member_serializer.validated_data["user_id"]
|
||||
from django.conf import settings
|
||||
User = settings.AUTH_USER_MODEL
|
||||
new_member = get_object_or_404(User, id=user_id)
|
||||
|
||||
if ReadingGroupMembership.objects.filter(group=group, user=new_member).exists():
|
||||
return Response(
|
||||
{"detail": "User is already a member of this group."},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
membership = ReadingGroupMembership.objects.create(
|
||||
user=new_member, group=group, role=ReadingGroupMembership.Role.MEMBER
|
||||
)
|
||||
from apps.groups.serializers import ReadingGroupMembershipSerializer
|
||||
return Response(
|
||||
ReadingGroupMembershipSerializer(membership).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin])
|
||||
def remove_member(self, request: Request, pk: int | None = None) -> Response:
|
||||
group = self.get_object()
|
||||
member_serializer = AddMemberSerializer(data=request.data)
|
||||
member_serializer.is_valid(raise_exception=True)
|
||||
|
||||
user_id = member_serializer.validated_data["user_id"]
|
||||
if user_id == group.admin_id:
|
||||
return Response(
|
||||
{"detail": "Cannot remove the group admin."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
deleted, _ = ReadingGroupMembership.objects.filter(
|
||||
group=group, user_id=user_id
|
||||
).delete()
|
||||
if not deleted:
|
||||
return Response(
|
||||
{"detail": "User is not a member of this group."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class GroupBookViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated, IsGroupAdmin]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return GroupBookCreateSerializer
|
||||
if self.action == "retrieve":
|
||||
return GroupBookDetailSerializer
|
||||
return GroupBookListSerializer
|
||||
|
||||
def get_queryset(self) -> QuerySet[GroupBook]:
|
||||
group_id = self.kwargs.get("group_pk")
|
||||
return GroupBook.objects.filter(
|
||||
group_id=group_id
|
||||
).select_related("ebook", "ebook__user", "uploaded_by").prefetch_related(
|
||||
"sections", "schedules"
|
||||
)
|
||||
|
||||
def perform_create(self, serializer: GroupBookCreateSerializer) -> GroupBook:
|
||||
group = get_object_or_404(ReadingGroup, id=self.kwargs["group_pk"])
|
||||
ebook = get_object_or_404(EBook, id=serializer.validated_data["ebook_id"])
|
||||
title = serializer.validated_data.get("title") or ebook.title
|
||||
|
||||
# Mark existing active books as replaced
|
||||
GroupBook.objects.filter(group=group, status=GroupBook.Status.ACTIVE).update(
|
||||
status=GroupBook.Status.REPLACED
|
||||
)
|
||||
|
||||
group_book = GroupBook.objects.create(
|
||||
group=group,
|
||||
ebook=ebook,
|
||||
uploaded_by=self.request.user,
|
||||
title=title,
|
||||
status=GroupBook.Status.ACTIVE,
|
||||
)
|
||||
|
||||
# Auto-detect sections
|
||||
try:
|
||||
apply_sections(group_book)
|
||||
except Exception:
|
||||
logger.exception("Auto-section detection failed for group_book %s", group_book.id)
|
||||
|
||||
# Auto-generate schedule
|
||||
try:
|
||||
apply_schedule(group_book)
|
||||
except Exception:
|
||||
logger.exception("Schedule generation failed for group_book %s", group_book.id)
|
||||
|
||||
return group_book
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="detect-sections")
|
||||
def detect_sections_action(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
|
||||
group_book = self.get_object()
|
||||
try:
|
||||
sections = apply_sections(group_book)
|
||||
except Exception as exc:
|
||||
logger.exception("Section detection failed for group_book %s", group_book.id)
|
||||
return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return Response(SectionSerializer(sections, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="adjust-sections")
|
||||
def adjust_sections(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
|
||||
group_book = self.get_object()
|
||||
adjust_serializer = SectionAdjustSerializer(data=request.data)
|
||||
adjust_serializer.is_valid(raise_exception=True)
|
||||
|
||||
operation = adjust_serializer.validated_data["operation"]
|
||||
section_ids: list[int] = adjust_serializer.validated_data["section_ids"]
|
||||
|
||||
if operation == "merge":
|
||||
if len(section_ids) < 2:
|
||||
return Response(
|
||||
{"error": "At least 2 section IDs required for merge."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
sections = list(
|
||||
Section.objects.filter(id__in=section_ids, group_book=group_book).order_by("order")
|
||||
)
|
||||
if len(sections) < 2:
|
||||
return Response(
|
||||
{"error": "Not enough valid sections found."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
merged_title = " — ".join(s.title for s in sections)
|
||||
merged_minutes = sum(s.estimated_reading_minutes for s in sections)
|
||||
merged_start = sections[0].start_chapter_index
|
||||
merged_end = sections[-1].end_chapter_index
|
||||
merged_order = sections[0].order
|
||||
|
||||
# Delete old sections
|
||||
Section.objects.filter(id__in=section_ids, group_book=group_book).delete()
|
||||
|
||||
# Create merged section
|
||||
merged = Section.objects.create(
|
||||
group_book=group_book,
|
||||
title=merged_title,
|
||||
order=merged_order,
|
||||
start_chapter_index=merged_start,
|
||||
end_chapter_index=merged_end,
|
||||
estimated_reading_minutes=merged_minutes,
|
||||
)
|
||||
# Reorder remaining sections
|
||||
_renumber_sections(group_book)
|
||||
return Response(SectionSerializer(merged).data)
|
||||
|
||||
if operation == "split":
|
||||
if len(section_ids) != 1:
|
||||
return Response(
|
||||
{"error": "Exactly 1 section ID required for split."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
split_at = adjust_serializer.validated_data.get("split_at", 2)
|
||||
section = get_object_or_404(Section, id=section_ids[0], group_book=group_book)
|
||||
chapters = section.end_chapter_index - section.start_chapter_index
|
||||
if chapters < split_at:
|
||||
return Response(
|
||||
{"error": f"Section only has {chapters} chapters, cannot split into {split_at}."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Delete original
|
||||
orig_order = section.order
|
||||
orig_title = section.title
|
||||
section.delete()
|
||||
|
||||
# Create split sections
|
||||
chunk_size = max(1, chapters // split_at)
|
||||
new_sections: list[Section] = []
|
||||
for i in range(split_at):
|
||||
start_idx = section.start_chapter_index + i * chunk_size
|
||||
end_idx = start_idx + chunk_size if i < split_at - 1 else section.end_chapter_index
|
||||
minutes_each = max(1, section.estimated_reading_minutes // split_at)
|
||||
new_sec = Section.objects.create(
|
||||
group_book=group_book,
|
||||
title=f"{orig_title} (Part {i + 1})",
|
||||
order=orig_order + i,
|
||||
start_chapter_index=start_idx,
|
||||
end_chapter_index=end_idx,
|
||||
estimated_reading_minutes=minutes_each,
|
||||
)
|
||||
new_sections.append(new_sec)
|
||||
|
||||
_renumber_sections(group_book)
|
||||
return Response(SectionSerializer(new_sections, many=True).data)
|
||||
|
||||
return Response({"error": "Invalid operation."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"], url_path="schedule")
|
||||
def schedule(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
|
||||
group_book = self.get_object()
|
||||
|
||||
if request.method == "GET":
|
||||
schedules = ReadingSchedule.objects.filter(group_book=group_book).order_by("meeting_number")
|
||||
return Response(ReadingScheduleSerializer(schedules, many=True).data)
|
||||
|
||||
if request.method == "POST":
|
||||
ReadingSchedule.objects.filter(group_book=group_book).delete()
|
||||
num_meetings = int(request.data.get("num_meetings", 4))
|
||||
try:
|
||||
schedules = apply_schedule(group_book, num_meetings=num_meetings)
|
||||
except Exception as exc:
|
||||
logger.exception("Schedule generation failed for group_book %s", group_book.id)
|
||||
return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return Response(
|
||||
ReadingScheduleSerializer(schedules, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
if request.method == "DELETE":
|
||||
ReadingSchedule.objects.filter(group_book=group_book).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["get", "patch"], url_path="progress")
|
||||
def progress(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
|
||||
group_book = self.get_object()
|
||||
|
||||
if request.method == "GET":
|
||||
progress_records = MemberProgress.objects.filter(
|
||||
group_book=group_book
|
||||
).select_related("user", "current_section")
|
||||
return Response(MemberProgressSerializer(progress_records, many=True).data)
|
||||
|
||||
# PATCH: update own progress
|
||||
progress_obj, _created = MemberProgress.objects.get_or_create(
|
||||
user=request.user, group_book=group_book,
|
||||
)
|
||||
serializer = MemberProgressSerializer(progress_obj, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
def _renumber_sections(group_book: GroupBook) -> None:
|
||||
"""Re-number sections sequentially by their current order."""
|
||||
sections = list(Section.objects.filter(group_book=group_book).order_by("order"))
|
||||
for idx, section in enumerate(sections):
|
||||
if section.order != idx + 1:
|
||||
section.order = idx + 1
|
||||
section.save(update_fields=["order"])
|
||||
Reference in New Issue
Block a user