Archived
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
220 lines
6.2 KiB
Python
220 lines
6.2 KiB
Python
"""Pace calculation service for reading progress vs meeting targets."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import TypedDict
|
|
|
|
from django.db.models import QuerySet
|
|
from django.utils import timezone
|
|
|
|
from apps.books.models import EBook, ReadingProgress
|
|
from apps.groups.models import (
|
|
GroupMeeting,
|
|
GroupMembership,
|
|
NotificationPreference,
|
|
PaceNotification,
|
|
NotificationStatus,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PaceStatus(TypedDict):
|
|
meeting_id: int
|
|
meeting_title: str
|
|
scheduled_at: str
|
|
target_section: int
|
|
target_section_label: str
|
|
current_section: int
|
|
ahead_behind_delta: int
|
|
status: str # "ahead", "behind", "on_track", "completed"
|
|
days_until_meeting: int
|
|
sections_to_catch_up: int
|
|
ebook_id: int
|
|
ebook_title: str
|
|
|
|
|
|
def get_relevant_ebooks_for_user(user) -> QuerySet[EBook]:
|
|
"""Return ebooks that belong to groups the user is a member of."""
|
|
group_ids = (
|
|
GroupMembership.objects.filter(user=user)
|
|
.values_list("group_id", flat=True)
|
|
)
|
|
return (
|
|
EBook.objects.filter(reading_groups__in=group_ids)
|
|
.select_related("reading_progress")
|
|
.prefetch_related("chapters")
|
|
.distinct()
|
|
)
|
|
|
|
|
|
def calculate_pace(
|
|
user,
|
|
ebook: EBook | None = None,
|
|
meeting: GroupMeeting | None = None,
|
|
) -> list[PaceStatus]:
|
|
"""Calculate reading pace for a user's upcoming meetings.
|
|
|
|
Compares the user's current reading progress (chapter index) against
|
|
the target section for each upcoming meeting within 3 days.
|
|
"""
|
|
now = timezone.now()
|
|
three_days_from_now = now + timedelta(days=3)
|
|
|
|
# Find upcoming meetings for groups the user belongs to
|
|
user_group_ids = (
|
|
GroupMembership.objects.filter(user=user)
|
|
.values_list("group_id", flat=True)
|
|
)
|
|
meetings_qs = GroupMeeting.objects.filter(
|
|
group_id__in=user_group_ids,
|
|
scheduled_at__gte=now,
|
|
scheduled_at__lte=three_days_from_now,
|
|
).select_related("group", "group__ebook")
|
|
|
|
if meeting:
|
|
meetings_qs = meetings_qs.filter(pk=meeting.pk)
|
|
|
|
meetings = list(meetings_qs)
|
|
|
|
results: list[PaceStatus] = []
|
|
|
|
for m in meetings:
|
|
group_ebook = m.group.ebook
|
|
ebooks_to_check: list[EBook] = []
|
|
|
|
if ebook:
|
|
ebooks_to_check = [ebook]
|
|
elif group_ebook:
|
|
ebooks_to_check = [group_ebook]
|
|
else:
|
|
# Check all ebooks in the group
|
|
ebooks_to_check = list(
|
|
EBook.objects.filter(reading_groups=m.group)
|
|
.select_related("reading_progress")
|
|
.prefetch_related("chapters")
|
|
)
|
|
|
|
for eb in ebooks_to_check:
|
|
pace = _compute_single_pace(user, m, eb, now)
|
|
if pace:
|
|
results.append(pace)
|
|
|
|
return results
|
|
|
|
|
|
def _compute_single_pace(
|
|
user,
|
|
meeting: GroupMeeting,
|
|
ebook: EBook,
|
|
now: datetime,
|
|
) -> PaceStatus | None:
|
|
"""Compute pace for one user + meeting + ebook combination."""
|
|
try:
|
|
progress = ebook.reading_progress
|
|
except ReadingProgress.DoesNotExist:
|
|
# No progress yet — user hasn't started reading
|
|
return None
|
|
|
|
# Current section: based on last_page (chapter index) or computed from progress %
|
|
if progress.last_page > 0:
|
|
current_section = progress.last_page
|
|
elif progress.current_position > 0:
|
|
chapter_count = ebook.chapters.count() or ebook.page_count or 1
|
|
current_section = max(1, int((progress.current_position / 100) * chapter_count))
|
|
else:
|
|
current_section = 0
|
|
|
|
target_section = meeting.target_section or 0
|
|
if target_section == 0:
|
|
return None
|
|
|
|
delta = current_section - target_section
|
|
days_until = max(0, (meeting.scheduled_at - now).days)
|
|
|
|
if current_section >= target_section:
|
|
if current_section >= target_section and progress.current_position > 0:
|
|
status = "completed"
|
|
else:
|
|
status = "ahead"
|
|
elif delta >= -1:
|
|
status = "on_track"
|
|
else:
|
|
status = "behind"
|
|
|
|
return PaceStatus(
|
|
meeting_id=meeting.pk,
|
|
meeting_title=meeting.title or meeting.target_section_label,
|
|
scheduled_at=meeting.scheduled_at.isoformat(),
|
|
target_section=target_section,
|
|
target_section_label=meeting.target_section_label,
|
|
current_section=current_section,
|
|
ahead_behind_delta=delta,
|
|
status=status,
|
|
days_until_meeting=days_until,
|
|
sections_to_catch_up=max(0, target_section - current_section),
|
|
ebook_id=ebook.pk,
|
|
ebook_title=ebook.title,
|
|
)
|
|
|
|
|
|
def should_send_notification(
|
|
user,
|
|
meeting: GroupMeeting,
|
|
ebook: EBook,
|
|
) -> bool:
|
|
"""Check if a pace notification should be sent based on user preferences."""
|
|
pref, _ = NotificationPreference.objects.get_or_create(user=user)
|
|
|
|
if not pref.pace_notifications_enabled:
|
|
return False
|
|
|
|
# Check if notification was already sent today
|
|
today = timezone.now().date()
|
|
existing = PaceNotification.objects.filter(
|
|
user=user,
|
|
meeting=meeting,
|
|
ebook=ebook,
|
|
created_at__date=today,
|
|
).exclude(status=NotificationStatus.DISMISSED)
|
|
|
|
if existing.exists():
|
|
return False
|
|
|
|
# Frequency check
|
|
if pref.frequency == "weekly":
|
|
week_ago = today - timedelta(days=7)
|
|
recent = PaceNotification.objects.filter(
|
|
user=user,
|
|
meeting=meeting,
|
|
ebook=ebook,
|
|
created_at__date__gte=week_ago,
|
|
).exclude(status=NotificationStatus.DISMISSED)
|
|
if recent.exists():
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def get_reminder_meetings(user) -> list[GroupMeeting]:
|
|
"""Get meetings that are within 24 hours for reminder notifications."""
|
|
now = timezone.now()
|
|
tomorrow = now + timedelta(days=1)
|
|
|
|
user_group_ids = (
|
|
GroupMembership.objects.filter(user=user)
|
|
.values_list("group_id", flat=True)
|
|
)
|
|
|
|
return list(
|
|
GroupMeeting.objects.filter(
|
|
group_id__in=user_group_ids,
|
|
scheduled_at__gte=now,
|
|
scheduled_at__lte=tomorrow,
|
|
)
|
|
.select_related("group", "group__ebook")
|
|
.order_by("scheduled_at")
|
|
)
|