From b970470237b14eb2887e95fdce215beb12f4f62b Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 30 Jun 2026 10:23:14 +0545 Subject: [PATCH] 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. --- src/dashboard/context_processors.py | 2 + src/notifications/apps.py | 3 + .../commands/generate_vapid_keys.py | 28 ++++++++++ src/notifications/models.py | 23 ++++++++ src/notifications/push_utils.py | 55 +++++++++++++++++++ src/notifications/serializers.py | 6 ++ src/notifications/signals.py | 17 ++++++ src/notifications/urls.py | 10 ++++ src/notifications/views.py | 51 ++++++++++++++++- 9 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 src/notifications/management/commands/generate_vapid_keys.py create mode 100644 src/notifications/push_utils.py diff --git a/src/dashboard/context_processors.py b/src/dashboard/context_processors.py index 1b87938..8df76ad 100644 --- a/src/dashboard/context_processors.py +++ b/src/dashboard/context_processors.py @@ -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 diff --git a/src/notifications/apps.py b/src/notifications/apps.py index d49c498..84db37f 100644 --- a/src/notifications/apps.py +++ b/src/notifications/apps.py @@ -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 diff --git a/src/notifications/management/commands/generate_vapid_keys.py b/src/notifications/management/commands/generate_vapid_keys.py new file mode 100644 index 0000000..ab239ed --- /dev/null +++ b/src/notifications/management/commands/generate_vapid_keys.py @@ -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") diff --git a/src/notifications/models.py b/src/notifications/models.py index d185368..5ac1bb0 100644 --- a/src/notifications/models.py +++ b/src/notifications/models.py @@ -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}" diff --git a/src/notifications/push_utils.py b/src/notifications/push_utils.py new file mode 100644 index 0000000..21c846d --- /dev/null +++ b/src/notifications/push_utils.py @@ -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) diff --git a/src/notifications/serializers.py b/src/notifications/serializers.py index 5cf5271..dcef8bc 100644 --- a/src/notifications/serializers.py +++ b/src/notifications/serializers.py @@ -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) diff --git a/src/notifications/signals.py b/src/notifications/signals.py index 0db7943..11c9353 100644 --- a/src/notifications/signals.py +++ b/src/notifications/signals.py @@ -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: diff --git a/src/notifications/urls.py b/src/notifications/urls.py index 64d6566..4d27ce9 100644 --- a/src/notifications/urls.py +++ b/src/notifications/urls.py @@ -9,4 +9,14 @@ urlpatterns = [ path( "/", 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", + ), ] diff --git a/src/notifications/views.py b/src/notifications/views.py index 5b8857d..2fa6948 100644 --- a/src/notifications/views.py +++ b/src/notifications/views.py @@ -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})