Archived
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
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.books.models import EBook
|
||||
from apps.groups.models import (
|
||||
GroupMeeting,
|
||||
GroupMembership,
|
||||
NotificationPreference,
|
||||
PaceNotification,
|
||||
NotificationStatus,
|
||||
ReadingGroup,
|
||||
)
|
||||
from apps.groups.serializers import (
|
||||
GroupMeetingSerializer,
|
||||
GroupMembershipSerializer,
|
||||
NotificationPreferenceSerializer,
|
||||
PaceNotificationSerializer,
|
||||
PaceStatusSerializer,
|
||||
ReadingGroupSerializer,
|
||||
ReminderSerializer,
|
||||
)
|
||||
from apps.groups.services.pace import (
|
||||
calculate_pace,
|
||||
get_reminder_meetings,
|
||||
should_send_notification,
|
||||
)
|
||||
|
||||
|
||||
class IsGroupMember(permissions.BasePermission):
|
||||
"""Only allow group members to access group resources."""
|
||||
|
||||
def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool:
|
||||
return obj.memberships.filter(user=request.user).exists()
|
||||
|
||||
|
||||
class ReadingGroupViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated, IsGroupMember]
|
||||
serializer_class = ReadingGroupSerializer
|
||||
|
||||
def get_queryset(self) -> QuerySet[ReadingGroup]:
|
||||
return (
|
||||
ReadingGroup.objects.filter(memberships__user=self.request.user)
|
||||
.prefetch_related("memberships", "meetings")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def perform_create(self, serializer) -> None:
|
||||
group = serializer.save(created_by=self.request.user)
|
||||
GroupMembership.objects.create(
|
||||
user=self.request.user,
|
||||
group=group,
|
||||
role="admin",
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="all")
|
||||
def all_groups(self, request: Request) -> Response:
|
||||
"""Return all groups (for discovery)."""
|
||||
qs = ReadingGroup.objects.all().prefetch_related("memberships", "meetings")
|
||||
page = self.paginate_queryset(qs)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
serializer = self.get_serializer(qs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class GroupMeetingViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = GroupMeetingSerializer
|
||||
|
||||
def get_queryset(self) -> QuerySet[GroupMeeting]:
|
||||
user_group_ids = (
|
||||
GroupMembership.objects.filter(user=self.request.user)
|
||||
.values_list("group_id", flat=True)
|
||||
)
|
||||
return GroupMeeting.objects.filter(group_id__in=user_group_ids).select_related(
|
||||
"group"
|
||||
)
|
||||
|
||||
def perform_create(self, serializer) -> None:
|
||||
group = serializer.validated_data["group"]
|
||||
if not group.memberships.filter(user=self.request.user).exists():
|
||||
raise permissions.PermissionDenied("You must be a group member to create meetings.")
|
||||
serializer.save()
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="upcoming")
|
||||
def upcoming(self, request: Request) -> Response:
|
||||
"""Return upcoming meetings for the user's groups (within 3 days)."""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
now = timezone.now()
|
||||
three_days = now + timedelta(days=3)
|
||||
qs = self.get_queryset().filter(
|
||||
scheduled_at__gte=now,
|
||||
scheduled_at__lte=three_days,
|
||||
)
|
||||
serializer = self.get_serializer(qs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@api_view(["GET", "PATCH"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def notification_preferences_view(request: Request) -> Response:
|
||||
"""Get or update notification preferences for the current user."""
|
||||
pref, _created = NotificationPreference.objects.get_or_create(user=request.user)
|
||||
|
||||
if request.method == "GET":
|
||||
serializer = NotificationPreferenceSerializer(pref)
|
||||
return Response(serializer.data)
|
||||
|
||||
serializer = NotificationPreferenceSerializer(pref, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def pace_status_view(request: Request) -> Response:
|
||||
"""Get reading pace status for the current user's upcoming meetings."""
|
||||
ebook_id = request.query_params.get("ebook_id")
|
||||
ebook: EBook | None = None
|
||||
|
||||
if ebook_id:
|
||||
try:
|
||||
ebook = EBook.objects.select_related("reading_progress").prefetch_related("chapters").get(
|
||||
pk=ebook_id, user=request.user
|
||||
)
|
||||
except EBook.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "EBook not found."}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
pace_data = calculate_pace(request.user, ebook=ebook)
|
||||
serializer = PaceStatusSerializer(pace_data, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def dismiss_pace_notification_view(request: Request) -> Response:
|
||||
"""Dismiss a pace notification so it won't show again."""
|
||||
meeting_id = request.data.get("meeting_id")
|
||||
ebook_id = request.data.get("ebook_id")
|
||||
|
||||
if not meeting_id or not ebook_id:
|
||||
return Response(
|
||||
{"error": "meeting_id and ebook_id are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
PaceNotification.objects.filter(
|
||||
user=request.user,
|
||||
meeting_id=meeting_id,
|
||||
ebook_id=ebook_id,
|
||||
status=NotificationStatus.PENDING,
|
||||
).update(status=NotificationStatus.DISMISSED)
|
||||
|
||||
return Response({"status": "dismissed"})
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def reminder_view(request: Request) -> Response:
|
||||
"""Get pre-meeting reminders for meetings within 24 hours."""
|
||||
pref, _ = NotificationPreference.objects.get_or_create(user=request.user)
|
||||
if not pref.reminder_enabled:
|
||||
return Response([])
|
||||
|
||||
meetings = get_reminder_meetings(request.user)
|
||||
now_dt = __import__("django").utils.timezone.now()
|
||||
|
||||
reminders = []
|
||||
for mtg in meetings:
|
||||
group_ebook = mtg.group.ebook
|
||||
ebook: EBook | None = group_ebook
|
||||
if not ebook:
|
||||
ebooks = list(
|
||||
EBook.objects.filter(reading_groups=mtg.group).select_related("reading_progress")[:1]
|
||||
)
|
||||
ebook = ebooks[0] if ebooks else None
|
||||
|
||||
if not ebook:
|
||||
continue
|
||||
|
||||
hours_until = max(0, int((mtg.scheduled_at - now_dt).total_seconds() / 3600))
|
||||
reminders.append({
|
||||
"meeting_id": mtg.pk,
|
||||
"meeting_title": mtg.title or mtg.target_section_label,
|
||||
"scheduled_at": mtg.scheduled_at.isoformat(),
|
||||
"target_section": mtg.target_section,
|
||||
"target_section_label": mtg.target_section_label,
|
||||
"group_name": mtg.group.name,
|
||||
"hours_until": hours_until,
|
||||
"ebook_id": ebook.pk,
|
||||
"ebook_title": ebook.title,
|
||||
})
|
||||
|
||||
serializer = ReminderSerializer(reminders, many=True)
|
||||
return Response(serializer.data)
|
||||
Reference in New Issue
Block a user