This commit is contained in:
2026-04-03 23:51:00 +03:30
parent e2728871ee
commit ecb42c6895
32 changed files with 2336 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notifications"
+10
View File
@@ -0,0 +1,10 @@
from rest_framework import serializers
class NotificationPublishSerializer(serializers.Serializer):
channel = serializers.CharField(max_length=128)
title = serializers.CharField(max_length=255)
message = serializers.CharField()
level = serializers.ChoiceField(choices=["info", "success", "warning", "error"], default="info")
metadata = serializers.DictField(required=False, default=dict)
event = serializers.CharField(max_length=64, required=False, default="notification")
+33
View File
@@ -0,0 +1,33 @@
import json
import uuid
from datetime import datetime, timezone
from django.conf import settings
from redis import Redis
def get_notifications_redis_client():
redis_url = getattr(settings, "NOTIFICATION_REDIS_URL", None) or _default_redis_url()
return Redis.from_url(redis_url, decode_responses=True)
def publish_notification(channel, title, message, *, level="info", metadata=None, event="notification"):
payload = {
"id": str(uuid.uuid4()),
"event": event,
"title": title,
"message": message,
"level": level,
"metadata": metadata or {},
"created_at": datetime.now(timezone.utc).isoformat(),
}
redis_client = get_notifications_redis_client()
redis_client.publish(channel, json.dumps(payload))
return payload
def _default_redis_url():
broker_url = getattr(settings, "CELERY_BROKER_URL", "")
if isinstance(broker_url, str) and broker_url.startswith("redis://"):
return broker_url
return "redis://127.0.0.1:6379/1"
+97
View File
@@ -0,0 +1,97 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from .views import NotificationPublishView, NotificationStreamView
class NotificationPublishViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="notify-user",
password="secret123",
email="notify@example.com",
phone_number="09120000099",
)
@patch("notifications.views.publish_notification")
def test_publish_calls_service_and_returns_payload(self, mock_publish_notification):
mock_publish_notification.return_value = {"id": "1", "event": "notification", "message": "hello"}
request = self.factory.post(
"/api/notifications/publish/",
{
"channel": "user-1",
"title": "Test",
"message": "hello",
"level": "info",
},
format="json",
)
force_authenticate(request, user=self.user)
response = NotificationPublishView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
mock_publish_notification.assert_called_once()
class _FakePubSub:
def __init__(self):
self.calls = 0
def subscribe(self, _channel):
return None
def get_message(self, ignore_subscribe_messages=True, timeout=15.0):
self.calls += 1
if self.calls == 1:
return {"type": "message", "data": '{"event":"notification","message":"hi"}'}
return None
def close(self):
return None
class _FakeRedis:
def __init__(self):
self._pubsub = _FakePubSub()
def pubsub(self):
return self._pubsub
class NotificationStreamViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="stream-user",
password="secret123",
email="stream@example.com",
phone_number="09120000098",
)
@patch("notifications.views.get_notifications_redis_client")
def test_stream_returns_event_stream_response(self, mock_redis_client):
mock_redis_client.return_value = _FakeRedis()
request = self.factory.get("/api/notifications/stream/?channel=user-1")
force_authenticate(request, user=self.user)
response = NotificationStreamView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/event-stream")
iterator = iter(response.streaming_content)
first_chunk = self._to_text(next(iterator))
second_chunk = self._to_text(next(iterator))
self.assertIn("connected", first_chunk)
self.assertIn("event: notification", second_chunk)
@staticmethod
def _to_text(value):
if isinstance(value, bytes):
return value.decode()
return str(value)
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .views import NotificationPublishView, NotificationStreamView
urlpatterns = [
path("stream/", NotificationStreamView.as_view(), name="notifications-stream"),
path("publish/", NotificationPublishView.as_view(), name="notifications-publish"),
]
+84
View File
@@ -0,0 +1,84 @@
import json
import time
from django.http import StreamingHttpResponse
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import code_response
from .serializers import NotificationPublishSerializer
from .services import get_notifications_redis_client, publish_notification
def _sse_event(event_name, data):
return f"event: {event_name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
class NotificationStreamView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Notifications"],
parameters=[
OpenApiParameter(
name="channel",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Redis channel to subscribe. Default is user-{current_user_id}.",
),
],
responses={200: OpenApiTypes.STR},
)
def get(self, request):
channel = request.query_params.get("channel") or f"user-{request.user.id}"
def stream():
redis_client = get_notifications_redis_client()
pubsub = redis_client.pubsub()
pubsub.subscribe(channel)
try:
yield ": connected\n\n"
while True:
message = pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
if message and message.get("type") == "message":
try:
payload = json.loads(message["data"])
except (TypeError, json.JSONDecodeError):
payload = {
"event": "notification",
"message": str(message["data"]),
}
yield _sse_event(payload.get("event", "notification"), payload)
else:
yield ": keepalive\n\n"
time.sleep(0.1)
except GeneratorExit:
return
finally:
pubsub.close()
response = StreamingHttpResponse(stream(), content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
class NotificationPublishView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Notifications"],
request=NotificationPublishSerializer,
responses={200: code_response("NotificationPublishResponse", data=OpenApiTypes.OBJECT)},
)
def post(self, request):
serializer = NotificationPublishSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = publish_notification(**serializer.validated_data)
return Response({"code": 200, "msg": "success", "data": payload}, status=status.HTTP_200_OK)