- 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.
402 lines
11 KiB
Markdown
402 lines
11 KiB
Markdown
# 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:
|
|
|
|
```python
|
|
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 `None` when skipped (so callers can check)
|
|
|
|
---
|
|
|
|
## Trigger Patterns
|
|
|
|
### 1. Signal-Based Triggers
|
|
|
|
The most common pattern — listen to Django signals and create notifications:
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
# 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):
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```yaml
|
|
# 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:
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
# 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`:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```python
|
|
# 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 `UserNotification` objects directly — use `notify_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
|
|
|
|
```python
|
|
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)
|
|
```
|