Archived
feat: implement per-member reading progress tracking for shared books (#30)
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
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user