- Implemented custom form widgets for date, time, and datetime fields with DaisyUI styling. - Created utility functions for formatting dates, times, and numbers according to user preferences. - Developed views for profile settings, API key management, and notifications, including health check endpoints. - Added URL configurations for Themis tests and main application routes. - Established test cases for custom widgets to ensure proper functionality and integration. - Defined project metadata and dependencies in pyproject.toml for package management.
11 KiB
Notification Trigger Pattern v1.0.0
Standard pattern for triggering notifications from domain-specific events in Django applications that use Themis for notification infrastructure.
🐾 Red Panda Approval™
This pattern follows Red Panda Approval standards.
Overview
Themis provides the notification mailbox — the model, UI (bell + dropdown + list page), polling, browser notifications, user preferences, and cleanup. What Themis does not provide is the trigger logic — the rules that decide when a notification should be created.
Trigger logic is inherently domain-specific:
- A task tracker sends "Task overdue" notifications
- A calendar sends "Event starting in 15 minutes" reminders
- A finance app sends "Invoice payment received" alerts
- A monitoring system sends "Server CPU above 90%" warnings
This pattern documents how consuming apps should create notifications using Themis infrastructure.
The Standard Interface
All notification creation goes through one function:
from themis.notifications import notify_user
notify_user(
user=user, # Django User instance
title="Task overdue", # Short headline (max 200 chars)
message="Task 'Deploy v2' was due yesterday.", # Optional body
level="warning", # info | success | warning | danger
url="/tasks/42/", # Optional: where to navigate on click
source_app="tasks", # Your app label (for tracking/cleanup)
source_model="Task", # Model that triggered this
source_id="42", # PK of the source object (as string)
deduplicate=True, # Skip if unread duplicate exists
expires_at=None, # Optional: auto-expire datetime
)
Never create UserNotification objects directly. The notify_user() function handles:
- Checking if the user has notifications enabled
- Filtering by the user's minimum notification level
- Deduplication (when
deduplicate=True) - Returning
Nonewhen skipped (so callers can check)
Trigger Patterns
1. Signal-Based Triggers
The most common pattern — listen to Django signals and create notifications:
# myapp/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from themis.notifications import notify_user
from .models import Task
@receiver(post_save, sender=Task)
def notify_task_assigned(sender, instance, created, **kwargs):
"""Notify user when a task is assigned to them."""
if not created and instance.assignee and instance.tracker.has_changed("assignee"):
notify_user(
user=instance.assignee,
title=f"Task assigned: {instance.title}",
message=f"You've been assigned to '{instance.title}'",
level="info",
url=instance.get_absolute_url(),
source_app="tasks",
source_model="Task",
source_id=str(instance.pk),
deduplicate=True,
)
2. View-Based Triggers
Create notifications during request processing:
# myapp/views.py
from themis.notifications import notify_user
@login_required
def approve_request(request, pk):
req = get_object_or_404(Request, pk=pk)
req.status = "approved"
req.save()
# Notify the requester
notify_user(
user=req.requester,
title="Request approved",
message=f"Your request '{req.title}' has been approved.",
level="success",
url=req.get_absolute_url(),
source_app="requests",
source_model="Request",
source_id=str(req.pk),
)
messages.success(request, "Request approved.")
return redirect("request-list")
3. Management Command Triggers
For scheduled checks (e.g., daily overdue detection):
# myapp/management/commands/check_overdue.py
from django.core.management.base import BaseCommand
from django.utils import timezone
from themis.notifications import notify_user
from myapp.models import Task
class Command(BaseCommand):
help = "Send notifications for overdue tasks"
def handle(self, *args, **options):
overdue = Task.objects.filter(
due_date__lt=timezone.now().date(),
status__in=["open", "in_progress"],
)
count = 0
for task in overdue:
result = notify_user(
user=task.assignee,
title=f"Overdue: {task.title}",
message=f"Task was due {task.due_date}",
level="danger",
url=task.get_absolute_url(),
source_app="tasks",
source_model="Task",
source_id=str(task.pk),
deduplicate=True, # Don't send again if unread
)
if result:
count += 1
self.stdout.write(f"Sent {count} overdue notification(s)")
Schedule with cron or Kubernetes CronJob:
# Kubernetes CronJob
apiVersion: batch/v1
kind: CronJob
metadata:
name: check-overdue-tasks
spec:
schedule: "0 8 * * *" # Daily at 8 AM
jobTemplate:
spec:
template:
spec:
containers:
- name: check-overdue
command: ["python", "manage.py", "check_overdue"]
4. Celery Task Triggers
For apps with background workers:
# myapp/tasks.py
from celery import shared_task
from django.contrib.auth import get_user_model
from themis.notifications import notify_user
User = get_user_model()
@shared_task
def notify_report_ready(user_id, report_id):
"""Notify user when their report has been generated."""
from myapp.models import Report
user = User.objects.get(pk=user_id)
report = Report.objects.get(pk=report_id)
notify_user(
user=user,
title="Report ready",
message=f"Your {report.report_type} report is ready to download.",
level="success",
url=report.get_absolute_url(),
source_app="reports",
source_model="Report",
source_id=str(report.pk),
)
Notification Levels
Choose the appropriate level for each notification type:
| Level | Weight | Use For |
|---|---|---|
info |
0 | Informational updates (assigned, comment added) |
success |
0 | Positive outcomes (approved, completed, payment received) |
warning |
1 | Needs attention (approaching deadline, low balance) |
danger |
2 | Urgent/error (overdue, failed, system error) |
Users can set a minimum notification level in their preferences:
- info (default) — receive all notifications
- warning — only warnings and errors
- danger — only errors
Note that info and success have the same weight (0), so setting minimum to "warning" filters out both.
Source Tracking
The three source tracking fields enable two important features:
Deduplication
When deduplicate=True, notify_user() checks for existing unread notifications with the same source_app, source_model, and source_id. This prevents notification spam when the same event is checked multiple times (e.g., a daily cron job for overdue tasks).
Bulk Cleanup
When a source object is deleted, clean up its notifications:
# In your model's delete signal or post_delete:
from themis.models import UserNotification
@receiver(post_delete, sender=Task)
def cleanup_task_notifications(sender, instance, **kwargs):
UserNotification.objects.filter(
source_app="tasks",
source_model="Task",
source_id=str(instance.pk),
).delete()
Expiring Notifications
For time-sensitive notifications, use expires_at:
from datetime import timedelta
from django.utils import timezone
# Event reminder that expires when the event starts
notify_user(
user=attendee,
title=f"Starting soon: {event.title}",
level="info",
url=event.get_absolute_url(),
expires_at=event.start_time,
source_app="events",
source_model="Event",
source_id=str(event.pk),
deduplicate=True,
)
Expired notifications are automatically excluded from counts and lists. The cleanup_notifications management command deletes them permanently.
Multi-User Notifications
For events that affect multiple users, call notify_user() in a loop:
def notify_team(team, title, message, **kwargs):
"""Send a notification to all members of a team."""
for member in team.members.all():
notify_user(user=member, title=title, message=message, **kwargs)
For large recipient lists, consider using a Celery task to avoid blocking the request.
Notification Cleanup
Themis provides automatic cleanup via the management command:
# Uses THEMIS_NOTIFICATION_MAX_AGE_DAYS (default: 90)
python manage.py cleanup_notifications
# Override max age
python manage.py cleanup_notifications --max-age-days=60
What gets deleted:
- Read notifications older than the max age
- Dismissed notifications older than the max age
- Expired notifications (past their
expires_at)
What is preserved:
- Unread notifications (regardless of age)
Schedule this as a daily cron job or Kubernetes CronJob.
Settings
Themis recognizes these settings for notification behavior:
# Polling interval for the notification bell (seconds, 0 = disabled)
THEMIS_NOTIFICATION_POLL_INTERVAL = 60
# Hard ceiling for notification cleanup (days)
THEMIS_NOTIFICATION_MAX_AGE_DAYS = 90
Users control their own preferences in Settings:
- Enable notifications — master on/off switch
- Minimum level — filter low-priority notifications
- Browser desktop notifications — opt-in for OS-level alerts
- Retention days — how long to keep read notifications
Anti-Patterns
- ❌ Don't create
UserNotificationobjects directly — usenotify_user() - ❌ Don't send notifications in tight loops without
deduplicate=True - ❌ Don't use notifications for real-time chat — use WebSocket channels
- ❌ Don't store sensitive data in notification messages (they're visible in admin)
- ❌ Don't rely on notifications as the sole delivery mechanism — they may be disabled by the user
- ❌ Don't forget
source_app/source_model/source_id— they enable cleanup and dedup
Testing Notifications
from themis.notifications import notify_user
from themis.models import UserNotification
class MyAppNotificationTest(TestCase):
def test_task_overdue_notification(self):
"""Overdue task creates a danger notification."""
user = User.objects.create_user(username="test", password="pass")
task = Task.objects.create(
title="Deploy v2",
assignee=user,
due_date=date.today() - timedelta(days=1),
)
# Trigger your notification logic
check_overdue_tasks()
# Verify notification was created
notif = UserNotification.objects.get(
user=user,
source_app="tasks",
source_model="Task",
source_id=str(task.pk),
)
self.assertEqual(notif.level, "danger")
self.assertIn("Deploy v2", notif.title)
def test_disabled_user_gets_no_notification(self):
"""Users with notifications disabled get nothing."""
user = User.objects.create_user(username="quiet", password="pass")
user.profile.notifications_enabled = False
user.profile.save()
result = notify_user(user, "Should be skipped")
self.assertIsNone(result)
self.assertEqual(UserNotification.objects.count(), 0)