feat(src): implement Web Push notifications infrastructure including VAPID key management, subscription endpoints, and push notification dispatch
Added context processor, management command, and supporting models/views to enable Web Push notifications and VAPID key generation.
This commit is contained in:
9 files changed
+192
-3
No files matched your search
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
|
||||
@@ -49,4 +50,5 @@ def site_stats(request):
|
||||
|
||||
branding = _get_cached_branding()
|
||||
ctx.update(branding)
|
||||
ctx["vapid_public_key"] = settings.VAPID_PUBLIC_KEY
|
||||
return ctx
|
||||
@@ -4,3 +4,6 @@ from django.apps import AppConfig
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "src.notifications"
|
||||
|
||||
def ready(self):
|
||||
import src.notifications.signals # noqa: F401
|
||||
@@ -0,0 +1,28 @@
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from django.core.management.base import BaseCommand
|
||||
from py_vapid import Vapid
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate VAPID keys for Web Push notifications"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
|
||||
pk_bytes = vapid.public_key.public_bytes(
|
||||
Encoding.X962, PublicFormat.UncompressedPoint
|
||||
)
|
||||
from base64 import urlsafe_b64encode
|
||||
|
||||
pk_b64 = urlsafe_b64encode(pk_bytes).rstrip(b"=").decode()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("VAPID keys generated!"))
|
||||
self.stdout.write("")
|
||||
self.stdout.write(f"VAPID_PUBLIC_KEY={pk_b64}")
|
||||
self.stdout.write("Add the public key above to your .env as VAPID_PUBLIC_KEY")
|
||||
self.stdout.write("")
|
||||
self.stdout.write("VAPID_PRIVATE_KEY (PEM format, single line):")
|
||||
self.stdout.write(vapid.private_pem().decode().replace("\n", "\\n"))
|
||||
self.stdout.write("")
|
||||
self.stdout.write("Add the private key above to your .env as VAPID_PRIVATE_KEY")
|
||||
@@ -66,3 +66,26 @@ class DeadlineReminderLog(models.Model):
|
||||
return (
|
||||
f"Reminder: {self.ticket.uuid} -> {self.user} ({self.days_before}d before)"
|
||||
)
|
||||
|
||||
|
||||
class PushSubscription(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="push_subscriptions",
|
||||
)
|
||||
endpoint = models.URLField(max_length=500)
|
||||
auth_key = models.CharField(max_length=255)
|
||||
p256dh_key = models.CharField(max_length=255)
|
||||
user_agent = models.CharField(max_length=500, blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "push_subscriptions"
|
||||
verbose_name = "Push Subscription"
|
||||
verbose_name_plural = "Push Subscriptions"
|
||||
unique_together = ("user", "endpoint")
|
||||
|
||||
def __str__(self):
|
||||
return f"Push sub for {self.user}"
|
||||
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from pywebpush import WebPushException, webpush
|
||||
|
||||
from src.notifications.models import PushSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vapid_private_key():
|
||||
raw = settings.VAPID_PRIVATE_KEY
|
||||
if not raw:
|
||||
return None
|
||||
return raw.replace("\\n", "\n")
|
||||
|
||||
|
||||
def send_push_notification(user, title, body, url=None, icon=None):
|
||||
private_key = _get_vapid_private_key()
|
||||
if not settings.VAPID_PUBLIC_KEY or not private_key:
|
||||
logger.warning("VAPID keys not configured; skipping push notification")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"icon": icon or "/static/img/favicon.png",
|
||||
"badge": "/static/img/favicon.png",
|
||||
}
|
||||
if url:
|
||||
payload["data"] = {"url": url}
|
||||
|
||||
subscriptions = PushSubscription.objects.filter(user=user)
|
||||
if not subscriptions.exists():
|
||||
return
|
||||
|
||||
for sub in subscriptions:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": sub.endpoint,
|
||||
"keys": {"auth": sub.auth_key, "p256dh": sub.p256dh_key},
|
||||
},
|
||||
data=payload,
|
||||
vapid_private_key=private_key,
|
||||
vapid_claims=settings.VAPID_CLAIMS,
|
||||
)
|
||||
except WebPushException as e:
|
||||
if e.response and e.response.status_code in (410, 404):
|
||||
logger.info("Removing expired push subscription for %s", user)
|
||||
sub.delete()
|
||||
else:
|
||||
logger.error("Web push failed for %s: %s", user, e)
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error sending push to %s: %s", user, e)
|
||||
@@ -18,3 +18,9 @@ class NotificationSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("id", "user", "created_at", "updated_at")
|
||||
|
||||
|
||||
class PushSubscriptionSerializer(serializers.Serializer):
|
||||
endpoint = serializers.URLField(max_length=500)
|
||||
auth_key = serializers.CharField(max_length=255)
|
||||
p256dh_key = serializers.CharField(max_length=255)
|
||||
@@ -3,9 +3,26 @@ from django.dispatch import receiver
|
||||
|
||||
from src.notifications.email_utils import send_notification_email
|
||||
from src.notifications.models import Notification
|
||||
from src.notifications.push_utils import send_push_notification
|
||||
from src.tickets.models import TicketComment, TicketHistory
|
||||
|
||||
|
||||
@receiver(post_save, sender=Notification, dispatch_uid="notification_push")
|
||||
def notify_push_on_create(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
ticket_id = instance.data.get("ticket_id") if instance.data else None
|
||||
url = None
|
||||
if ticket_id:
|
||||
url = f"/dashboard/tickets/{ticket_id}/"
|
||||
send_push_notification(
|
||||
user=instance.user,
|
||||
title=instance.title,
|
||||
body=instance.message or "",
|
||||
url=url,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=TicketComment, dispatch_uid="ticket_comment_notification")
|
||||
def notify_comment_added(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
|
||||
@@ -9,4 +9,14 @@ urlpatterns = [
|
||||
path(
|
||||
"<int:pk>/", views.NotificationDetailView.as_view(), name="notification-detail"
|
||||
),
|
||||
path(
|
||||
"push/subscribe/",
|
||||
views.subscribe_push_view,
|
||||
name="notification-push-subscribe",
|
||||
),
|
||||
path(
|
||||
"push/unsubscribe/",
|
||||
views.unsubscribe_push_view,
|
||||
name="notification-push-unsubscribe",
|
||||
),
|
||||
]
|
||||
@@ -1,10 +1,15 @@
|
||||
import json
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework import generics, permissions
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
|
||||
from src.notifications.models import Notification
|
||||
from src.notifications.serializers import NotificationSerializer
|
||||
from src.notifications.models import Notification, PushSubscription
|
||||
from src.notifications.serializers import (
|
||||
NotificationSerializer,
|
||||
PushSubscriptionSerializer,
|
||||
)
|
||||
|
||||
|
||||
class NotificationListView(generics.ListAPIView):
|
||||
@@ -41,3 +46,43 @@ def mark_all_read_view(request):
|
||||
def unread_count_view(request):
|
||||
count = Notification.objects.filter(user=request.user, read_at__isnull=True).count()
|
||||
return Response({"unread_count": count})
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def subscribe_push_view(request):
|
||||
serializer = PushSubscriptionSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
endpoint = serializer.validated_data["endpoint"]
|
||||
sub, created = PushSubscription.objects.update_or_create(
|
||||
user=request.user,
|
||||
endpoint=endpoint,
|
||||
defaults={
|
||||
"auth_key": serializer.validated_data["auth_key"],
|
||||
"p256dh_key": serializer.validated_data["p256dh_key"],
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
|
||||
},
|
||||
)
|
||||
return Response({"status": "subscribed", "created": created})
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def unsubscribe_push_view(request):
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return Response({"error": "Invalid JSON"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
endpoint = data.get("endpoint")
|
||||
if not endpoint:
|
||||
return Response(
|
||||
{"error": "endpoint required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
deleted, _ = PushSubscription.objects.filter(
|
||||
user=request.user, endpoint=endpoint
|
||||
).delete()
|
||||
return Response({"status": "unsubscribed", "deleted": deleted})
|
||||
Reference in new issue
Block a user