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

80 lines
3.0 KiB
Python

import uuid
from django.conf import settings
from django.db import models
class NotificationEventType(models.TextChoices):
COURSE_ASSIGNED = "course_assigned", "Course Assigned"
COURSE_DUE = "course_due", "Course Due Soon"
ATTEMPT_LIMIT_REACHED = "attempt_limit_reached", "Attempt Limit Reached"
CERTIFICATE_ISSUED = "certificate_issued", "Certificate Issued"
CERTIFICATE_EXPIRING = "certificate_expiring", "Certificate Expiring"
class DeliveryChannel(models.TextChoices):
IN_APP = "in_app", "In-App"
EMAIL = "email", "Email"
class DeliveryStatus(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
FAILED = "failed", "Failed"
DELIVERED = "delivered", "Delivered"
class Notification(models.Model):
"""
In-app notification record. One row per (recipient, event, object).
idempotency_key prevents duplicate delivery for the same logical event.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notifications",
)
event_type = models.CharField(max_length=40, choices=NotificationEventType.choices, db_index=True)
title = models.CharField(max_length=255)
body = models.TextField(blank=True)
object_type = models.CharField(max_length=64, blank=True)
object_id = models.CharField(max_length=128, blank=True)
# Caller-supplied dedup key; format: "<event_type>:<object_type>:<object_id>:<recipient_id>"
idempotency_key = models.CharField(max_length=255, unique=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
read_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "notifications_notification"
ordering = ["-created_at"]
def __str__(self):
return f"{self.event_type}{self.recipient_id}"
@property
def is_read(self):
return self.read_at is not None
class NotificationDelivery(models.Model):
"""Audit record for each delivery attempt per channel."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
notification = models.ForeignKey(
Notification, on_delete=models.CASCADE, related_name="deliveries"
)
channel = models.CharField(max_length=20, choices=DeliveryChannel.choices)
status = models.CharField(
max_length=20, choices=DeliveryStatus.choices, default=DeliveryStatus.PENDING
)
sent_at = models.DateTimeField(null=True, blank=True)
error_detail = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "notifications_delivery"
ordering = ["-created_at"]
def __str__(self):
return f"{self.channel}/{self.status} for notification {self.notification_id}"