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
+23 -3
View File
@@ -1,10 +1,16 @@
from django.contrib import admin
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose
from tracker.models import (
DoseLog,
Medication,
PulseReading,
ReminderSent,
ScheduledDose,
)
class ScheduledDoseInline(admin.TabularInline):
"""Edit a medication's daily doses right on the medication page."""
"""Edit a medication's daily doses (and their reminder times) on the medication page."""
model = ScheduledDose
extra = 1
@@ -29,10 +35,24 @@ class MedicationAdmin(admin.ModelAdmin):
@admin.register(ScheduledDose)
class ScheduledDoseAdmin(admin.ModelAdmin):
list_display = ["medication", "time_of_day", "amount", "is_active"]
list_display = ["medication", "time_of_day", "amount", "reminder_time", "is_active"]
list_filter = ["is_active", "time_of_day", "medication"]
@admin.register(ReminderSent)
class ReminderSentAdmin(admin.ModelAdmin):
"""Read-only trail of which reminders went out, for when one seems to be missing."""
list_display = ["date", "scheduled_dose", "sent_at"]
list_filter = ["date"]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.register(DoseLog)
class DoseLogAdmin(admin.ModelAdmin):
"""The history. Day-to-day ticking off happens on the Today page."""
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}"))
@@ -0,0 +1,55 @@
# Generated by Django 6.0.7 on 2026-07-14 18:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tracker", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="scheduleddose",
name="reminder_time",
field=models.TimeField(
blank=True,
help_text="Send a push reminder at this time if the dose isn't ticked off. Leave empty for no reminder.",
null=True,
),
),
migrations.CreateModel(
name="ReminderSent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateField()),
("sent_at", models.DateTimeField(auto_now_add=True)),
(
"scheduled_dose",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reminders",
to="tracker.scheduleddose",
),
),
],
options={
"ordering": ["-sent_at"],
"constraints": [
models.UniqueConstraint(
fields=("scheduled_dose", "date"),
name="unique_reminder_per_day",
)
],
},
),
]
+30
View File
@@ -41,6 +41,12 @@ class ScheduledDose(models.Model):
amount = models.CharField(
max_length=20, default="1/4", help_text='Free text, e.g. "1/4", "1", "5 mg".'
)
reminder_time = models.TimeField(
null=True,
blank=True,
help_text="Send a push reminder at this time if the dose isn't ticked off. "
"Leave empty for no reminder.",
)
is_active = models.BooleanField(default=True)
class Meta:
@@ -92,6 +98,30 @@ class DoseLog(models.Model):
return super().save(*args, **kwargs)
class ReminderSent(models.Model):
"""One row per dose per day once its reminder has gone out.
This is what stops a reminder being re-sent every time the cron job ticks.
"""
scheduled_dose = models.ForeignKey(
ScheduledDose, on_delete=models.CASCADE, related_name="reminders"
)
date = models.DateField()
sent_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-sent_at"]
constraints = [
models.UniqueConstraint(
fields=["scheduled_dose", "date"], name="unique_reminder_per_day"
)
]
def __str__(self):
return f"{self.date}{self.scheduled_dose}"
class PulseReading(models.Model):
"""One heart-rate measurement per day, in beats per minute."""
+55
View File
@@ -0,0 +1,55 @@
import json
import logging
from django.conf import settings
from django.utils import timezone
from pywebpush import WebPushException, webpush
from accounts.models import PushSubscription
logger = logging.getLogger(__name__)
def push_is_configured() -> bool:
return bool(settings.VAPID_PUBLIC_KEY and settings.VAPID_PRIVATE_KEY)
def send_to_subscription(subscription: PushSubscription, payload: dict) -> bool:
"""Push one message. Returns whether it was delivered.
A 404 or 410 from the push service means the browser threw the subscription
away (app uninstalled, notifications revoked); the row is then useless, so we
delete it rather than retrying it forever.
"""
if not push_is_configured():
logger.warning("Push not configured: set VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY.")
return False
try:
webpush(
subscription_info=subscription.as_subscription_info(),
data=json.dumps(payload),
vapid_private_key=settings.VAPID_PRIVATE_KEY,
vapid_claims={"sub": f"mailto:{settings.VAPID_CLAIM_EMAIL}"},
timeout=10,
)
except WebPushException as exc:
status = getattr(exc.response, "status_code", None)
if status in (404, 410):
logger.info("Dropping expired push subscription %s", subscription.pk)
subscription.delete()
else:
logger.error("Push to %s failed: %s", subscription.pk, exc)
return False
subscription.last_success_at = timezone.now()
subscription.save(update_fields=["last_success_at"])
return True
def notify_user(user, payload: dict) -> int:
"""Push to every device the user has enabled. Returns the number delivered."""
return sum(
send_to_subscription(subscription, payload)
for subscription in user.push_subscriptions.all()
)
+79
View File
@@ -0,0 +1,79 @@
from django.conf import settings
from django.http import HttpRequest
from ninja import Router, Schema
from ninja.errors import HttpError
from accounts.auth import AUTH
from accounts.models import PushSubscription
from tracker.push import notify_user, push_is_configured
router = Router(auth=AUTH)
class SubscriptionKeys(Schema):
p256dh: str
auth: str
class SubscribeIn(Schema):
"""Exactly the shape of a browser PushSubscription, so the client can post it as-is."""
endpoint: str
keys: SubscriptionKeys
class UnsubscribeIn(Schema):
endpoint: str
@router.post("/subscribe")
def subscribe(request: HttpRequest, payload: SubscribeIn):
"""Register this device for reminders. Re-subscribing the same endpoint is a no-op."""
if not push_is_configured():
raise HttpError(503, "Push is not configured on the server.")
PushSubscription.objects.update_or_create(
endpoint=payload.endpoint,
defaults={
"user": request.user,
"p256dh": payload.keys.p256dh,
"auth": payload.keys.auth,
"user_agent": request.headers.get("User-Agent", "")[:300],
},
)
return {"detail": "Reminders enabled on this device."}
@router.post("/unsubscribe")
def unsubscribe(request: HttpRequest, payload: UnsubscribeIn):
PushSubscription.objects.filter(
user=request.user, endpoint=payload.endpoint
).delete()
return {"detail": "Reminders disabled on this device."}
@router.post("/test")
def send_test(request: HttpRequest):
"""Push a notification right now, to prove the chain works end to end."""
if not request.user.push_subscriptions.exists():
raise HttpError(400, "No device has enabled reminders yet.")
delivered = notify_user(
request.user,
{
"title": "Osiris — test",
"body": "Reminders are working. This is what a dose reminder looks like.",
"url": "/admin/today/",
"tag": "test",
},
)
if not delivered:
raise HttpError(502, "The push service rejected the message.")
return {"detail": f"Sent to {delivered} device(s)."}
@router.get("/public-key", auth=None)
def public_key(request: HttpRequest):
"""The applicationServerKey the browser needs in order to subscribe."""
return {"public_key": settings.VAPID_PUBLIC_KEY}
+197 -4
View File
@@ -1,13 +1,24 @@
import json
from datetime import timedelta
from datetime import datetime, timedelta
from io import StringIO
from unittest.mock import patch
from django.conf import settings
from django.test import TestCase
from django.core.management import call_command
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from pywebpush import WebPushException
from accounts.models import User
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose, TimeOfDay
from accounts.models import PushSubscription, User
from tracker.models import (
DoseLog,
Medication,
PulseReading,
ReminderSent,
ScheduledDose,
TimeOfDay,
)
class TrackerTestCase(TestCase):
@@ -265,3 +276,185 @@ class PwaTests(TrackerTestCase):
response = self.client.get("/admin/login/")
self.assertContains(response, 'rel="manifest"')
@override_settings(
VAPID_PUBLIC_KEY="BMn-test-public-key",
VAPID_PRIVATE_KEY="DiXsYTkxHVb68y2-9CGUZheAe7Iah9cLZ_vXc9DbJ5w",
)
class PushTests(TrackerTestCase):
def subscribe(self, endpoint="https://push.example/abc") -> PushSubscription:
return PushSubscription.objects.create(
user=self.user,
endpoint=endpoint,
p256dh="p256dh-key",
auth="auth-secret",
)
def test_a_device_can_subscribe_and_unsubscribe(self):
self.client.force_login(self.user)
body = {
"endpoint": "https://push.example/xyz",
"keys": {"p256dh": "key", "auth": "secret"},
}
response = self.client.post(
"/api/push/subscribe", body, content_type="application/json"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.push_subscriptions.count(), 1)
# Subscribing the same endpoint twice must not duplicate the device.
self.client.post("/api/push/subscribe", body, content_type="application/json")
self.assertEqual(self.user.push_subscriptions.count(), 1)
self.client.post(
"/api/push/unsubscribe",
{"endpoint": body["endpoint"]},
content_type="application/json",
)
self.assertEqual(self.user.push_subscriptions.count(), 0)
def test_the_test_button_needs_a_subscribed_device(self):
self.client.force_login(self.user)
response = self.client.post("/api/push/test")
self.assertEqual(response.status_code, 400)
@patch("tracker.push.webpush")
def test_the_test_button_pushes_to_every_device(self, webpush):
self.subscribe("https://push.example/phone")
self.subscribe("https://push.example/tablet")
self.client.force_login(self.user)
response = self.client.post("/api/push/test")
self.assertEqual(response.status_code, 200)
self.assertEqual(webpush.call_count, 2)
payload = json.loads(webpush.call_args.kwargs["data"])
self.assertIn("test", payload["title"])
@patch("tracker.push.webpush")
def test_a_dead_subscription_is_dropped_rather_than_retried(self, webpush):
subscription = self.subscribe()
response = type("R", (), {"status_code": 410})()
webpush.side_effect = WebPushException("gone", response=response)
self.client.force_login(self.user)
self.client.post("/api/push/test")
self.assertFalse(
PushSubscription.objects.filter(pk=subscription.pk).exists(),
"A 410 means the browser discarded the subscription; the row is dead.",
)
@override_settings(
VAPID_PUBLIC_KEY="BMn-test-public-key",
VAPID_PRIVATE_KEY="DiXsYTkxHVb68y2-9CGUZheAe7Iah9cLZ_vXc9DbJ5w",
)
class SendRemindersTests(TrackerTestCase):
def setUp(self):
super().setUp()
PushSubscription.objects.create(
user=self.user,
endpoint="https://push.example/phone",
p256dh="key",
auth="secret",
)
def run_command(self, at: str, **kwargs) -> str:
"""Run send_reminders as if the clock read `at` (local time, today)."""
hour, minute = (int(part) for part in at.split(":"))
now = timezone.make_aware(
datetime.combine(timezone.localdate(), datetime.min.time()).replace(
hour=hour, minute=minute
)
)
out = StringIO()
with patch("django.utils.timezone.localtime", return_value=now):
call_command("send_reminders", stdout=out, stderr=out, **kwargs)
return out.getvalue()
def set_reminder(self, dose: ScheduledDose, at: str):
hour, minute = (int(part) for part in at.split(":"))
dose.reminder_time = datetime.min.time().replace(hour=hour, minute=minute)
dose.save()
@patch("tracker.push.webpush")
def test_a_due_dose_is_reminded_once(self, webpush):
self.set_reminder(self.morning, "08:00")
self.run_command("08:05")
self.assertEqual(webpush.call_count, 1)
payload = json.loads(webpush.call_args.kwargs["data"])
self.assertIn("Fortekor 1/4", payload["body"])
self.assertEqual(ReminderSent.objects.count(), 1)
# A second run the same day must stay quiet.
self.run_command("08:30")
self.assertEqual(webpush.call_count, 1)
@patch("tracker.push.webpush")
def test_doses_due_at_the_same_time_share_one_notification(self, webpush):
self.set_reminder(self.morning, "08:00")
self.set_reminder(self.morning_only, "08:00")
self.run_command("08:01")
self.assertEqual(webpush.call_count, 1, "One push, not one per pill.")
payload = json.loads(webpush.call_args.kwargs["data"])
self.assertIn("Fortekor", payload["body"])
self.assertIn("Vetmedin", payload["body"])
self.assertEqual(ReminderSent.objects.count(), 2)
@patch("tracker.push.webpush")
def test_a_dose_already_given_is_not_reminded(self, webpush):
self.set_reminder(self.morning, "08:00")
DoseLog.objects.create(scheduled_dose=self.morning, taken=True)
self.run_command("08:05")
webpush.assert_not_called()
@patch("tracker.push.webpush")
def test_nothing_fires_before_the_reminder_time(self, webpush):
self.set_reminder(self.evening, "19:00")
self.run_command("18:59")
webpush.assert_not_called()
@patch("tracker.push.webpush")
def test_a_long_missed_reminder_is_not_fired_late(self, webpush):
# The server was down all morning; do not buzz at 23:00 about the 08:00 dose.
self.set_reminder(self.morning, "08:00")
self.run_command("23:00")
webpush.assert_not_called()
self.assertEqual(ReminderSent.objects.count(), 0)
@patch("tracker.push.webpush")
def test_dry_run_sends_and_records_nothing(self, webpush):
self.set_reminder(self.morning, "08:00")
output = self.run_command("08:05", dry_run=True)
webpush.assert_not_called()
self.assertEqual(ReminderSent.objects.count(), 0)
self.assertIn("dry-run", output)
@patch("tracker.push.webpush")
def test_a_failed_delivery_is_retried_on_the_next_run(self, webpush):
self.set_reminder(self.morning, "08:00")
webpush.side_effect = WebPushException("push service down")
self.run_command("08:05")
self.assertEqual(
ReminderSent.objects.count(), 0, "Nothing recorded, so it can retry."
)
webpush.side_effect = None
self.run_command("08:10")
self.assertEqual(ReminderSent.objects.count(), 1)
+3
View File
@@ -1,6 +1,7 @@
from datetime import timedelta
from typing import Optional
from django.conf import settings
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.shortcuts import redirect, render
@@ -103,5 +104,7 @@ def today(request):
"next_date": day + timedelta(days=1),
"chart": build_chart(readings),
"reading_count": len(readings),
# Empty when push is not configured; the page then hides the card.
"vapid_public_key": settings.VAPID_PUBLIC_KEY,
},
)