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:
Marko
2026-06-20 19:31:21 +00:00
parent 26c5f6f06b
commit 8a86cf8388
20 changed files with 1571 additions and 0 deletions
View File
+23
View File
@@ -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"]
+7
View File
@@ -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')},
},
),
]
+138
View File
@@ -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",
]
)
+28
View File
@@ -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()
+183
View File
@@ -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())
+11
View File
@@ -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)),
]
+203
View File
@@ -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)
+1
View File
@@ -41,6 +41,7 @@ INSTALLED_APPS = [
"apps.books",
"apps.annotations",
"apps.reader",
"apps.groups",
]
MIDDLEWARE = [
+1
View File
@@ -8,6 +8,7 @@ urlpatterns = [
path("api/books/", include("apps.books.urls")),
path("api/annotations/", include("apps.annotations.urls")),
path("api/reader/", include("apps.reader.urls")),
path("api/groups/", include("apps.groups.urls")),
]
if settings.DEBUG: