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:
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user