182 lines
6.8 KiB
Python
182 lines
6.8 KiB
Python
|
|
"""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")} → {sunday.strftime("%A %d %b %Y")}
|
|||
|
|
· {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 › Settings › 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)
|