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