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