Files
training-software/notifications/services.py
Paperclip CTO 6384eac890
Some checks failed
CI / Tests (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / OpenAPI Schema (push) Has been cancelled
feat(TRA-245): Notification service with email and in-app delivery
- 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>
2026-05-07 09:34:24 +02:00

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