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
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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', '')
|
||||||
|
|||||||
+69
-23
@@ -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,31 +142,45 @@ def send_daily_task_email():
|
|||||||
|
|
||||||
message = '\n'.join(message_lines)
|
message = '\n'.join(message_lines)
|
||||||
|
|
||||||
# Send email
|
notified = False
|
||||||
try:
|
|
||||||
send_mail(
|
|
||||||
subject=subject,
|
|
||||||
message=message,
|
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
||||||
recipient_list=[user.email],
|
|
||||||
fail_silently=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(
|
Notification.objects.create(
|
||||||
user=user,
|
user=user,
|
||||||
notification_type='daily_email',
|
notification_type='daily_email',
|
||||||
title=subject,
|
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"Daily task notification job completed. Notified {notifications_sent} users.")
|
||||||
logger.info(f"Sent daily task email to {user.email}")
|
return notifications_sent
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 || '/'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user