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,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())
|
||||
Reference in New Issue
Block a user