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
Marko e80c2673ef feat: implement reading pace notifications (US #33)
Backend:
- New hermes app with ReadingGroup, GroupMeeting, GroupMembership models
- NotificationPreference (per-user: enable/disable, frequency daily/weekly)
- PaceNotification model for tracking sent/dismissed notifications
- Pace calculation service comparing current progress vs meeting targets
- API endpoints: pace status, notification preferences CRUD, reminders, dismiss
- Ahead/behind/on_track/completed status determination

Frontend:
- PaceNotification component for in-app alerts (behind/ahead/on_track statuses)
- ReadingPaceBanner — persistent banner in reader view
- Notification settings in Settings page (enable/disable, frequency, reminders)
- API client module (groupsApi) for all notification endpoints
2026-06-20 19:33:41 +00:00

191 lines
5.7 KiB
Python

from __future__ import annotations
from django.conf import settings
from django.db import models
class FrequencyChoice(models.TextChoices):
DAILY = "daily", "Daily"
WEEKLY = "weekly", "Weekly"
class MembershipRole(models.TextChoices):
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
class NotificationStatus(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
DISMISSED = "dismissed", "Dismissed"
class ReadingGroup(models.Model):
"""A group of users reading a book together with scheduled meetings."""
name = models.CharField(max_length=256, db_index=True)
description = models.TextField(blank=True, default="")
book = models.ForeignKey(
"books.Book",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="reading_groups",
)
ebook = models.ForeignKey(
"books.EBook",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="reading_groups",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="created_groups",
)
created_at = models.DateTimeField(auto_now_add=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
class GroupMembership(models.Model):
"""User membership in a reading group."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="group_memberships",
)
group = models.ForeignKey(
ReadingGroup,
on_delete=models.CASCADE,
related_name="memberships",
)
role = models.CharField(
max_length=16,
choices=MembershipRole.choices,
default=MembershipRole.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 = [("user", "group")]
indexes = [models.Index(fields=["user", "group"])]
def __str__(self) -> str:
return f"{self.user} in {self.group}"
class GroupMeeting(models.Model):
"""A scheduled meeting for a reading group with a target section."""
group = models.ForeignKey(
ReadingGroup,
on_delete=models.CASCADE,
related_name="meetings",
)
title = models.CharField(max_length=512, blank=True, default="")
scheduled_at = models.DateTimeField(db_index=True)
target_section = models.PositiveIntegerField(
default=0,
help_text="Target chapter/section index to reach by this meeting",
)
target_section_label = models.CharField(
max_length=512,
blank=True,
default="",
help_text="Human-readable label for the target section (e.g. 'Section 3')",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_meeting"
verbose_name = "Group Meeting"
verbose_name_plural = "Group Meetings"
ordering = ["scheduled_at"]
def __str__(self) -> str:
return f"{self.group.name}{self.title or self.target_section_label} ({self.scheduled_at.date()})"
class NotificationPreference(models.Model):
"""Per-user notification settings for reading pace alerts."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notification_preference",
primary_key=True,
)
pace_notifications_enabled = models.BooleanField(default=True)
reminder_enabled = models.BooleanField(default=True)
frequency = models.CharField(
max_length=16,
choices=FrequencyChoice.choices,
default=FrequencyChoice.DAILY,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "groups_notification_preference"
verbose_name = "Notification Preference"
verbose_name_plural = "Notification Preferences"
def __str__(self) -> str:
return f"{self.user} — pace={'on' if self.pace_notifications_enabled else 'off'}, freq={self.frequency}"
class PaceNotification(models.Model):
"""Tracks reading pace notifications sent to users to avoid duplicates."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="pace_notifications",
)
meeting = models.ForeignKey(
GroupMeeting,
on_delete=models.CASCADE,
related_name="pace_notifications",
)
ebook = models.ForeignKey(
"books.EBook",
on_delete=models.CASCADE,
related_name="pace_notifications",
null=True,
blank=True,
)
status = models.CharField(
max_length=16,
choices=NotificationStatus.choices,
default=NotificationStatus.PENDING,
)
current_section = models.PositiveIntegerField(default=0)
target_section = models.PositiveIntegerField(default=0)
ahead_behind_delta = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_pace_notification"
verbose_name = "Pace Notification"
verbose_name_plural = "Pace Notifications"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "meeting", "ebook"]),
]
def __str__(self) -> str:
return f"{self.user} — meeting {self.meeting_id}: {self.get_status_display()}"