Files
training-software/notifications/tasks.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

90 lines
3.0 KiB
Python

from celery import shared_task
from django.utils.timezone import now
@shared_task(name="notifications.deliver_notification", bind=True,
autoretry_for=(Exception,), retry_kwargs={"max_retries": 3, "countdown": 60})
def deliver_notification_task(self, delivery_id: str):
"""
Dispatch a single NotificationDelivery via its channel.
Marks status SENT on success or FAILED on final exhaustion.
"""
from .models import DeliveryChannel, DeliveryStatus, NotificationDelivery
try:
delivery = NotificationDelivery.objects.select_related(
"notification__recipient"
).get(pk=delivery_id)
except NotificationDelivery.DoesNotExist:
return {"error": "delivery not found", "id": delivery_id}
try:
if delivery.channel == DeliveryChannel.EMAIL:
_send_email(delivery)
# In-app channel: the Notification row IS the in-app message; nothing more to send.
delivery.status = DeliveryStatus.SENT
delivery.sent_at = now()
delivery.save(update_fields=["status", "sent_at"])
return {"delivery_id": delivery_id, "status": "sent"}
except Exception as exc:
delivery.error_detail = str(exc)
delivery.status = DeliveryStatus.FAILED
delivery.save(update_fields=["status", "error_detail"])
raise # triggers autoretry
def _send_email(delivery):
from django.core.mail import send_mail
from django.conf import settings
n = delivery.notification
recipient_email = n.recipient.email
if not recipient_email:
return
send_mail(
subject=n.title,
message=n.body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", "no-reply@example.com"),
recipient_list=[recipient_email],
fail_silently=False,
)
@shared_task(name="notifications.send_course_due_reminders")
def send_course_due_reminders_task():
"""
Periodic task: notify learners of enrollments due within the configured
reminder window. Hook this into Celery Beat.
"""
from django.conf import settings
from django.utils.timezone import now
from datetime import timedelta
from tracking.models import Enrollment
from .services import notify
days_ahead = getattr(settings, "COURSE_DUE_REMINDER_DAYS", 7)
cutoff = now() + timedelta(days=days_ahead)
# Enrollments that have a due_at field (future optional extension) or use
# completed_at is None as a proxy for "not yet done". For now we fire
# reminders only for enrollments that have a due_at set on the course.
# Since due_at is not yet on Course, this is a safe no-op stub.
# Replace the queryset below once Course.due_at is added.
qs = Enrollment.objects.none()
count = 0
for enrollment in qs:
notify(
recipient=enrollment.user,
event_type="course_due",
title=f"Course due soon: {enrollment.course.title}",
body="",
object_type="enrollment",
object_id=str(enrollment.pk),
)
count += 1
return {"reminders_sent": count}