Internal
Public Access
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:
co-authored by
Claude Sonnet 5
parent
cf37389655
commit
7e905a0566
+69
-23
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user