Archived
Backend (apps/groups):
- ReadingGroup model: named groups linked to EBooks with creator tracking
- GroupMembership model: user-group association with member/admin roles
- MemberProgress model: per-member progress with section tracking,
percentage, time_spent, privacy toggles, and device-position preservation
- ReadingGroupViewSet: CRUD, join/leave, members list, progress endpoints
- GET /api/groups/{id}/members/progress/ — all members' progress
(public: section label only; own: full detail; private: stubbed)
- PATCH /api/groups/{id}/progress/ — update own progress with auto-
percentage calculation from section boundaries
- GET /api/groups/{id}/progress/summary/ — admin dashboard with
averages, started/finished counts, and per-member details
- Permissions: IsGroupMember, IsGroupAdmin
Frontend:
- Groups list page (/groups) with create modal and book selector
- Group detail page (/groups/:id) with progress and admin summary tabs
- Progress bar visualization per member with privacy-aware display
- Admin stat cards (total members, started, finished, avg progress, avg time)
- Navigation link from Library header
Shared:
- ReadingGroupSummary and MemberProgressPublic types
- API endpoint constants for groups routes
Closes #30
203 lines
8.4 KiB
Python
203 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
from django.db import models
|
|
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.groups.models import GroupMembership, MemberProgress, ReadingGroup
|
|
from apps.groups.permissions import IsGroupAdmin, IsGroupMember
|
|
from apps.groups.serializers import (
|
|
AdminProgressSummarySerializer,
|
|
GroupMembershipSerializer,
|
|
MemberProgressPublicSerializer,
|
|
MemberProgressSerializer,
|
|
MemberProgressUpdateSerializer,
|
|
ReadingGroupCreateSerializer,
|
|
ReadingGroupDetailSerializer,
|
|
ReadingGroupListSerializer,
|
|
)
|
|
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ReadingGroupViewSet(viewsets.ModelViewSet):
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return (
|
|
ReadingGroup.objects.filter(memberships__user=self.request.user)
|
|
.select_related("ebook", "created_by")
|
|
.prefetch_related("memberships", "memberships__progress")
|
|
.distinct()
|
|
)
|
|
|
|
def get_serializer_class(self):
|
|
if self.action == "create":
|
|
return ReadingGroupCreateSerializer
|
|
if self.action == "retrieve":
|
|
return ReadingGroupDetailSerializer
|
|
return ReadingGroupListSerializer
|
|
|
|
def perform_create(self, serializer):
|
|
group = serializer.save(created_by=self.request.user)
|
|
# Creator automatically becomes admin member
|
|
GroupMembership.objects.create(
|
|
group=group,
|
|
user=self.request.user,
|
|
role=GroupMembership.Role.ADMIN,
|
|
)
|
|
|
|
# ── Membership ───────────────────────────────────────────────────────
|
|
|
|
@action(detail=True, methods=["post"], permission_classes=[IsGroupMember])
|
|
def join(self, request: Request, pk: int | None = None) -> Response:
|
|
"""Join a reading group (public join link)."""
|
|
group = self.get_object()
|
|
membership, created = GroupMembership.objects.get_or_create(
|
|
group=group, user=request.user
|
|
)
|
|
if not created:
|
|
return Response({"detail": "Already a member."}, status=status.HTTP_200_OK)
|
|
MemberProgress.objects.get_or_create(
|
|
membership=membership,
|
|
group=group,
|
|
user=request.user,
|
|
defaults={"current_section": 0, "percentage": 0.0},
|
|
)
|
|
return Response({"detail": "Joined successfully."}, status=status.HTTP_201_CREATED)
|
|
|
|
@action(detail=True, methods=["post"], permission_classes=[IsGroupMember])
|
|
def leave(self, request: Request, pk: int | None = None) -> Response:
|
|
"""Leave a reading group."""
|
|
group = self.get_object()
|
|
if group.created_by == request.user:
|
|
return Response(
|
|
{"error": "Group creator cannot leave. Transfer ownership or delete the group."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
GroupMembership.objects.filter(group=group, user=request.user).delete()
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
@action(detail=True, methods=["get"], permission_classes=[IsGroupMember])
|
|
def members(self, request: Request, pk: int | None = None) -> Response:
|
|
"""List group members with their progress."""
|
|
group = self.get_object()
|
|
memberships = (
|
|
group.memberships.select_related("user")
|
|
.prefetch_related("progress")
|
|
.all()
|
|
)
|
|
serializer = GroupMembershipSerializer(memberships, many=True)
|
|
return Response(serializer.data)
|
|
|
|
# ── Progress ─────────────────────────────────────────────────────────
|
|
|
|
@action(detail=True, methods=["get"], permission_classes=[IsGroupMember],
|
|
url_path="members/progress")
|
|
def members_progress(self, request: Request, pk: int | None = None) -> Response:
|
|
"""Get progress for all group members.
|
|
|
|
Other members' progress shows section label only (no last_position).
|
|
Your own progress shows full detail.
|
|
"""
|
|
group = self.get_object()
|
|
progress_qs = (
|
|
MemberProgress.objects.filter(group=group)
|
|
.select_related("user")
|
|
.order_by("-percentage")
|
|
)
|
|
result: list[dict] = []
|
|
for mp in progress_qs:
|
|
if mp.user == request.user:
|
|
result.append(MemberProgressSerializer(mp).data)
|
|
elif mp.is_public:
|
|
result.append(MemberProgressPublicSerializer(mp).data)
|
|
else:
|
|
result.append({
|
|
"user_id": mp.user.id,
|
|
"user_email": mp.user.email,
|
|
"is_public": False,
|
|
"note": "Private",
|
|
})
|
|
return Response(result)
|
|
|
|
@action(detail=True, methods=["get", "patch"], permission_classes=[IsGroupMember],
|
|
url_path="progress")
|
|
def my_progress(self, request: Request, pk: int | None = None) -> Response:
|
|
"""Get or update my own progress in the group."""
|
|
group = self.get_object()
|
|
membership = GroupMembership.objects.get(group=group, user=request.user)
|
|
progress_obj, _created = MemberProgress.objects.get_or_create(
|
|
membership=membership,
|
|
group=group,
|
|
user=request.user,
|
|
)
|
|
|
|
if request.method == "GET":
|
|
serializer = MemberProgressSerializer(progress_obj)
|
|
return Response(serializer.data)
|
|
|
|
# PATCH — update progress
|
|
update_serializer = MemberProgressUpdateSerializer(data=request.data)
|
|
update_serializer.is_valid(raise_exception=True)
|
|
data = update_serializer.validated_data
|
|
|
|
progress_obj.update_progress(
|
|
current_section=data["current_section"],
|
|
percentage=data.get("percentage"),
|
|
time_spent_delta=data.get("time_spent_delta", 0),
|
|
last_position=data.get("last_position"),
|
|
)
|
|
if "is_public" in data:
|
|
progress_obj.is_public = data["is_public"]
|
|
progress_obj.save(update_fields=["is_public"])
|
|
|
|
serializer = MemberProgressSerializer(progress_obj)
|
|
return Response(serializer.data)
|
|
|
|
# ── Admin ────────────────────────────────────────────────────────────
|
|
|
|
@action(detail=True, methods=["get"], permission_classes=[IsGroupAdmin],
|
|
url_path="progress/summary")
|
|
def progress_summary(self, request: Request, pk: int | None = None) -> Response:
|
|
"""Admin summary of all member progress."""
|
|
group = self.get_object()
|
|
progress_qs = MemberProgress.objects.filter(group=group).select_related("user")
|
|
|
|
total_members = group.memberships.count()
|
|
members_started = progress_qs.filter(current_section__gt=0).count()
|
|
members_finished = progress_qs.filter(percentage__gte=100.0).count()
|
|
|
|
avg_pct = progress_qs.aggregate(avg=models.Avg("percentage"))["avg"] or 0.0
|
|
avg_time = progress_qs.aggregate(avg=models.Avg("time_spent_seconds"))["avg"] or 0.0
|
|
|
|
member_details: list[dict] = []
|
|
for mp in progress_qs:
|
|
member_details.append({
|
|
"user_id": mp.user.id,
|
|
"user_email": mp.user.email,
|
|
"current_section": mp.current_section,
|
|
"percentage": mp.percentage,
|
|
"time_spent_seconds": mp.time_spent_seconds,
|
|
"is_public": mp.is_public,
|
|
"updated_at": mp.updated_at,
|
|
})
|
|
|
|
summary_data = {
|
|
"group_id": group.id,
|
|
"group_name": group.name,
|
|
"ebook_title": group.ebook.title,
|
|
"total_members": total_members,
|
|
"members_started": members_started,
|
|
"members_finished": members_finished,
|
|
"average_percentage": round(avg_pct, 1),
|
|
"average_time_spent_hours": round(avg_time / 3600.0, 1) if avg_time else 0.0,
|
|
"member_details": member_details,
|
|
}
|
|
serializer = AdminProgressSummarySerializer(data=summary_data)
|
|
serializer.is_valid(raise_exception=True)
|
|
return Response(serializer.data) |