Archived
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
183 lines
6.4 KiB
Python
183 lines
6.4 KiB
Python
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()) |