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
138 lines
4.2 KiB
Python
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",
|
|
]
|
|
) |