Files
eventify_backend/notifications/emails.py
Sicherhaven a8751b5183 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>
2026-04-20 11:41:46 +05:30

182 lines
6.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)