- 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>
121 lines
4.1 KiB
Python
121 lines
4.1 KiB
Python
"""
|
|
Notification service layer.
|
|
|
|
Public API:
|
|
notify(recipient, event_type, title, body, object_type, object_id, channels)
|
|
|
|
Returns the Notification instance (created or fetched if duplicate idempotency_key).
|
|
Enqueues delivery tasks for requested channels.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from django.db import IntegrityError
|
|
|
|
from .models import (
|
|
DeliveryChannel,
|
|
DeliveryStatus,
|
|
Notification,
|
|
NotificationDelivery,
|
|
NotificationEventType,
|
|
)
|
|
|
|
|
|
def _make_key(event_type: str, object_type: str, object_id: str, recipient_id) -> str:
|
|
return f"{event_type}:{object_type}:{object_id}:{recipient_id}"
|
|
|
|
|
|
def notify(
|
|
recipient,
|
|
event_type: str,
|
|
title: str,
|
|
body: str = "",
|
|
object_type: str = "",
|
|
object_id: str = "",
|
|
channels: list[str] | None = None,
|
|
) -> Notification:
|
|
"""
|
|
Create a Notification (idempotent) and enqueue delivery for each channel.
|
|
|
|
Channels defaults to [in_app, email]. Duplicate calls with the same
|
|
(event_type, object_type, object_id, recipient) are silently deduplicated
|
|
via idempotency_key.
|
|
"""
|
|
if channels is None:
|
|
channels = [DeliveryChannel.IN_APP, DeliveryChannel.EMAIL]
|
|
|
|
key = _make_key(event_type, object_type, object_id, recipient.pk)
|
|
|
|
try:
|
|
notification = Notification.objects.create(
|
|
recipient=recipient,
|
|
event_type=event_type,
|
|
title=title,
|
|
body=body,
|
|
object_type=object_type,
|
|
object_id=object_id,
|
|
idempotency_key=key,
|
|
)
|
|
except IntegrityError:
|
|
# Already delivered — return existing record, do not re-enqueue
|
|
return Notification.objects.get(idempotency_key=key)
|
|
|
|
from .tasks import deliver_notification_task
|
|
|
|
for channel in channels:
|
|
delivery = NotificationDelivery.objects.create(
|
|
notification=notification,
|
|
channel=channel,
|
|
status=DeliveryStatus.PENDING,
|
|
)
|
|
deliver_notification_task.delay(str(delivery.pk))
|
|
|
|
return notification
|
|
|
|
|
|
# ── Event trigger helpers ──────────────────────────────────────────────────────
|
|
|
|
def notify_course_assigned(user, enrollment) -> Notification:
|
|
return notify(
|
|
recipient=user,
|
|
event_type=NotificationEventType.COURSE_ASSIGNED,
|
|
title=f"You have been enrolled in "{enrollment.course.title}"",
|
|
body=f"You now have access to the course "{enrollment.course.title}". Complete it at your own pace.",
|
|
object_type="enrollment",
|
|
object_id=str(enrollment.pk),
|
|
)
|
|
|
|
|
|
def notify_attempt_limit_reached(user, quiz) -> Notification:
|
|
return notify(
|
|
recipient=user,
|
|
event_type=NotificationEventType.ATTEMPT_LIMIT_REACHED,
|
|
title=f"Attempt limit reached for "{quiz.title}"",
|
|
body=f"You have used all {quiz.max_attempts} allowed attempts for "{quiz.title}".",
|
|
object_type="quiz",
|
|
object_id=str(quiz.pk),
|
|
)
|
|
|
|
|
|
def notify_certificate_issued(user, certificate) -> Notification:
|
|
return notify(
|
|
recipient=user,
|
|
event_type=NotificationEventType.CERTIFICATE_ISSUED,
|
|
title="Your certificate is ready",
|
|
body=f"Your certificate for "{certificate.enrollment.course.title}" has been issued. Serial: {certificate.serial_number}.",
|
|
object_type="certificate",
|
|
object_id=str(certificate.pk),
|
|
)
|
|
|
|
|
|
def notify_certificate_expiring(user, certificate, days_remaining: int) -> Notification:
|
|
# Include days_remaining in object_id so each daily reminder gets its own idempotency key
|
|
return notify(
|
|
recipient=user,
|
|
event_type=NotificationEventType.CERTIFICATE_EXPIRING,
|
|
title=f"Certificate expiring in {days_remaining} day(s)",
|
|
body=f"Your certificate for "{certificate.enrollment.course.title}" expires in {days_remaining} day(s). Renew it before it lapses.",
|
|
object_type="certificate",
|
|
object_id=f"{certificate.pk}:d{days_remaining}",
|
|
channels=[DeliveryChannel.IN_APP, DeliveryChannel.EMAIL],
|
|
)
|