feat: pwa notifications
This commit is contained in:
+23
-3
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user