Archived
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
350 lines
14 KiB
Python
350 lines
14 KiB
Python
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"])
|