Add Themis application with custom widgets, views, and utilities

- 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.
This commit is contained in:
2026-03-21 02:00:18 +00:00
parent e99346d014
commit 99bdb4ac92
351 changed files with 65123 additions and 2 deletions

View File

@@ -0,0 +1,401 @@
# 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)
```