Files
training-software/notifications/services.py
Paperclip CTO 7e7e094fb7
Some checks failed
CI / lint (push) Successful in 6s
CI / test (push) Failing after 26s
CI / build-container (push) Has been skipped
fix(tests): unblock sqlite CI failures in mocks, helpers, and legacy cms route
2026-05-18 15:05:47 +02:00

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],
)