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"])