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:
2026-04-20 11:41:46 +05:30
parent 170208d3e5
commit a8751b5183
11 changed files with 783 additions and 0 deletions

View File

@@ -81,6 +81,14 @@ urlpatterns = [
path('rbac/org-tree/', views.OrgTreeView.as_view(), name='rbac-org-tree'), path('rbac/org-tree/', views.OrgTreeView.as_view(), name='rbac-org-tree'),
path('rbac/audit-log/', views.AuditLogListView.as_view(), name='rbac-audit-log'), path('rbac/audit-log/', views.AuditLogListView.as_view(), name='rbac-audit-log'),
# Notifications (admin-side recurring email jobs)
path('notifications/types/', views.NotificationTypesView.as_view(), name='notification-types'),
path('notifications/schedules/', views.NotificationScheduleListView.as_view(), name='notification-schedule-list'),
path('notifications/schedules/<int:pk>/', views.NotificationScheduleDetailView.as_view(), name='notification-schedule-detail'),
path('notifications/schedules/<int:pk>/recipients/', views.NotificationRecipientView.as_view(), name='notification-recipient-create'),
path('notifications/schedules/<int:pk>/recipients/<int:rid>/', views.NotificationRecipientDetailView.as_view(), name='notification-recipient-detail'),
path('notifications/schedules/<int:pk>/send-now/', views.NotificationScheduleSendNowView.as_view(), name='notification-schedule-send-now'),
# Ad Control # Ad Control
path('ad-control/', include('ad_control.urls')), path('ad-control/', include('ad_control.urls')),
] ]

View File

@@ -759,6 +759,10 @@ class EventListView(APIView):
qs = qs.filter( qs = qs.filter(
Q(title__icontains=q) | Q(name__icontains=q) | Q(venue_name__icontains=q) Q(title__icontains=q) | Q(name__icontains=q) | Q(venue_name__icontains=q)
) )
if date_from := request.GET.get('date_from'):
qs = qs.filter(start_date__gte=date_from)
if date_to := request.GET.get('date_to'):
qs = qs.filter(start_date__lte=date_to)
try: try:
page = max(1, int(request.GET.get('page', 1))) page = max(1, int(request.GET.get('page', 1)))
page_size = min(100, int(request.GET.get('page_size', 20))) page_size = min(100, int(request.GET.get('page_size', 20)))
@@ -2517,3 +2521,265 @@ class LeadUpdateView(APIView):
lead.save() lead.save()
log("info", f"Lead #{pk} updated: {', '.join(changed)}", request=request, user=request.user) log("info", f"Lead #{pk} updated: {', '.join(changed)}", request=request, user=request.user)
return Response(_serialize_lead(lead)) return Response(_serialize_lead(lead))
# ---------------------------------------------------------------------------
# Notification schedules (admin-side recurring email jobs)
# ---------------------------------------------------------------------------
def _serialize_recipient(r):
return {
'id': r.pk,
'email': r.email,
'displayName': r.display_name,
'isActive': r.is_active,
'createdAt': r.created_at.isoformat(),
}
def _serialize_schedule(s, include_recipients=True):
payload = {
'id': s.pk,
'name': s.name,
'notificationType': s.notification_type,
'cronExpression': s.cron_expression,
'isActive': s.is_active,
'lastRunAt': s.last_run_at.isoformat() if s.last_run_at else None,
'lastStatus': s.last_status,
'lastError': s.last_error,
'createdAt': s.created_at.isoformat(),
'updatedAt': s.updated_at.isoformat(),
}
if include_recipients:
payload['recipients'] = [
_serialize_recipient(r) for r in s.recipients.all()
]
payload['recipientCount'] = s.recipients.filter(is_active=True).count()
return payload
def _validate_cron(expr):
from croniter import croniter
if not expr or not isinstance(expr, str):
return False, 'cronExpression is required'
if not croniter.is_valid(expr.strip()):
return False, f'Invalid cron expression: {expr!r}'
return True, None
class NotificationTypesView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
from notifications.models import NotificationSchedule
return Response({
'types': [
{'value': v, 'label': l}
for v, l in NotificationSchedule.TYPE_CHOICES
],
})
class NotificationScheduleListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
from notifications.models import NotificationSchedule
qs = NotificationSchedule.objects.prefetch_related('recipients').order_by('-created_at')
return Response({
'count': qs.count(),
'results': [_serialize_schedule(s) for s in qs],
})
def post(self, request):
from notifications.models import NotificationSchedule, NotificationRecipient
from django.db import transaction as db_tx
name = (request.data.get('name') or '').strip()
ntype = (request.data.get('notificationType') or '').strip()
cron = (request.data.get('cronExpression') or '').strip()
is_active = bool(request.data.get('isActive', True))
recipients_in = request.data.get('recipients') or []
if not name:
return Response({'error': 'name is required'}, status=400)
if ntype not in dict(NotificationSchedule.TYPE_CHOICES):
return Response({'error': f'Invalid notificationType: {ntype!r}'}, status=400)
ok, err = _validate_cron(cron)
if not ok:
return Response({'error': err}, status=400)
with db_tx.atomic():
schedule = NotificationSchedule.objects.create(
name=name,
notification_type=ntype,
cron_expression=cron,
is_active=is_active,
)
seen_emails = set()
for r in recipients_in:
email = (r.get('email') or '').strip().lower()
if not email or email in seen_emails:
continue
seen_emails.add(email)
NotificationRecipient.objects.create(
schedule=schedule,
email=email,
display_name=(r.get('displayName') or '').strip(),
is_active=bool(r.get('isActive', True)),
)
log('info', f'Notification schedule #{schedule.pk} created: {schedule.name}',
request=request, user=request.user)
return Response(_serialize_schedule(schedule), status=201)
class NotificationScheduleDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, pk):
from notifications.models import NotificationSchedule
from django.shortcuts import get_object_or_404
s = get_object_or_404(
NotificationSchedule.objects.prefetch_related('recipients'), pk=pk,
)
return Response(_serialize_schedule(s))
def patch(self, request, pk):
from notifications.models import NotificationSchedule
from django.shortcuts import get_object_or_404
s = get_object_or_404(NotificationSchedule, pk=pk)
changed = []
if (name := request.data.get('name')) is not None:
name = str(name).strip()
if not name:
return Response({'error': 'name cannot be empty'}, status=400)
s.name = name
changed.append('name')
if (ntype := request.data.get('notificationType')) is not None:
if ntype not in dict(NotificationSchedule.TYPE_CHOICES):
return Response({'error': f'Invalid notificationType: {ntype!r}'}, status=400)
s.notification_type = ntype
changed.append('notification_type')
if (cron := request.data.get('cronExpression')) is not None:
ok, err = _validate_cron(cron)
if not ok:
return Response({'error': err}, status=400)
s.cron_expression = cron.strip()
changed.append('cron_expression')
if (is_active := request.data.get('isActive')) is not None:
s.is_active = bool(is_active)
changed.append('is_active')
s.save()
log('info', f'Notification schedule #{pk} updated: {", ".join(changed) or "no-op"}',
request=request, user=request.user)
return Response(_serialize_schedule(s))
def delete(self, request, pk):
from notifications.models import NotificationSchedule
from django.shortcuts import get_object_or_404
s = get_object_or_404(NotificationSchedule, pk=pk)
s.delete()
log('info', f'Notification schedule #{pk} deleted', request=request, user=request.user)
return Response(status=204)
class NotificationRecipientView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, pk):
from notifications.models import NotificationSchedule, NotificationRecipient
from django.shortcuts import get_object_or_404
schedule = get_object_or_404(NotificationSchedule, pk=pk)
email = (request.data.get('email') or '').strip().lower()
if not email:
return Response({'error': 'email is required'}, status=400)
if NotificationRecipient.objects.filter(schedule=schedule, email=email).exists():
return Response({'error': f'{email} is already a recipient'}, status=409)
r = NotificationRecipient.objects.create(
schedule=schedule,
email=email,
display_name=(request.data.get('displayName') or '').strip(),
is_active=bool(request.data.get('isActive', True)),
)
return Response(_serialize_recipient(r), status=201)
class NotificationRecipientDetailView(APIView):
permission_classes = [IsAuthenticated]
def patch(self, request, pk, rid):
from notifications.models import NotificationRecipient
from django.shortcuts import get_object_or_404
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
if (email := request.data.get('email')) is not None:
email = str(email).strip().lower()
if not email:
return Response({'error': 'email cannot be empty'}, status=400)
clash = NotificationRecipient.objects.filter(
schedule_id=pk, email=email,
).exclude(pk=rid).exists()
if clash:
return Response({'error': f'{email} is already a recipient'}, status=409)
r.email = email
if (display_name := request.data.get('displayName')) is not None:
r.display_name = str(display_name).strip()
if (is_active := request.data.get('isActive')) is not None:
r.is_active = bool(is_active)
r.save()
return Response(_serialize_recipient(r))
def delete(self, request, pk, rid):
from notifications.models import NotificationRecipient
from django.shortcuts import get_object_or_404
r = get_object_or_404(NotificationRecipient, pk=rid, schedule_id=pk)
r.delete()
return Response(status=204)
class NotificationScheduleSendNowView(APIView):
"""Trigger an immediate dispatch of one schedule, bypassing cron check."""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
from notifications.models import NotificationSchedule
from notifications.emails import render_and_send
from django.shortcuts import get_object_or_404
from django.utils import timezone as dj_tz
schedule = get_object_or_404(NotificationSchedule, pk=pk)
try:
recipient_count = render_and_send(schedule)
except Exception as exc: # noqa: BLE001
schedule.last_status = NotificationSchedule.STATUS_ERROR
schedule.last_error = str(exc)[:2000]
schedule.save(update_fields=['last_status', 'last_error', 'updated_at'])
log('error', f'Send-now failed for schedule #{pk}: {exc}',
request=request, user=request.user)
return Response({'error': str(exc)}, status=500)
schedule.last_run_at = dj_tz.now()
schedule.last_status = NotificationSchedule.STATUS_SUCCESS
schedule.last_error = ''
schedule.save(update_fields=[
'last_run_at', 'last_status', 'last_error', 'updated_at',
])
log('info', f'Send-now fired for schedule #{pk}{recipient_count} recipient(s)',
request=request, user=request.user)
return Response({
'ok': True,
'recipientCount': recipient_count,
'schedule': _serialize_schedule(schedule),
})

181
notifications/emails.py Normal file
View File

@@ -0,0 +1,181 @@
"""HTML email builders for scheduled admin notifications.
Each builder is registered in ``BUILDERS`` keyed by ``NotificationSchedule.notification_type``
and returns ``(subject, html_body)``. Add new types by appending to the registry
and extending ``NotificationSchedule.TYPE_CHOICES``.
Week bounds for ``events_expiring_this_week`` are computed in Asia/Kolkata so the
"this week" semantics match the operations team's wall-clock week regardless of
``settings.TIME_ZONE`` (currently UTC).
"""
from datetime import date, datetime, timedelta
from html import escape
try:
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover — fallback for py<3.9
from backports.zoneinfo import ZoneInfo # type: ignore
from django.conf import settings
from django.core.mail import EmailMessage
from django.db.models import Q
from eventify_logger.services import log
IST = ZoneInfo('Asia/Kolkata')
def _today_in_ist() -> date:
return datetime.now(IST).date()
def _upcoming_week_bounds(today: date) -> tuple[date, date]:
"""Return (next Monday, next Sunday) inclusive.
If today is Monday the result is *this week* (today..Sunday).
If today is any other weekday the result is *next week* (next Monday..next Sunday).
Mon=0 per Python ``weekday()``.
"""
days_until_monday = (7 - today.weekday()) % 7
monday = today + timedelta(days=days_until_monday)
sunday = monday + timedelta(days=6)
return monday, sunday
def _build_events_expiring_this_week(schedule) -> tuple[str, str]:
from events.models import Event
today = _today_in_ist()
monday, sunday = _upcoming_week_bounds(today)
qs = (
Event.objects
.select_related('partner', 'event_type')
.filter(event_status='published')
.filter(
Q(end_date__isnull=False, end_date__gte=monday, end_date__lte=sunday)
| Q(end_date__isnull=True, start_date__gte=monday, start_date__lte=sunday)
)
.order_by('end_date', 'start_date', 'name')
)
events = list(qs)
rows_html = ''
for e in events:
end = e.end_date or e.start_date
title = e.title or e.name or '(untitled)'
partner_name = ''
if e.partner_id:
try:
partner_name = e.partner.name or ''
except Exception:
partner_name = ''
category = ''
if e.event_type_id and e.event_type:
category = getattr(e.event_type, 'event_type', '') or ''
rows_html += (
'<tr>'
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">{escape(title)}</td>'
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">{escape(partner_name or "")}</td>'
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">{escape(category or "")}</td>'
f'<td style="padding:10px 12px;border-bottom:1px solid #eee;">'
f'{end.strftime("%a %d %b %Y") if end else ""}</td>'
'</tr>'
)
if not events:
rows_html = (
'<tr><td colspan="4" style="padding:24px;text-align:center;color:#888;">'
'No published events are expiring next week.'
'</td></tr>'
)
subject = (
f'[Eventify] {len(events)} event(s) expiring '
f'{monday.strftime("%d %b")}{sunday.strftime("%d %b")}'
)
html = f"""<!doctype html>
<html><body style="margin:0;padding:0;background:#f5f5f5;font-family:Arial,Helvetica,sans-serif;color:#1a1a1a;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f5f5;">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:10px;overflow:hidden;box-shadow:0 2px 6px rgba(15,69,207,0.08);">
<tr><td style="background:#0F45CF;color:#ffffff;padding:24px 28px;">
<h2 style="margin:0;font-size:20px;">Events expiring next week</h2>
<p style="margin:6px 0 0;color:#d2dcff;font-size:14px;">
{monday.strftime("%A %d %b %Y")} &rarr; {sunday.strftime("%A %d %b %Y")}
&middot; {len(events)} event(s)
</p>
</td></tr>
<tr><td style="padding:20px 24px;">
<p style="margin:0 0 12px;font-size:14px;color:#444;">
Scheduled notification: <strong>{escape(schedule.name)}</strong>
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:14px;">
<thead>
<tr style="background:#f0f4ff;color:#0F45CF;">
<th align="left" style="padding:10px 12px;">Title</th>
<th align="left" style="padding:10px 12px;">Partner</th>
<th align="left" style="padding:10px 12px;">Category</th>
<th align="left" style="padding:10px 12px;">End date</th>
</tr>
</thead>
<tbody>{rows_html}</tbody>
</table>
</td></tr>
<tr><td style="padding:16px 24px 24px;color:#888;font-size:12px;">
Sent automatically by Eventify Command Center.
To change recipients or the schedule, open
<a href="https://admin.eventifyplus.com/settings" style="color:#0F45CF;">admin.eventifyplus.com &rsaquo; Settings &rsaquo; Notifications</a>.
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
return subject, html
BUILDERS: dict = {
'events_expiring_this_week': _build_events_expiring_this_week,
}
def render_and_send(schedule) -> int:
"""Render the email for ``schedule`` and deliver it to active recipients.
Returns the number of recipients the message was sent to. Raises on SMTP
failure so the management command can mark the schedule as errored.
"""
builder = BUILDERS.get(schedule.notification_type)
if builder is None:
raise ValueError(f'No builder for notification type: {schedule.notification_type}')
subject, html = builder(schedule)
recipients = list(
schedule.recipients.filter(is_active=True).values_list('email', flat=True)
)
if not recipients:
log('warning', 'notification schedule has no active recipients', logger_data={
'schedule_id': schedule.id,
'schedule_name': schedule.name,
})
return 0
msg = EmailMessage(
subject=subject,
body=html,
from_email=settings.DEFAULT_FROM_EMAIL,
to=recipients,
)
msg.content_subtype = 'html'
msg.send(fail_silently=False)
log('info', 'notification email sent', logger_data={
'schedule_id': schedule.id,
'schedule_name': schedule.name,
'type': schedule.notification_type,
'recipient_count': len(recipients),
})
return len(recipients)

View File

View File

@@ -0,0 +1,153 @@
"""Dispatch due ``NotificationSchedule`` jobs.
Host cron invokes this every ~15 minutes via ``docker exec``. The command
walks all active schedules, evaluates their cron expression against
``last_run_at`` using ``croniter``, and fires any that are due. A row-level
``select_for_update(skip_locked=True)`` prevents duplicate sends if two cron
ticks race or the container is restarted mid-run.
Evaluation timezone is **Asia/Kolkata** to match
``notifications/emails.py::_upcoming_week_bounds`` — the same wall-clock week
used in the outgoing email body.
Flags:
--schedule-id <id> Fire exactly one schedule, ignoring cron check.
--dry-run Resolve due schedules + render emails, send nothing.
"""
from datetime import datetime, timedelta
try:
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover — py<3.9
from backports.zoneinfo import ZoneInfo # type: ignore
from croniter import croniter
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from eventify_logger.services import log
from notifications.emails import BUILDERS, render_and_send
from notifications.models import NotificationSchedule
IST = ZoneInfo('Asia/Kolkata')
def _is_due(schedule: NotificationSchedule, now_ist: datetime) -> bool:
"""Return True if ``schedule`` should fire at ``now_ist``.
``croniter`` is seeded with ``last_run_at`` (or one year ago for a fresh
schedule) and asked for the next fire time. If that time has already
passed relative to ``now_ist`` the schedule is due.
"""
if not croniter.is_valid(schedule.cron_expression):
return False
if schedule.last_run_at is not None:
seed = schedule.last_run_at.astimezone(IST)
else:
seed = now_ist - timedelta(days=365)
itr = croniter(schedule.cron_expression, seed)
next_fire = itr.get_next(datetime)
return next_fire <= now_ist
class Command(BaseCommand):
help = 'Dispatch due NotificationSchedule email jobs.'
def add_arguments(self, parser):
parser.add_argument(
'--schedule-id', type=int, default=None,
help='Force-run a single schedule by ID, ignoring cron check.',
)
parser.add_argument(
'--dry-run', action='store_true',
help='Render and log but do not send or persist last_run_at.',
)
def handle(self, *args, **opts):
schedule_id = opts.get('schedule_id')
dry_run = opts.get('dry_run', False)
now_ist = datetime.now(IST)
qs = NotificationSchedule.objects.filter(is_active=True)
if schedule_id is not None:
qs = qs.filter(id=schedule_id)
candidate_ids = list(qs.values_list('id', flat=True))
if not candidate_ids:
self.stdout.write('No active schedules to evaluate.')
return
fired = 0
skipped = 0
errored = 0
for sid in candidate_ids:
with transaction.atomic():
locked_qs = (
NotificationSchedule.objects
.select_for_update(skip_locked=True)
.filter(id=sid, is_active=True)
)
schedule = locked_qs.first()
if schedule is None:
skipped += 1
continue
forced = schedule_id is not None
if not forced and not _is_due(schedule, now_ist):
skipped += 1
continue
if schedule.notification_type not in BUILDERS:
schedule.last_status = NotificationSchedule.STATUS_ERROR
schedule.last_error = (
f'No builder registered for {schedule.notification_type!r}'
)
schedule.save(update_fields=['last_status', 'last_error', 'updated_at'])
errored += 1
continue
if dry_run:
self.stdout.write(
f'[dry-run] would fire schedule {schedule.id} '
f'({schedule.name}) type={schedule.notification_type}'
)
fired += 1
continue
try:
recipient_count = render_and_send(schedule)
except Exception as exc: # noqa: BLE001 — wide catch, store msg
log('error', 'notification dispatch failed', logger_data={
'schedule_id': schedule.id,
'schedule_name': schedule.name,
'error': str(exc),
})
schedule.last_status = NotificationSchedule.STATUS_ERROR
schedule.last_error = str(exc)[:2000]
schedule.save(update_fields=['last_status', 'last_error', 'updated_at'])
errored += 1
continue
schedule.last_run_at = timezone.now()
schedule.last_status = NotificationSchedule.STATUS_SUCCESS
schedule.last_error = ''
schedule.save(update_fields=[
'last_run_at', 'last_status', 'last_error', 'updated_at',
])
fired += 1
self.stdout.write(
f'Fired schedule {schedule.id} ({schedule.name}) '
f'{recipient_count} recipient(s)'
)
summary = f'Done. fired={fired} skipped={skipped} errored={errored}'
self.stdout.write(summary)
log('info', 'send_scheduled_notifications complete', logger_data={
'fired': fired, 'skipped': skipped, 'errored': errored,
'dry_run': dry_run, 'forced_id': schedule_id,
})

View File

@@ -0,0 +1,93 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('message', models.TextField()),
('notification_type', models.CharField(
choices=[
('event', 'Event'),
('promo', 'Promotion'),
('system', 'System'),
('booking', 'Booking'),
],
default='system', max_length=20,
)),
('is_read', models.BooleanField(default=False)),
('action_url', models.URLField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='notifications',
to=settings.AUTH_USER_MODEL,
)),
],
options={'ordering': ['-created_at']},
),
migrations.CreateModel(
name='NotificationSchedule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('notification_type', models.CharField(
choices=[('events_expiring_this_week', 'Events Expiring This Week')],
db_index=True, max_length=64,
)),
('cron_expression', models.CharField(
default='0 0 * * 1',
help_text=(
'Standard 5-field cron (minute hour dom month dow). '
'Evaluated in Asia/Kolkata.'
),
max_length=100,
)),
('is_active', models.BooleanField(db_index=True, default=True)),
('last_run_at', models.DateTimeField(blank=True, null=True)),
('last_status', models.CharField(blank=True, default='', max_length=20)),
('last_error', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={'ordering': ['-created_at']},
),
migrations.AddIndex(
model_name='notificationschedule',
index=models.Index(
fields=['is_active', 'notification_type'],
name='notificatio_is_acti_26dfb5_idx',
),
),
migrations.CreateModel(
name='NotificationRecipient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
('display_name', models.CharField(blank=True, default='', max_length=200)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('schedule', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='recipients',
to='notifications.notificationschedule',
)),
],
options={
'ordering': ['display_name', 'email'],
'unique_together': {('schedule', 'email')},
},
),
]

View File

View File

@@ -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 django.db import models
from accounts.models import User from accounts.models import User
@@ -23,3 +35,68 @@ class Notification(models.Model):
def __str__(self): def __str__(self):
return f"{self.notification_type}: {self.title}{self.user.email}" 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})'

View File

@@ -9,3 +9,5 @@ psycopg2-binary==2.9.9
djangorestframework-simplejwt==5.3.1 djangorestframework-simplejwt==5.3.1
google-auth>=2.0.0 google-auth>=2.0.0
requests>=2.28.0 requests>=2.28.0
qrcode[pil]>=7.4.2
croniter>=2.0.0

View File

@@ -2,3 +2,6 @@ Django>=4.2
Pillow Pillow
django-summernote django-summernote
google-auth>=2.0.0 google-auth>=2.0.0
requests>=2.31.0
qrcode[pil]>=7.4.2
croniter>=2.0.0