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 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2026-09-04 22:30:56 -06:00
co-authored by Claude Sonnet 5
parent cf37389655
commit 7e905a0566
12 changed files with 226 additions and 27 deletions
+2
View File
@@ -324,6 +324,8 @@ Registers device for push notifications.
**Platform Options:** `android`, `web`, `desktop` **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):** **Response (201 Created):**
```json ```json
{ {
+5
View File
@@ -79,6 +79,11 @@ SERVER_EMAIL=kig@keepitgoing.app
# Email Verification # Email Verification
EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS=24 EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS=24
# Web Push (generate with `python manage.py generate_vapid_keys`)
VAPID_PUBLIC_KEY=<vapid-public-key>
VAPID_PRIVATE_KEY=<vapid-private-key>
VAPID_ADMIN_EMAIL=<admin-contact-email>
# CORS Configuration # CORS Configuration
CORS_ALLOWED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app CORS_ALLOWED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app
+5
View File
@@ -175,3 +175,8 @@ EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS = int(
os.environ.get('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24) os.environ.get('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24)
) )
SITE_DOMAIN = os.environ.get('SITE_DOMAIN', 'localhost:8000') 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', '')
+63 -17
View File
@@ -2,18 +2,51 @@
Celery tasks for notifications. Celery tasks for notifications.
""" """
import json
import logging import logging
from celery import shared_task from celery import shared_task
from django.utils import timezone from django.utils import timezone
from django.core.mail import send_mail from django.core.mail import send_mail
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.conf import settings from django.conf import settings
from django.db.models import Q
from datetime import timedelta from datetime import timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__) 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 @shared_task
def send_daily_task_email(): def send_daily_task_email():
""" """
@@ -25,14 +58,13 @@ def send_daily_task_email():
User = get_user_model() 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( users = User.objects.filter(
email_notifications=True, Q(email_notifications=True) | Q(push_notifications=True),
email_verified=True,
is_active=True is_active=True
) ).distinct()
emails_sent = 0 notifications_sent = 0
current_utc_hour = timezone.now().hour current_utc_hour = timezone.now().hour
for user in users: for user in users:
@@ -110,7 +142,10 @@ def send_daily_task_email():
message = '\n'.join(message_lines) message = '\n'.join(message_lines)
notified = False
# Send email # Send email
if user.email_notifications and user.email_verified:
try: try:
send_mail( send_mail(
subject=subject, subject=subject,
@@ -119,22 +154,33 @@ def send_daily_task_email():
recipient_list=[user.email], recipient_list=[user.email],
fail_silently=False, fail_silently=False,
) )
notified = True
# Record that we sent the email (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"
)
emails_sent += 1
logger.info(f"Sent daily task email to {user.email}") logger.info(f"Sent daily task email to {user.email}")
except Exception as e: except Exception as e:
logger.error(f"Failed to send daily email to {user.email}: {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.") # Send push
return emails_sent 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 notification sent with {tasks_due_today.count()} tasks due today and {overdue_tasks.count()} overdue tasks"
)
notifications_sent += 1
logger.info(f"Daily task notification job completed. Notified {notifications_sent} users.")
return notifications_sent
@shared_task @shared_task
+84
View File
@@ -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 Theme Toggle
============================================ */ ============================================ */
+1 -1
View File
@@ -147,7 +147,7 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
<script src="{% static 'js/app.js' %}?v=4"></script> <script src="{% static 'js/app.js' %}?v=6"></script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>
+17
View File
@@ -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 || '/'));
});
+2 -1
View File
@@ -18,7 +18,7 @@
<div class="card" style="background: var(--surface); padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1.5rem; border: 1px solid var(--border);"> <div class="card" style="background: var(--surface); padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1.5rem; border: 1px solid var(--border);">
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1.5rem;">Personal Information</h2> <h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1.5rem;">Personal Information</h2>
<form method="post"> <form method="post" onsubmit="handleProfileFormSubmit(event, this)">
{% csrf_token %} {% csrf_token %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
@@ -96,6 +96,7 @@
<label style="display: flex; align-items: center; cursor: pointer; padding: 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.375rem;"> <label style="display: flex; align-items: center; cursor: pointer; padding: 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.375rem;">
<input type="checkbox" <input type="checkbox"
name="push_notifications" name="push_notifications"
data-vapid-public-key="{{ vapid_public_key }}"
{% if user.push_notifications %}checked{% endif %} {% if user.push_notifications %}checked{% endif %}
style="margin-right: 0.75rem; width: 1rem; height: 1rem;"> style="margin-right: 0.75rem; width: 1rem; height: 1rem;">
<div> <div>
View File
@@ -0,0 +1,36 @@
"""
Generate a VAPID keypair for Web Push notifications.
Uses `cryptography` directly rather than py_vapid's own Vapid02.generate_keys(),
which raises `TypeError: curve must be an EllipticCurve instance` against
newer versions of `cryptography` (confirmed with cryptography 46.0.3 / py-vapid 1.9.2).
"""
import base64
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = 'Generate a VAPID public/private keypair for Web Push notifications'
def handle(self, *args, **options):
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
private_value = private_key.private_numbers().private_value
private_bytes = private_value.to_bytes(32, 'big')
private_b64 = base64.urlsafe_b64encode(private_bytes).rstrip(b'=').decode()
public_bytes = public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
public_b64 = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode()
self.stdout.write('Add these to your environment configuration:\n')
self.stdout.write(f'VAPID_PUBLIC_KEY={public_b64}')
self.stdout.write(f'VAPID_PRIVATE_KEY={private_b64}')
self.stdout.write('VAPID_ADMIN_EMAIL=<your admin contact email>')
+5 -2
View File
@@ -424,7 +424,9 @@ class ProfileView(View):
def get(self, request): def get(self, request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return redirect('login') return redirect('login')
return render(request, 'users/profile.html') return render(request, 'users/profile.html', {
'vapid_public_key': settings.VAPID_PUBLIC_KEY,
})
def post(self, request): def post(self, request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
@@ -440,7 +442,8 @@ class ProfileView(View):
user.save() user.save()
return render(request, 'users/profile.html', { return render(request, 'users/profile.html', {
'success': 'Profile updated successfully.' 'success': 'Profile updated successfully.',
'vapid_public_key': settings.VAPID_PUBLIC_KEY,
}) })