- Notification model with idempotency_key dedup (unique per event+object+recipient) - NotificationDelivery audit model (pending/sent/failed/delivered per channel) - notify() service: creates notification idempotently, enqueues per-channel tasks - deliver_notification_task Celery task: sends email via send_mail, marks in-app as sent without email; marks FAILED with error_detail on exception (autoretry x3) - Event trigger helpers: notify_course_assigned, notify_attempt_limit_reached, notify_certificate_issued, notify_certificate_expiring (daily-reminder safe) - send_course_due_reminders_task: periodic Celery Beat stub for due-date alerts - REST API: list notifications (with ?unread=true filter), mark-read, mark-all-read - Admin registrations with inline delivery audit view - Initial migration (Notification + NotificationDelivery tables) - Pytest test suite: idempotency, delivery dispatch, view auth/filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
62 lines
2.1 KiB
Python
62 lines
2.1 KiB
Python
from django.utils.timezone import now
|
|
from rest_framework import status
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from .models import Notification
|
|
|
|
|
|
class NotificationListView(APIView):
|
|
"""List the authenticated user's notifications, newest first."""
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
qs = Notification.objects.filter(recipient=request.user)
|
|
unread_only = request.query_params.get("unread") == "true"
|
|
if unread_only:
|
|
qs = qs.filter(read_at__isnull=True)
|
|
data = [
|
|
{
|
|
"id": str(n.id),
|
|
"event_type": n.event_type,
|
|
"title": n.title,
|
|
"body": n.body,
|
|
"object_type": n.object_type,
|
|
"object_id": n.object_id,
|
|
"is_read": n.is_read,
|
|
"created_at": n.created_at.isoformat(),
|
|
"read_at": n.read_at.isoformat() if n.read_at else None,
|
|
}
|
|
for n in qs[:100] # cap at 100 per page; pagination is a future concern
|
|
]
|
|
return Response(data)
|
|
|
|
|
|
class NotificationMarkReadView(APIView):
|
|
"""Mark a notification as read."""
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request, notification_id):
|
|
try:
|
|
n = Notification.objects.get(pk=notification_id, recipient=request.user)
|
|
except Notification.DoesNotExist:
|
|
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
if n.read_at is None:
|
|
n.read_at = now()
|
|
n.save(update_fields=["read_at"])
|
|
return Response({"id": str(n.id), "is_read": True})
|
|
|
|
|
|
class NotificationMarkAllReadView(APIView):
|
|
"""Mark all of the authenticated user's unread notifications as read."""
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request):
|
|
ts = now()
|
|
count = Notification.objects.filter(
|
|
recipient=request.user, read_at__isnull=True
|
|
).update(read_at=ts)
|
|
return Response({"marked_read": count})
|