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,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.groups.models import (
|
||||
FrequencyChoice,
|
||||
GroupMeeting,
|
||||
GroupMembership,
|
||||
NotificationPreference,
|
||||
PaceNotification,
|
||||
ReadingGroup,
|
||||
)
|
||||
from apps.groups.services.pace import PaceStatus
|
||||
|
||||
|
||||
class NotificationPreferenceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NotificationPreference
|
||||
fields = [
|
||||
"pace_notifications_enabled",
|
||||
"reminder_enabled",
|
||||
"frequency",
|
||||
]
|
||||
|
||||
def validate_frequency(self, value: str) -> str:
|
||||
valid = {choice[0] for choice in FrequencyChoice.choices}
|
||||
if value not in valid:
|
||||
raise serializers.ValidationError(
|
||||
f"Frequency must be one of: {', '.join(valid)}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class GroupMeetingSerializer(serializers.ModelSerializer):
|
||||
group_name = serializers.CharField(source="group.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = GroupMeeting
|
||||
fields = [
|
||||
"id",
|
||||
"group",
|
||||
"group_name",
|
||||
"title",
|
||||
"scheduled_at",
|
||||
"target_section",
|
||||
"target_section_label",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
def validate_target_section(self, value: int) -> int:
|
||||
if value < 0:
|
||||
raise serializers.ValidationError("Target section must be non-negative.")
|
||||
return value
|
||||
|
||||
|
||||
class ReadingGroupSerializer(serializers.ModelSerializer):
|
||||
member_count = serializers.SerializerMethodField()
|
||||
meeting_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ReadingGroup
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"book",
|
||||
"ebook",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"member_count",
|
||||
"meeting_count",
|
||||
]
|
||||
read_only_fields = ["id", "created_by", "created_at"]
|
||||
|
||||
def get_member_count(self, obj: ReadingGroup) -> int:
|
||||
return obj.memberships.count()
|
||||
|
||||
def get_meeting_count(self, obj: ReadingGroup) -> int:
|
||||
return obj.meetings.count()
|
||||
|
||||
|
||||
class GroupMembershipSerializer(serializers.ModelSerializer):
|
||||
user_email = serializers.CharField(source="user.email", read_only=True)
|
||||
group_name = serializers.CharField(source="group.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = GroupMembership
|
||||
fields = ["id", "user", "user_email", "group", "group_name", "role", "joined_at"]
|
||||
read_only_fields = ["id", "joined_at"]
|
||||
|
||||
|
||||
class PaceNotificationSerializer(serializers.ModelSerializer):
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PaceNotification
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"meeting",
|
||||
"ebook",
|
||||
"status",
|
||||
"status_display",
|
||||
"current_section",
|
||||
"target_section",
|
||||
"ahead_behind_delta",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
|
||||
class PaceStatusSerializer(serializers.Serializer):
|
||||
meeting_id = serializers.IntegerField()
|
||||
meeting_title = serializers.CharField()
|
||||
scheduled_at = serializers.CharField()
|
||||
target_section = serializers.IntegerField()
|
||||
target_section_label = serializers.CharField()
|
||||
current_section = serializers.IntegerField()
|
||||
ahead_behind_delta = serializers.IntegerField()
|
||||
status = serializers.CharField()
|
||||
days_until_meeting = serializers.IntegerField()
|
||||
sections_to_catch_up = serializers.IntegerField()
|
||||
ebook_id = serializers.IntegerField()
|
||||
ebook_title = serializers.CharField()
|
||||
|
||||
|
||||
class ReminderSerializer(serializers.Serializer):
|
||||
meeting_id = serializers.IntegerField()
|
||||
meeting_title = serializers.CharField()
|
||||
scheduled_at = serializers.CharField()
|
||||
target_section = serializers.IntegerField()
|
||||
target_section_label = serializers.CharField()
|
||||
group_name = serializers.CharField()
|
||||
hours_until = serializers.IntegerField()
|
||||
ebook_id = serializers.IntegerField()
|
||||
ebook_title = serializers.CharField()
|
||||
Reference in New Issue
Block a user