From 7e905a0566cdd79dbe8fb21a3259f2ae6cc6e755 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Fri, 4 Sep 2026 22:30:56 -0600 Subject: [PATCH] Add Web Push notifications (PWA) Wires up the previously-scaffolded VAPID/DeviceToken infrastructure end to end: browser subscription flow on the Profile page's existing "Push Notifications" toggle, a service worker push/notificationclick handler, and server-side sending from the daily task digest. Also broadens that digest's eligibility query so push-only users (email notifications off) aren't silently skipped, and adds `generate_vapid_keys` since the pinned py-vapid's own key generator is broken against current cryptography. Co-Authored-By: Claude Sonnet 5 --- API.md | 2 + PORTAINER_DEPLOYMENT.md | 5 + config/settings/base.py | 5 + notifications/tasks.py | 92 ++++++++++++++----- static/js/app.js | 84 +++++++++++++++++ templates/base.html | 2 +- templates/sw.js | 17 ++++ templates/users/profile.html | 3 +- users/management/__init__.py | 0 users/management/commands/__init__.py | 0 .../commands/generate_vapid_keys.py | 36 ++++++++ users/views.py | 7 +- 12 files changed, 226 insertions(+), 27 deletions(-) create mode 100644 users/management/__init__.py create mode 100644 users/management/commands/__init__.py create mode 100644 users/management/commands/generate_vapid_keys.py diff --git a/API.md b/API.md index 0547aba..e4626a9 100644 --- a/API.md +++ b/API.md @@ -324,6 +324,8 @@ Registers device for push notifications. **Platform Options:** `android`, `web`, `desktop` +For `platform: "web"`, `token` is the JSON-stringified `PushSubscription` object from the browser's `PushManager.subscribe()` (i.e. `JSON.stringify(subscription)`), not a bare string token. The web app's own subscribe flow (Profile page → Push Notifications) handles this automatically. + **Response (201 Created):** ```json { diff --git a/PORTAINER_DEPLOYMENT.md b/PORTAINER_DEPLOYMENT.md index 36ffbad..2ac643c 100644 --- a/PORTAINER_DEPLOYMENT.md +++ b/PORTAINER_DEPLOYMENT.md @@ -79,6 +79,11 @@ SERVER_EMAIL=kig@keepitgoing.app # Email Verification EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS=24 +# Web Push (generate with `python manage.py generate_vapid_keys`) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_ADMIN_EMAIL= + # CORS Configuration CORS_ALLOWED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app diff --git a/config/settings/base.py b/config/settings/base.py index 028feae..584b5fd 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -175,3 +175,8 @@ EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS = int( os.environ.get('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24) ) SITE_DOMAIN = os.environ.get('SITE_DOMAIN', 'localhost:8000') + +# Web Push (VAPID) +VAPID_PUBLIC_KEY = os.environ.get('VAPID_PUBLIC_KEY', '') +VAPID_PRIVATE_KEY = os.environ.get('VAPID_PRIVATE_KEY', '') +VAPID_ADMIN_EMAIL = os.environ.get('VAPID_ADMIN_EMAIL', '') diff --git a/notifications/tasks.py b/notifications/tasks.py index c9a0232..2dd371d 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -2,18 +2,51 @@ Celery tasks for notifications. """ +import json import logging from celery import shared_task from django.utils import timezone from django.core.mail import send_mail from django.template.loader import render_to_string from django.conf import settings +from django.db.models import Q from datetime import timedelta from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) +def send_web_push_to_user(user, title, body, url='/'): + """Send a Web Push notification to all of a user's registered web devices.""" + if not settings.VAPID_PRIVATE_KEY: + return + + from pywebpush import webpush, WebPushException + from users.models import DeviceToken + + devices = DeviceToken.objects.filter(user=user, platform='web', is_active=True) + + for device in devices: + try: + subscription_info = json.loads(device.token) + except (ValueError, TypeError): + continue + + try: + webpush( + subscription_info=subscription_info, + data=json.dumps({'title': title, 'body': body, 'url': url}), + vapid_private_key=settings.VAPID_PRIVATE_KEY, + vapid_claims={'sub': f'mailto:{settings.VAPID_ADMIN_EMAIL}'}, + ) + except WebPushException as e: + status_code = e.response.status_code if e.response is not None else None + if status_code in (404, 410): + device.is_active = False + device.save(update_fields=['is_active']) + logger.warning(f"Web push failed for {user.email}: {e}") + + @shared_task def send_daily_task_email(): """ @@ -25,14 +58,13 @@ def send_daily_task_email(): User = get_user_model() - # Get all users who have email notifications enabled + # Get all users who have email and/or push notifications enabled users = User.objects.filter( - email_notifications=True, - email_verified=True, + Q(email_notifications=True) | Q(push_notifications=True), is_active=True - ) + ).distinct() - emails_sent = 0 + notifications_sent = 0 current_utc_hour = timezone.now().hour for user in users: @@ -110,31 +142,45 @@ def send_daily_task_email(): message = '\n'.join(message_lines) - # Send email - try: - send_mail( - subject=subject, - message=message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - fail_silently=False, - ) + notified = False - # Record that we sent the email (prevents duplicates) + # Send email + if user.email_notifications and user.email_verified: + try: + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + fail_silently=False, + ) + notified = True + logger.info(f"Sent daily task email to {user.email}") + except Exception as e: + logger.error(f"Failed to send daily email to {user.email}: {e}") + + # Send push + if user.push_notifications: + send_web_push_to_user( + user, + title=subject, + body=f"{tasks_due_today.count()} due today, {overdue_tasks.count()} overdue", + url='/', + ) + notified = True + + if notified: + # Record that we notified the user today (prevents duplicates) Notification.objects.create( user=user, notification_type='daily_email', title=subject, - message=f"Daily email sent with {tasks_due_today.count()} tasks due today and {overdue_tasks.count()} overdue tasks" + message=f"Daily notification sent with {tasks_due_today.count()} tasks due today and {overdue_tasks.count()} overdue tasks" ) + notifications_sent += 1 - emails_sent += 1 - logger.info(f"Sent daily task email to {user.email}") - except Exception as e: - logger.error(f"Failed to send daily email to {user.email}: {e}") - - logger.info(f"Daily task email job completed. Sent {emails_sent} emails.") - return emails_sent + logger.info(f"Daily task notification job completed. Notified {notifications_sent} users.") + return notifications_sent @shared_task diff --git a/static/js/app.js b/static/js/app.js index 7d13d2d..d3956f8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -20,6 +20,90 @@ function registerServiceWorker() { } } +/* ============================================ + Push Notifications + ============================================ */ + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; i++) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +async function subscribeToPush(vapidPublicKey) { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + throw new Error('Push notifications are not supported in this browser.'); + } + if (!vapidPublicKey) { + throw new Error('Push notifications are not configured on this server.'); + } + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + throw new Error('Notification permission was not granted.'); + } + + const registration = await navigator.serviceWorker.ready; + let subscription = await registration.pushManager.getSubscription(); + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }); + } + + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; + const response = await fetch('/api/users/devices/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + }, + body: JSON.stringify({ + platform: 'web', + token: JSON.stringify(subscription), + device_name: navigator.userAgent.slice(0, 255), + }), + }); + + if (!response.ok) { + throw new Error('Failed to register push subscription with the server.'); + } +} + +function handleProfileFormSubmit(event, form) { + const pushCheckbox = form.querySelector('[name=push_notifications]'); + if (!pushCheckbox || !pushCheckbox.checked) { + return; + } + + // preventDefault() must happen synchronously (before any await) or the + // browser will have already submitted the form by the time we get to it. + event.preventDefault(); + enablePushThenSubmitProfile(form, pushCheckbox); +} + +async function enablePushThenSubmitProfile(form, pushCheckbox) { + try { + if ('serviceWorker' in navigator && 'PushManager' in window) { + const registration = await navigator.serviceWorker.getRegistration(); + const existing = registration && await registration.pushManager.getSubscription(); + if (!existing) { + await subscribeToPush(pushCheckbox.dataset.vapidPublicKey); + } + } + } catch (err) { + alert('Could not enable push notifications: ' + err.message); + pushCheckbox.checked = false; + } + form.submit(); +} + /* ============================================ Theme Toggle ============================================ */ diff --git a/templates/base.html b/templates/base.html index 7a7c838..999016e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -147,7 +147,7 @@ {% endif %} {% endblock %} - + {% block extra_js %}{% endblock %} diff --git a/templates/sw.js b/templates/sw.js index 9e2bf9a..572174e 100644 --- a/templates/sw.js +++ b/templates/sw.js @@ -47,3 +47,20 @@ self.addEventListener('fetch', (event) => { ); } }); + +self.addEventListener('push', (event) => { + const data = event.data ? event.data.json() : {}; + event.waitUntil( + self.registration.showNotification(data.title || 'KeepItGoing', { + body: data.body || '', + icon: '{% static "favicons/icon-192.png" %}', + badge: '{% static "favicons/icon-192.png" %}', + data: { url: data.url || '/' }, + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil(clients.openWindow(event.notification.data.url || '/')); +}); diff --git a/templates/users/profile.html b/templates/users/profile.html index fe947d8..670ee93 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -18,7 +18,7 @@

Personal Information

-
+ {% csrf_token %}
@@ -96,6 +96,7 @@