from collections import defaultdict from datetime import datetime, timedelta from django.core.management.base import BaseCommand from django.db import transaction from django.urls import reverse from django.utils import timezone from accounts.models import User from tracker.models import DoseLog, ReminderSent, ScheduledDose from tracker.push import notify_user, push_is_configured class Command(BaseCommand): help = ( "Push a reminder for any dose that is due and not yet ticked off. " "Meant to run from cron every few minutes." ) def add_arguments(self, parser): parser.add_argument( "--grace-minutes", type=int, default=120, help=( "Ignore reminders older than this. Stops a server that was asleep " "or down from firing a 08:00 reminder at 23:00 (default: 120)." ), ) parser.add_argument( "--dry-run", action="store_true", help="Report what would be sent without sending or recording anything.", ) def handle(self, *args, **options): if not push_is_configured(): self.stderr.write( self.style.ERROR( "VAPID keys are not set — run generate_vapid_keys first." ) ) return now = timezone.localtime() today = now.date() grace = timedelta(minutes=options["grace_minutes"]) already_taken = set( DoseLog.objects.filter(date=today, taken=True).values_list( "scheduled_dose_id", flat=True ) ) already_reminded = set( ReminderSent.objects.filter(date=today).values_list( "scheduled_dose_id", flat=True ) ) due = defaultdict(list) for dose in ScheduledDose.objects.filter( is_active=True, medication__is_active=True, reminder_time__isnull=False, ).select_related("medication"): if dose.id in already_taken or dose.id in already_reminded: continue due_at = timezone.make_aware(datetime.combine(today, dose.reminder_time)) if not (due_at <= now <= due_at + grace): continue due[dose.reminder_time].append(dose) if not due: self.stdout.write("Nothing due.") return recipients = User.objects.filter( is_active=True, push_subscriptions__isnull=False ).distinct() if not recipients: self.stdout.write("Doses are due, but no device has enabled reminders.") return for reminder_time, doses in sorted(due.items()): label = doses[0].get_time_of_day_display() body = ", ".join(f"{d.medication.name} {d.amount}" for d in doses) payload = { "title": f"Osiris — {label.lower()} medication", "body": body, "url": reverse("tracker:today"), # Same tag replaces rather than stacks a repeat notification. "tag": f"dose-{today.isoformat()}-{reminder_time}", } if options["dry_run"]: self.stdout.write(f"[dry-run] {reminder_time} → {payload['body']}") continue delivered = sum(notify_user(user, payload) for user in recipients) if delivered: with transaction.atomic(): ReminderSent.objects.bulk_create( [ ReminderSent(scheduled_dose=dose, date=today) for dose in doses ], ignore_conflicts=True, ) self.stdout.write( self.style.SUCCESS(f"Reminded ({body}) on {delivered} device(s).") ) else: # Nothing recorded, so the next run tries again. self.stderr.write(self.style.WARNING(f"Delivery failed for: {body}"))