This commit is contained in:
2026-04-05 00:57:25 +03:30
parent 6d5ece1f5d
commit 32dbbed1af
26 changed files with 825 additions and 291 deletions
-1
View File
@@ -1 +0,0 @@
+1
View File
@@ -4,3 +4,4 @@ from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notifications"
verbose_name = "Notifications"
+41
View File
@@ -0,0 +1,41 @@
# Generated by Django 5.1.7 on 2025-02-20 00:00
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("farm_hub", "0006_seed_expanded_product_catalog"),
]
operations = [
migrations.CreateModel(
name="FarmNotification",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("title", models.CharField(max_length=255)),
("message", models.TextField()),
("level", models.CharField(default="info", max_length=32)),
("is_read", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"farm",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="farm_hub.farmhub",
),
),
],
options={
"db_table": "farm_notifications",
"ordering": ["-created_at", "-id"],
},
),
]
+1
View File
@@ -0,0 +1 @@
+25
View File
@@ -0,0 +1,25 @@
import uuid as uuid_lib
from django.db import models
class FarmNotification(models.Model):
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
farm = models.ForeignKey(
"farm_hub.FarmHub",
on_delete=models.CASCADE,
related_name="notifications",
)
title = models.CharField(max_length=255)
message = models.TextField()
level = models.CharField(max_length=32, default="info")
is_read = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "farm_notifications"
ordering = ["-created_at", "-id"]
def __str__(self):
return f"{self.farm_id}:{self.title}"
+17 -7
View File
@@ -1,10 +1,20 @@
from rest_framework import serializers
from .models import FarmNotification
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")
class FarmNotificationSerializer(serializers.ModelSerializer):
farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True)
class Meta:
model = FarmNotification
fields = [
"uuid",
"farm_uuid",
"title",
"message",
"level",
"is_read",
"metadata",
"created_at",
]
+43 -26
View File
@@ -1,33 +1,50 @@
import json
import uuid
from datetime import datetime, timezone
import time
from django.conf import settings
from redis import Redis
from django.db import OperationalError, ProgrammingError
from django.db.models import QuerySet
from farm_hub.models import FarmHub
from .models import FarmNotification
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)
DEFAULT_POLL_TIMEOUT_SECONDS = 15
DEFAULT_POLL_INTERVAL_SECONDS = 1
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 create_notification_for_farm_uuid(*, farm_uuid, title, message, level="info", metadata=None):
farm = FarmHub.objects.filter(farm_uuid=farm_uuid).first()
if farm is None:
raise ValueError("Farm not found.")
try:
return FarmNotification.objects.create(
farm=farm,
title=title,
message=message,
level=level,
metadata=metadata or {},
)
except (ProgrammingError, OperationalError) as exc:
raise ValueError("Notifications table is not migrated.") from exc
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"
def get_notifications_for_farm(*, farm: FarmHub, since_id=None) -> QuerySet[FarmNotification]:
try:
queryset = FarmNotification.objects.filter(farm=farm)
if since_id is not None:
queryset = queryset.filter(id__gt=since_id)
return queryset.order_by("created_at", "id")
except (ProgrammingError, OperationalError) as exc:
raise ValueError("Notifications table is not migrated.") from exc
def long_poll_notifications(*, farm: FarmHub, since_id=None, timeout_seconds=DEFAULT_POLL_TIMEOUT_SECONDS, interval_seconds=DEFAULT_POLL_INTERVAL_SECONDS):
deadline = time.monotonic() + max(timeout_seconds, 0)
while True:
notifications = list(get_notifications_for_farm(farm=farm, since_id=since_id))
if notifications:
return notifications
if time.monotonic() >= deadline:
return []
time.sleep(max(interval_seconds, 0))
+160 -71
View File
@@ -1,97 +1,186 @@
from unittest.mock import patch
from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory, force_authenticate
from .views import NotificationPublishView, NotificationStreamView
from farm_hub.models import FarmHub, FarmType
from .models import FarmNotification
from .services import create_notification_for_farm_uuid, long_poll_notifications
from .views import ExternalNotificationIngestView, NotificationLongPollView
class NotificationPublishViewTests(TestCase):
class NotificationServiceTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="notif-service-user",
password="secret123",
email="notif-service@example.com",
phone_number="09120000011",
)
self.farm_type = FarmType.objects.create(name="گلخانه")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Farm A",
)
def test_create_notification_for_farm_uuid_creates_record(self):
notification = create_notification_for_farm_uuid(
farm_uuid=self.farm.farm_uuid,
title="Irrigation alert",
message="Soil moisture dropped",
level="warning",
metadata={"sensor": "soil-1"},
)
self.assertEqual(notification.farm, self.farm)
self.assertEqual(notification.level, "warning")
self.assertEqual(notification.metadata["sensor"], "soil-1")
def test_create_notification_for_farm_uuid_raises_for_unknown_farm(self):
with self.assertRaisesMessage(ValueError, "Farm not found."):
create_notification_for_farm_uuid(
farm_uuid="11111111-1111-1111-1111-111111111111",
title="x",
message="y",
)
def test_long_poll_notifications_returns_new_notifications(self):
FarmNotification.objects.create(farm=self.farm, title="A", message="B")
notifications = long_poll_notifications(farm=self.farm, timeout_seconds=0)
self.assertEqual(len(notifications), 1)
self.assertEqual(notifications[0].title, "A")
class NotificationLongPollViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="notify-user",
username="notif-view-user",
password="secret123",
email="notify@example.com",
phone_number="09120000099",
email="notif-view@example.com",
phone_number="09120000012",
)
self.other_user = get_user_model().objects.create_user(
username="notif-other-user",
password="secret123",
email="notif-other@example.com",
phone_number="09120000013",
)
self.farm_type = FarmType.objects.create(name="دامداری")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Farm B",
)
@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"}
def test_long_poll_view_returns_notifications_for_owned_farm(self):
FarmNotification.objects.create(farm=self.farm, title="Alert", message="Check sensor")
request = self.factory.get(f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&timeout=0")
force_authenticate(request, user=self.user)
response = NotificationLongPollView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data["data"]), 1)
self.assertEqual(response.data["data"][0]["title"], "Alert")
def test_long_poll_view_returns_404_for_unowned_farm(self):
request = self.factory.get(f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&timeout=0")
force_authenticate(request, user=self.other_user)
response = NotificationLongPollView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["msg"], "Farm not found.")
@patch("notifications.views.long_poll_notifications")
def test_long_poll_view_passes_since_id(self, mocked_long_poll):
mocked_long_poll.return_value = []
request = self.factory.get(
f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&since_id=5&timeout=0"
)
force_authenticate(request, user=self.user)
response = NotificationLongPollView.as_view()(request)
self.assertEqual(response.status_code, 200)
mocked_long_poll.assert_called_once()
self.assertEqual(mocked_long_poll.call_args.kwargs["since_id"], 5)
@override_settings(EXTERNAL_NOTIFICATION_API_KEY="12345")
class ExternalNotificationIngestViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="notif-external-user",
password="secret123",
email="notif-external@example.com",
phone_number="09120000014",
)
self.farm_type = FarmType.objects.create(name="آبی")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Farm C",
)
def test_external_ingest_requires_api_key(self):
request = self.factory.post(
"/api/notifications/publish/",
"/api/notifications/external/ingest/",
{
"channel": "user-1",
"title": "Test",
"message": "hello",
"level": "info",
"farm_uuid": str(self.farm.farm_uuid),
"title": "external",
"message": "payload",
},
format="json",
)
force_authenticate(request, user=self.user)
response = NotificationPublishView.as_view()(request)
response = ExternalNotificationIngestView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
mock_publish_notification.assert_called_once()
self.assertEqual(response.status_code, 401)
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",
def test_external_ingest_creates_notification_with_valid_api_key(self):
request = self.factory.post(
"/api/notifications/external/ingest/",
{
"farm_uuid": str(self.farm.farm_uuid),
"title": "Pump alert",
"message": "Pump disconnected",
"level": "critical",
"metadata": {"source": "external-service"},
},
format="json",
HTTP_X_API_KEY=settings.EXTERNAL_NOTIFICATION_API_KEY,
)
@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 = ExternalNotificationIngestView.as_view()(request)
response = NotificationStreamView.as_view()(request)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["data"]["title"], "Pump alert")
self.assertTrue(
FarmNotification.objects.filter(farm=self.farm, title="Pump alert", level="critical").exists()
)
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)
def test_external_ingest_returns_404_for_unknown_farm(self):
request = self.factory.post(
"/api/notifications/external/ingest/",
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"title": "Pump alert",
"message": "Pump disconnected",
},
format="json",
HTTP_X_API_KEY=settings.EXTERNAL_NOTIFICATION_API_KEY,
)
@staticmethod
def _to_text(value):
if isinstance(value, bytes):
return value.decode()
return str(value)
response = ExternalNotificationIngestView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["msg"], "Farm not found.")
+2 -3
View File
@@ -1,8 +1,7 @@
from django.urls import path
from .views import NotificationPublishView, NotificationStreamView
from .views import NotificationLongPollView
urlpatterns = [
path("stream/", NotificationStreamView.as_view(), name="notifications-stream"),
path("publish/", NotificationPublishView.as_view(), name="notifications-publish"),
path("long-poll/", NotificationLongPollView.as_view(), name="notification-long-poll"),
]
+51 -68
View File
@@ -1,84 +1,67 @@
import json
import time
from django.http import StreamingHttpResponse
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated, AllowAny
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 drf_spectacular.utils import extend_schema
from config.swagger import code_response
from farm_hub.models import FarmHub
from .serializers import NotificationPublishSerializer
from .services import get_notifications_redis_client, publish_notification
from .serializers import FarmNotificationSerializer
from .services import create_notification_for_farm_uuid, long_poll_notifications
def _sse_event(event_name, data):
return f"event: {event_name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
class NotificationLongPollQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
since_id = serializers.IntegerField(required=False, min_value=1)
timeout = serializers.IntegerField(required=False, min_value=0, max_value=60)
class NotificationStreamView(APIView):
class ExternalNotificationCreateSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
title = serializers.CharField(max_length=255)
message = serializers.CharField()
level = serializers.CharField(max_length=32, required=False, default="info")
metadata = serializers.JSONField(required=False)
class NotificationLongPollView(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},
parameters=[NotificationLongPollQuerySerializer],
responses={
200: code_response("NotificationLongPollResponse", data=FarmNotificationSerializer(many=True)),
404: code_response("NotificationLongPollNotFoundResponse"),
503: code_response("NotificationLongPollNotificationsUnavailableResponse"),
},
)
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 = NotificationLongPollQuerySerializer(data=request.query_params)
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)
farm = FarmHub.objects.filter(
farm_uuid=serializer.validated_data["farm_uuid"],
owner=request.user,
).first()
if farm is None:
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
try:
notifications = long_poll_notifications(
farm=farm,
since_id=serializer.validated_data.get("since_id"),
timeout_seconds=serializer.validated_data.get("timeout", 15),
)
except ValueError as exc:
if str(exc) == "Notifications table is not migrated.":
return Response(
{"code": 503, "msg": "Notifications table is not ready. Run migrations."},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
raise
data = FarmNotificationSerializer(notifications, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)