diff --git a/admin_api/urls.py b/admin_api/urls.py index c2b815e..8e89a7e 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -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//', views.NotificationScheduleDetailView.as_view(), name='notification-schedule-detail'), + path('notifications/schedules//recipients/', views.NotificationRecipientView.as_view(), name='notification-recipient-create'), + path('notifications/schedules//recipients//', views.NotificationRecipientDetailView.as_view(), name='notification-recipient-detail'), + path('notifications/schedules//send-now/', views.NotificationScheduleSendNowView.as_view(), name='notification-schedule-send-now'), + # Ad Control path('ad-control/', include('ad_control.urls')), ] \ No newline at end of file diff --git a/admin_api/views.py b/admin_api/views.py index 6b68613..471afa1 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -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), + }) diff --git a/notifications/emails.py b/notifications/emails.py new file mode 100644 index 0000000..939ff24 --- /dev/null +++ b/notifications/emails.py @@ -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 += ( + '' + f'{escape(title)}' + f'{escape(partner_name or "—")}' + f'{escape(category or "—")}' + f'' + f'{end.strftime("%a %d %b %Y") if end else "—"}' + '' + ) + + if not events: + rows_html = ( + '' + 'No published events are expiring next week.' + '' + ) + + subject = ( + f'[Eventify] {len(events)} event(s) expiring ' + f'{monday.strftime("%d %b")}–{sunday.strftime("%d %b")}' + ) + + html = f""" + + + +
+ + + + +
+

Events expiring next week

+

+ {monday.strftime("%A %d %b %Y")} → {sunday.strftime("%A %d %b %Y")} + · {len(events)} event(s) +

+
+

+ Scheduled notification: {escape(schedule.name)} +

+ + + + + + + + + + {rows_html} +
TitlePartnerCategoryEnd date
+
+ Sent automatically by Eventify Command Center. + To change recipients or the schedule, open + admin.eventifyplus.com › Settings › Notifications. +
+
+""" + + 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) diff --git a/notifications/management/__init__.py b/notifications/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/management/commands/__init__.py b/notifications/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/management/commands/send_scheduled_notifications.py b/notifications/management/commands/send_scheduled_notifications.py new file mode 100644 index 0000000..b7aaf17 --- /dev/null +++ b/notifications/management/commands/send_scheduled_notifications.py @@ -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 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, + }) diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..582a652 --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/models.py b/notifications/models.py index af582a9..b725bd7 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -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})' diff --git a/requirements-docker.txt b/requirements-docker.txt index e07877b..be05bbe 100644 --- a/requirements-docker.txt +++ b/requirements-docker.txt @@ -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 diff --git a/requirements.txt b/requirements.txt index c088acc..ab8232f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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