- 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>
80 lines
3.0 KiB
Python
80 lines
3.0 KiB
Python
import uuid
|
|
|
|
from django.conf import settings
|
|
from django.db import models
|
|
|
|
|
|
class NotificationEventType(models.TextChoices):
|
|
COURSE_ASSIGNED = "course_assigned", "Course Assigned"
|
|
COURSE_DUE = "course_due", "Course Due Soon"
|
|
ATTEMPT_LIMIT_REACHED = "attempt_limit_reached", "Attempt Limit Reached"
|
|
CERTIFICATE_ISSUED = "certificate_issued", "Certificate Issued"
|
|
CERTIFICATE_EXPIRING = "certificate_expiring", "Certificate Expiring"
|
|
|
|
|
|
class DeliveryChannel(models.TextChoices):
|
|
IN_APP = "in_app", "In-App"
|
|
EMAIL = "email", "Email"
|
|
|
|
|
|
class DeliveryStatus(models.TextChoices):
|
|
PENDING = "pending", "Pending"
|
|
SENT = "sent", "Sent"
|
|
FAILED = "failed", "Failed"
|
|
DELIVERED = "delivered", "Delivered"
|
|
|
|
|
|
class Notification(models.Model):
|
|
"""
|
|
In-app notification record. One row per (recipient, event, object).
|
|
idempotency_key prevents duplicate delivery for the same logical event.
|
|
"""
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
recipient = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="notifications",
|
|
)
|
|
event_type = models.CharField(max_length=40, choices=NotificationEventType.choices, db_index=True)
|
|
title = models.CharField(max_length=255)
|
|
body = models.TextField(blank=True)
|
|
object_type = models.CharField(max_length=64, blank=True)
|
|
object_id = models.CharField(max_length=128, blank=True)
|
|
# Caller-supplied dedup key; format: "<event_type>:<object_type>:<object_id>:<recipient_id>"
|
|
idempotency_key = models.CharField(max_length=255, unique=True, db_index=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
read_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
db_table = "notifications_notification"
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return f"{self.event_type} → {self.recipient_id}"
|
|
|
|
@property
|
|
def is_read(self):
|
|
return self.read_at is not None
|
|
|
|
|
|
class NotificationDelivery(models.Model):
|
|
"""Audit record for each delivery attempt per channel."""
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
notification = models.ForeignKey(
|
|
Notification, on_delete=models.CASCADE, related_name="deliveries"
|
|
)
|
|
channel = models.CharField(max_length=20, choices=DeliveryChannel.choices)
|
|
status = models.CharField(
|
|
max_length=20, choices=DeliveryStatus.choices, default=DeliveryStatus.PENDING
|
|
)
|
|
sent_at = models.DateTimeField(null=True, blank=True)
|
|
error_detail = models.TextField(blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = "notifications_delivery"
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return f"{self.channel}/{self.status} for notification {self.notification_id}"
|