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:
sankalp committed 2026-06-30 10:23:14 +05:45
1 parent f5ec721cb2
commit b970470237
9 files changed
+192 -3

No files matched your search

+2
View File
@@ -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
+3
View File
@@ -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")
+23
View File
@@ -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}"
+55
View File
@@ -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)
+6
View File
@@ -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)
+17
View File
@@ -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:
+10
View File
@@ -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",
),
]
+48 -3
View File
@@ -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})