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,23 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.groups.models import GroupMembership, MemberProgress, ReadingGroup
|
||||
|
||||
|
||||
@admin.register(ReadingGroup)
|
||||
class ReadingGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "ebook", "created_by", "created_at"]
|
||||
search_fields = ["name", "ebook__title", "created_by__email"]
|
||||
|
||||
|
||||
@admin.register(GroupMembership)
|
||||
class GroupMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ["group", "user", "role", "joined_at"]
|
||||
list_filter = ["role"]
|
||||
search_fields = ["user__email", "group__name"]
|
||||
|
||||
|
||||
@admin.register(MemberProgress)
|
||||
class MemberProgressAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "group", "current_section", "percentage", "time_spent_seconds", "is_public", "updated_at"]
|
||||
list_filter = ["is_public"]
|
||||
search_fields = ["user__email", "group__name"]
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GroupsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.groups"
|
||||
verbose_name = "Reading Groups"
|
||||
@@ -0,0 +1,75 @@
|
||||
# Generated by Django 5.1.7 on 2026-06-20 19:21
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('books', '0003_readingprogress_epub_location'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReadingGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(db_index=True, max_length=256)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_groups', to=settings.AUTH_USER_MODEL)),
|
||||
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reading_groups', to='books.ebook')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Reading Group',
|
||||
'verbose_name_plural': 'Reading Groups',
|
||||
'db_table': 'groups_reading_group',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GroupMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('member', 'Member'), ('admin', 'Admin')], default='member', max_length=16)),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Group Membership',
|
||||
'verbose_name_plural': 'Group Memberships',
|
||||
'db_table': 'groups_membership',
|
||||
'ordering': ['joined_at'],
|
||||
'unique_together': {('group', 'user')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MemberProgress',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('current_section', models.PositiveIntegerField(default=0)),
|
||||
('percentage', models.FloatField(default=0.0)),
|
||||
('time_spent_seconds', models.PositiveIntegerField(default=0)),
|
||||
('last_position', models.JSONField(blank=True, default=dict)),
|
||||
('is_public', models.BooleanField(default=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('membership', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='groups.groupmembership')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to=settings.AUTH_USER_MODEL)),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to='groups.readinggroup')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Member Progress',
|
||||
'verbose_name_plural': 'Member Progress',
|
||||
'db_table': 'groups_member_progress',
|
||||
'indexes': [models.Index(fields=['group', '-percentage'], name='groups_memb_group_i_42e333_idx'), models.Index(fields=['group', 'user'], name='groups_memb_group_i_f50793_idx')],
|
||||
'unique_together': {('group', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ReadingGroup(models.Model):
|
||||
"""A group of users reading the same book together."""
|
||||
|
||||
name = models.CharField(max_length=256, db_index=True)
|
||||
ebook = models.ForeignKey(
|
||||
"books.EBook",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reading_groups",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="created_groups",
|
||||
)
|
||||
description = models.TextField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "groups_reading_group"
|
||||
verbose_name = "Reading Group"
|
||||
verbose_name_plural = "Reading Groups"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def total_sections(self) -> int:
|
||||
return self.ebook.chapters.count()
|
||||
|
||||
|
||||
class GroupMembership(models.Model):
|
||||
class Role(models.TextChoices):
|
||||
MEMBER = "member", "Member"
|
||||
ADMIN = "admin", "Admin"
|
||||
|
||||
group = models.ForeignKey(
|
||||
ReadingGroup,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="memberships",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="group_memberships",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=16,
|
||||
choices=Role.choices,
|
||||
default=Role.MEMBER,
|
||||
)
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "groups_membership"
|
||||
verbose_name = "Group Membership"
|
||||
verbose_name_plural = "Group Memberships"
|
||||
unique_together = [("group", "user")]
|
||||
ordering = ["joined_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} in {self.group.name} ({self.role})"
|
||||
|
||||
|
||||
class MemberProgress(models.Model):
|
||||
"""Per-member reading progress within a reading group."""
|
||||
|
||||
membership = models.OneToOneField(
|
||||
GroupMembership,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="progress",
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
ReadingGroup,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="member_progress",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="member_progress",
|
||||
)
|
||||
current_section = models.PositiveIntegerField(default=0)
|
||||
percentage = models.FloatField(default=0.0)
|
||||
time_spent_seconds = models.PositiveIntegerField(default=0)
|
||||
last_position = models.JSONField(blank=True, default=dict)
|
||||
is_public = models.BooleanField(default=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "groups_member_progress"
|
||||
verbose_name = "Member Progress"
|
||||
verbose_name_plural = "Member Progress"
|
||||
unique_together = [("group", "user")]
|
||||
indexes = [
|
||||
models.Index(fields=["group", "-percentage"]),
|
||||
models.Index(fields=["group", "user"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} — {self.group.name} ({self.percentage:.0f}%)"
|
||||
|
||||
def total_sections(self) -> int:
|
||||
return self.group.total_sections
|
||||
|
||||
def update_progress(
|
||||
self,
|
||||
current_section: int,
|
||||
percentage: float | None = None,
|
||||
time_spent_delta: int = 0,
|
||||
last_position: dict | None = None,
|
||||
) -> None:
|
||||
"""Update progress with computed percentage if not provided."""
|
||||
self.current_section = current_section
|
||||
if percentage is not None:
|
||||
self.percentage = min(100.0, max(0.0, percentage))
|
||||
elif (ts := self.total_sections()) > 0:
|
||||
self.percentage = min(100.0, (current_section / ts) * 100.0)
|
||||
else:
|
||||
self.percentage = 0.0
|
||||
if time_spent_delta > 0:
|
||||
self.time_spent_seconds += time_spent_delta
|
||||
if last_position is not None:
|
||||
self.last_position = last_position
|
||||
self.save(
|
||||
update_fields=[
|
||||
"current_section",
|
||||
"percentage",
|
||||
"time_spent_seconds",
|
||||
"last_position",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from rest_framework import permissions
|
||||
from rest_framework.request import Request
|
||||
|
||||
from apps.groups.models import GroupMembership
|
||||
|
||||
|
||||
class IsGroupMember(permissions.BasePermission):
|
||||
"""Allow access only to members of the group."""
|
||||
|
||||
def has_permission(self, request: Request, view: object) -> bool:
|
||||
group_id = view.kwargs.get("group_pk") or view.kwargs.get("pk")
|
||||
if not group_id:
|
||||
return False
|
||||
return GroupMembership.objects.filter(
|
||||
group_id=group_id, user=request.user
|
||||
).exists()
|
||||
|
||||
|
||||
class IsGroupAdmin(permissions.BasePermission):
|
||||
"""Allow access only to group admins."""
|
||||
|
||||
def has_permission(self, request: Request, view: object) -> bool:
|
||||
group_id = view.kwargs.get("group_pk") or view.kwargs.get("pk")
|
||||
if not group_id:
|
||||
return False
|
||||
return GroupMembership.objects.filter(
|
||||
group_id=group_id, user=request.user, role=GroupMembership.Role.ADMIN
|
||||
).exists()
|
||||
@@ -0,0 +1,183 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.groups.models import GroupMembership, MemberProgress, ReadingGroup
|
||||
|
||||
|
||||
class GroupMembershipSerializer(serializers.ModelSerializer):
|
||||
user_email = serializers.CharField(source="user.email", read_only=True)
|
||||
user_id = serializers.IntegerField(source="user.id", read_only=True)
|
||||
role = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = GroupMembership
|
||||
fields = ["id", "user_id", "user_email", "role", "joined_at"]
|
||||
|
||||
|
||||
class MemberProgressSerializer(serializers.ModelSerializer):
|
||||
user_email = serializers.CharField(source="user.email", read_only=True)
|
||||
user_id = serializers.IntegerField(source="user.id", read_only=True)
|
||||
total_sections = serializers.SerializerMethodField()
|
||||
section_label = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MemberProgress
|
||||
fields = [
|
||||
"id",
|
||||
"user_id",
|
||||
"user_email",
|
||||
"current_section",
|
||||
"total_sections",
|
||||
"section_label",
|
||||
"percentage",
|
||||
"time_spent_seconds",
|
||||
"last_position",
|
||||
"is_public",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "user_id", "user_email", "updated_at"]
|
||||
extra_kwargs = {
|
||||
"percentage": {"required": False, "min_value": 0.0, "max_value": 100.0},
|
||||
}
|
||||
|
||||
def get_total_sections(self, obj: MemberProgress) -> int:
|
||||
return obj.total_sections()
|
||||
|
||||
def get_section_label(self, obj: MemberProgress) -> str:
|
||||
ts = obj.total_sections()
|
||||
if ts:
|
||||
return f"Section {obj.current_section} of {ts}"
|
||||
return "No sections"
|
||||
|
||||
def validate_percentage(self, value: float) -> float:
|
||||
if value < 0.0 or value > 100.0:
|
||||
raise serializers.ValidationError("Percentage must be between 0.0 and 100.0.")
|
||||
return value
|
||||
|
||||
|
||||
class MemberProgressPublicSerializer(serializers.ModelSerializer):
|
||||
"""Limited view for other group members — section label only, no exact position."""
|
||||
|
||||
user_email = serializers.CharField(source="user.email", read_only=True)
|
||||
user_id = serializers.IntegerField(source="user.id", read_only=True)
|
||||
total_sections = serializers.SerializerMethodField()
|
||||
section_label = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MemberProgress
|
||||
fields = [
|
||||
"user_id",
|
||||
"user_email",
|
||||
"current_section",
|
||||
"total_sections",
|
||||
"section_label",
|
||||
"percentage",
|
||||
"time_spent_seconds",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_total_sections(self, obj: MemberProgress) -> int:
|
||||
return obj.total_sections()
|
||||
|
||||
def get_section_label(self, obj: MemberProgress) -> str:
|
||||
ts = obj.total_sections()
|
||||
if ts:
|
||||
return f"Section {obj.current_section} of {ts}"
|
||||
return "No sections"
|
||||
|
||||
|
||||
class MemberProgressUpdateSerializer(serializers.Serializer):
|
||||
current_section = serializers.IntegerField(min_value=0)
|
||||
percentage = serializers.FloatField(required=False, min_value=0.0, max_value=100.0)
|
||||
time_spent_delta = serializers.IntegerField(default=0, min_value=0)
|
||||
last_position = serializers.JSONField(required=False, default=dict)
|
||||
is_public = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
class ReadingGroupListSerializer(serializers.ModelSerializer):
|
||||
created_by_email = serializers.CharField(source="created_by.email", read_only=True)
|
||||
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
||||
ebook_author = serializers.CharField(source="ebook.author", read_only=True)
|
||||
member_count = serializers.SerializerMethodField()
|
||||
my_progress = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ReadingGroup
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"ebook",
|
||||
"ebook_title",
|
||||
"ebook_author",
|
||||
"created_by_email",
|
||||
"description",
|
||||
"member_count",
|
||||
"my_progress",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_member_count(self, obj: ReadingGroup) -> int:
|
||||
return obj.memberships.count()
|
||||
|
||||
def get_my_progress(self, obj: ReadingGroup) -> dict | None:
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
try:
|
||||
mp = MemberProgress.objects.get(group=obj, user=request.user)
|
||||
except MemberProgress.DoesNotExist:
|
||||
return None
|
||||
return {
|
||||
"current_section": mp.current_section,
|
||||
"percentage": mp.percentage,
|
||||
"time_spent_seconds": mp.time_spent_seconds,
|
||||
"section_label": MemberProgressSerializer().get_section_label(mp),
|
||||
}
|
||||
|
||||
|
||||
class ReadingGroupDetailSerializer(ReadingGroupListSerializer):
|
||||
members = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(ReadingGroupListSerializer.Meta):
|
||||
fields = ReadingGroupListSerializer.Meta.fields + ["members"]
|
||||
|
||||
def get_members(self, obj: ReadingGroup) -> list[dict]:
|
||||
memberships = obj.memberships.select_related("user").prefetch_related("progress")
|
||||
result: list[dict] = []
|
||||
for m in memberships:
|
||||
entry: dict = {
|
||||
"id": m.id,
|
||||
"user_id": m.user.id,
|
||||
"user_email": m.user.email,
|
||||
"role": m.role,
|
||||
"joined_at": m.joined_at,
|
||||
}
|
||||
try:
|
||||
mp = m.progress
|
||||
except MemberProgress.DoesNotExist:
|
||||
entry["progress"] = None
|
||||
else:
|
||||
if mp.is_public:
|
||||
entry["progress"] = MemberProgressPublicSerializer(mp).data
|
||||
else:
|
||||
entry["progress"] = {"is_public": False, "note": "Private"}
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
class ReadingGroupCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ReadingGroup
|
||||
fields = ["name", "ebook", "description"]
|
||||
|
||||
|
||||
class AdminProgressSummarySerializer(serializers.Serializer):
|
||||
group_id = serializers.IntegerField()
|
||||
group_name = serializers.CharField()
|
||||
ebook_title = serializers.CharField()
|
||||
total_members = serializers.IntegerField()
|
||||
members_started = serializers.IntegerField()
|
||||
members_finished = serializers.IntegerField()
|
||||
average_percentage = serializers.FloatField()
|
||||
average_time_spent_hours = serializers.FloatField()
|
||||
member_details = serializers.ListField(child=serializers.JSONField())
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.groups.views import ReadingGroupViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"", ReadingGroupViewSet, basename="reading-group")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -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