Appointment reminders: email 15 minutes before the start

Python · Django · intermediate · greenfield

Adds the 15-minute reminder job as a management command, with the logic in `appointments/reminders.py`. Each run selects booked appointments whose `starts_at` falls in the window (now, now + 15 minutes] using aware datetimes from `timezone.now()`, then claims each row with a conditional `UPDATE` of `reminder_sent_at` before it sends, so an overlapping run skips rows another run already took. If the SMTP send fails, the claim is released and the failure is logged and counted; a later run that starts before the appointment picks the row up again. The email shows the start in the clinic's local time. No migration: the columns already exist.

Django 5.1 on PostgreSQL 16 (default READ COMMITTED), `USE_TZ = True`, `TIME_ZONE` is the clinic city's zone (Europe/Berlin), so local times need no zone label. Existing model `appointments.Appointment`: `user` (FK), `starts_at` (aware datetime), `status` (`Appointment.BOOKED` or `Appointment.CANCELLED`) and `reminder_sent_at` (nullable aware datetime; nothing reads it except as a sent/unsent flag). The existing reschedule code sets `starts_at` and clears `reminder_sent_at`; cancelling sets `status`. `appointments/management/commands/` already exists with other commands, and `appointments` is in `INSTALLED_APPS`. Cron mails any output on stderr to the on-call address. Every account has a verified, non-empty `email`; `EMAIL_TIMEOUT = 10` and `DEFAULT_FROM_EMAIL` are set. A window never holds more than a few hundred appointments.

Requirements

Files touched

--- appointments/reminders.py
+import logging
+import smtplib
+from datetime import timedelta
+
+from django.core.mail import send_mail
+from django.utils import timezone
+
+from .models import Appointment
+
+log = logging.getLogger(__name__)
+
+REMINDER_LEAD = timedelta(minutes=15)
+
+
+def send_due_reminders():
+    now = timezone.now()
+    due = Appointment.objects.filter(
+        status=Appointment.BOOKED,
+        reminder_sent_at__isnull=True,
+        starts_at__gt=now,
+        starts_at__lte=now + REMINDER_LEAD,
+    ).select_related("user")
+
+    sent = failed = 0
+    for appointment in due:
+        # Claim the row before sending, so an overlapping run skips it.
+        claimed = Appointment.objects.filter(
+            pk=appointment.pk,
+            status=Appointment.BOOKED,

Review this PR

Python practice