feat: pwa notifications

This commit is contained in:
2026-07-14 20:49:30 +02:00
parent 2cf7b283e0
commit 5198826f83
21 changed files with 1563 additions and 8 deletions
View File
@@ -0,0 +1,39 @@
import base64
from cryptography.hazmat.primitives import serialization
from django.core.management.base import BaseCommand
from py_vapid import Vapid01
def b64(raw: bytes) -> str:
"""base64url without padding — the encoding both the browser and py_vapid want."""
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
class Command(BaseCommand):
help = "Generate a VAPID key pair for Web Push. Run once, then keep the values."
def handle(self, *args, **options):
vapid = Vapid01()
vapid.generate_keys()
# The private scalar, raw: py_vapid's from_string() takes exactly this.
private = vapid.private_key.private_numbers().private_value.to_bytes(32, "big")
# The public point, uncompressed: what the browser wants as
# applicationServerKey when subscribing.
public = vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
self.stdout.write(
self.style.SUCCESS(
"Add these to the environment (keep the private key secret):\n"
)
)
self.stdout.write(f"VAPID_PUBLIC_KEY={b64(public)}")
self.stdout.write(f"VAPID_PRIVATE_KEY={b64(private)}")
self.stdout.write(
"\nChanging these later invalidates every existing subscription: "
"each device would have to re-enable reminders."
)
@@ -0,0 +1,116 @@
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}"))