Compare commits
2 Commits
ca24a4cb23
...
a8751b5183
| Author | SHA1 | Date | |
|---|---|---|---|
| a8751b5183 | |||
| 170208d3e5 |
@@ -81,6 +81,14 @@ urlpatterns = [
|
||||
path('rbac/org-tree/', views.OrgTreeView.as_view(), name='rbac-org-tree'),
|
||||
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
|
||||
path('ad-control/', include('ad_control.urls')),
|
||||
]
|
||||
@@ -759,6 +759,10 @@ class EventListView(APIView):
|
||||
qs = qs.filter(
|
||||
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:
|
||||
page = max(1, int(request.GET.get('page', 1)))
|
||||
page_size = min(100, int(request.GET.get('page_size', 20)))
|
||||
@@ -2517,3 +2521,265 @@ class LeadUpdateView(APIView):
|
||||
lead.save()
|
||||
log("info", f"Lead #{pk} updated: {', '.join(changed)}", request=request, user=request.user)
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -244,9 +244,9 @@ class EventListAPI(APIView):
|
||||
if pincode_qs.count() >= MIN_EVENTS_THRESHOLD:
|
||||
qs = pincode_qs
|
||||
|
||||
# Priority 3: Full-text search on title / description
|
||||
# Priority 3: Full-text search on title / name / description
|
||||
if q:
|
||||
qs = qs.filter(Q(title__icontains=q) | Q(description__icontains=q))
|
||||
qs = qs.filter(Q(title__icontains=q) | Q(name__icontains=q) | Q(description__icontains=q))
|
||||
|
||||
if per_type > 0 and page == 1:
|
||||
type_ids = list(qs.values_list('event_type_id', flat=True).distinct())
|
||||
|
||||
181
notifications/emails.py
Normal file
181
notifications/emails.py
Normal 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")} → {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)
|
||||
0
notifications/management/__init__.py
Normal file
0
notifications/management/__init__.py
Normal file
0
notifications/management/commands/__init__.py
Normal file
0
notifications/management/commands/__init__.py
Normal 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,
|
||||
})
|
||||
93
notifications/migrations/0001_initial.py
Normal file
93
notifications/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
notifications/migrations/__init__.py
Normal file
0
notifications/migrations/__init__.py
Normal 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 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})'
|
||||
|
||||
@@ -9,3 +9,5 @@ psycopg2-binary==2.9.9
|
||||
djangorestframework-simplejwt==5.3.1
|
||||
google-auth>=2.0.0
|
||||
requests>=2.28.0
|
||||
qrcode[pil]>=7.4.2
|
||||
croniter>=2.0.0
|
||||
|
||||
@@ -2,3 +2,6 @@ Django>=4.2
|
||||
Pillow
|
||||
django-summernote
|
||||
google-auth>=2.0.0
|
||||
requests>=2.31.0
|
||||
qrcode[pil]>=7.4.2
|
||||
croniter>=2.0.0
|
||||
|
||||
Reference in New Issue
Block a user