This repository has been archived on 2026-07-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cloud-reader/backend/apps/groups/models.py
T
Marko 8a86cf8388 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
2026-06-20 19:31:21 +00:00

138 lines
4.2 KiB
Python

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",
]
)