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
207 lines
7.1 KiB
Python
207 lines
7.1 KiB
Python
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) |