120 lines
4.0 KiB
Python
120 lines
4.0 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 .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)
|
|
|
|
notification, created = Notification.objects.get_or_create(
|
|
idempotency_key=key,
|
|
defaults={
|
|
"recipient": recipient,
|
|
"event_type": event_type,
|
|
"title": title,
|
|
"body": body,
|
|
"object_type": object_type,
|
|
"object_id": object_id,
|
|
},
|
|
)
|
|
if not created:
|
|
# Already delivered — return existing record, do not re-enqueue
|
|
return notification
|
|
|
|
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],
|
|
)
|