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
+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())