feat: pwa notifications
This commit is contained in:
@@ -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}"))
|
||||
Reference in New Issue
Block a user