feat(notifications): add scheduled email notification system
- NotificationSchedule + NotificationRecipient models with initial migration - emails.py BUILDERS registry + events_expiring_this_week HTML email builder (IST week bounds) - send_scheduled_notifications management command (croniter due-check + select_for_update(skip_locked)) - 6 admin API endpoints under /api/v1/notifications/ (types, schedules CRUD, recipients CRUD, send-now) - date_from/date_to filters on EventListView for dashboard card - croniter>=2.0.0 added to requirements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,16 @@
|
||||
"""
|
||||
Two distinct concerns live in this app:
|
||||
|
||||
1. ``Notification`` — consumer-facing in-app inbox entries surfaced on the mobile
|
||||
SPA (/api/notifications/list/). One row per user per alert.
|
||||
|
||||
2. ``NotificationSchedule`` + ``NotificationRecipient`` — admin-side recurring
|
||||
email jobs configured from the Command Center Settings tab and dispatched by
|
||||
the ``send_scheduled_notifications`` management command (host cron).
|
||||
Not user-facing; strictly operational.
|
||||
"""
|
||||
from django.db import models
|
||||
|
||||
from accounts.models import User
|
||||
|
||||
|
||||
@@ -23,3 +35,68 @@ class Notification(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.notification_type}: {self.title} → {self.user.email}"
|
||||
|
||||
|
||||
class NotificationSchedule(models.Model):
|
||||
"""One configurable recurring email job.
|
||||
|
||||
New types are added by registering a builder in ``notifications/emails.py``
|
||||
and adding the slug to ``TYPE_CHOICES`` below. Cron expression is evaluated
|
||||
in ``Asia/Kolkata`` by the dispatcher (matches operations team timezone).
|
||||
"""
|
||||
|
||||
TYPE_EVENTS_EXPIRING_THIS_WEEK = 'events_expiring_this_week'
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_EVENTS_EXPIRING_THIS_WEEK, 'Events Expiring This Week'),
|
||||
]
|
||||
|
||||
STATUS_SUCCESS = 'success'
|
||||
STATUS_ERROR = 'error'
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
notification_type = models.CharField(
|
||||
max_length=64, choices=TYPE_CHOICES, db_index=True,
|
||||
)
|
||||
cron_expression = models.CharField(
|
||||
max_length=100, default='0 0 * * 1',
|
||||
help_text='Standard 5-field cron (minute hour dom month dow). '
|
||||
'Evaluated in Asia/Kolkata.',
|
||||
)
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
last_run_at = models.DateTimeField(null=True, blank=True)
|
||||
last_status = models.CharField(max_length=20, blank=True, default='')
|
||||
last_error = models.TextField(blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [models.Index(fields=['is_active', 'notification_type'])]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.notification_type})'
|
||||
|
||||
|
||||
class NotificationRecipient(models.Model):
|
||||
"""Free-form recipient — not tied to a User row so external stakeholders
|
||||
(vendors, partners, sponsors) can receive notifications without needing
|
||||
platform accounts."""
|
||||
|
||||
schedule = models.ForeignKey(
|
||||
NotificationSchedule,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='recipients',
|
||||
)
|
||||
email = models.EmailField()
|
||||
display_name = models.CharField(max_length=200, blank=True, default='')
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [('schedule', 'email')]
|
||||
ordering = ['display_name', 'email']
|
||||
|
||||
def __str__(self):
|
||||
label = self.display_name or self.email
|
||||
return f'{label} ({self.schedule.name})'
|
||||
|
||||
Reference in New Issue
Block a user