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
+69 -23
View File
@@ -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