This commit is contained in:
2026-04-05 04:20:58 +03:30
parent df15d03d7c
commit ec8e72737b
6 changed files with 163 additions and 25 deletions
+2
View File
@@ -5,12 +5,14 @@ from .models import FarmNotification
class FarmNotificationSerializer(serializers.ModelSerializer):
farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True)
since_id = serializers.IntegerField(source="id", read_only=True)
class Meta:
model = FarmNotification
fields = [
"uuid",
"farm_uuid",
"since_id",
"title",
"message",
"level",
+11
View File
@@ -39,6 +39,17 @@ def get_notifications_for_farm(*, farm: FarmHub, since_id=None) -> QuerySet[Farm
raise ValueError("Notifications table is not migrated.") from exc
def mark_notifications_as_read(*, farm: FarmHub, slice_id: int) -> int:
try:
return FarmNotification.objects.filter(
farm=farm,
id__lte=slice_id,
is_read=False,
).update(is_read=True)
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:
+85 -3
View File
@@ -8,8 +8,8 @@ from rest_framework.test import APIRequestFactory, force_authenticate
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
from .services import create_notification_for_farm_uuid, long_poll_notifications, mark_notifications_as_read
from .views import ExternalNotificationIngestView, NotificationLongPollView, NotificationMarkReadView
class NotificationServiceTests(TestCase):
@@ -56,6 +56,21 @@ class NotificationServiceTests(TestCase):
self.assertEqual(len(notifications), 1)
self.assertEqual(notifications[0].title, "A")
def test_mark_notifications_as_read_marks_until_slice_id(self):
first = FarmNotification.objects.create(farm=self.farm, title="A", message="B")
second = FarmNotification.objects.create(farm=self.farm, title="C", message="D")
third = FarmNotification.objects.create(farm=self.farm, title="E", message="F")
marked_count = mark_notifications_as_read(farm=self.farm, slice_id=second.id)
self.assertEqual(marked_count, 2)
first.refresh_from_db()
second.refresh_from_db()
third.refresh_from_db()
self.assertTrue(first.is_read)
self.assertTrue(second.is_read)
self.assertFalse(third.is_read)
class NotificationLongPollViewTests(TestCase):
def setUp(self):
@@ -80,7 +95,7 @@ class NotificationLongPollViewTests(TestCase):
)
def test_long_poll_view_returns_notifications_for_owned_farm(self):
FarmNotification.objects.create(farm=self.farm, title="Alert", message="Check sensor")
notification = 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)
@@ -89,6 +104,8 @@ class NotificationLongPollViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data["data"]), 1)
self.assertEqual(response.data["data"][0]["title"], "Alert")
self.assertEqual(response.data["data"][0]["since_id"], notification.id)
self.assertFalse(response.data["data"][0]["is_read"])
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")
@@ -114,6 +131,71 @@ class NotificationLongPollViewTests(TestCase):
self.assertEqual(mocked_long_poll.call_args.kwargs["since_id"], 5)
class NotificationMarkReadViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="notif-mark-user",
password="secret123",
email="notif-mark@example.com",
phone_number="09120000015",
)
self.other_user = get_user_model().objects.create_user(
username="notif-mark-other-user",
password="secret123",
email="notif-mark-other@example.com",
phone_number="09120000016",
)
self.farm_type = FarmType.objects.create(name="باغ")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Farm D",
)
def test_mark_read_view_marks_notifications_up_to_slice_id(self):
first = FarmNotification.objects.create(farm=self.farm, title="Alert 1", message="Check sensor")
second = FarmNotification.objects.create(farm=self.farm, title="Alert 2", message="Check pump")
third = FarmNotification.objects.create(farm=self.farm, title="Alert 3", message="Check valve")
request = self.factory.post(
"/api/notifications/mark-as-read/",
{
"farm_uuid": str(self.farm.farm_uuid),
"slice_id": second.id,
},
format="json",
)
force_authenticate(request, user=self.user)
response = NotificationMarkReadView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["marked_count"], 2)
first.refresh_from_db()
second.refresh_from_db()
third.refresh_from_db()
self.assertTrue(first.is_read)
self.assertTrue(second.is_read)
self.assertFalse(third.is_read)
def test_mark_read_view_returns_404_for_unowned_farm(self):
notification = FarmNotification.objects.create(farm=self.farm, title="Alert", message="Check sensor")
request = self.factory.post(
"/api/notifications/mark-as-read/",
{
"farm_uuid": str(self.farm.farm_uuid),
"slice_id": notification.id,
},
format="json",
)
force_authenticate(request, user=self.other_user)
response = NotificationMarkReadView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["msg"], "Farm not found.")
@override_settings(EXTERNAL_NOTIFICATION_API_KEY="12345")
class ExternalNotificationIngestViewTests(TestCase):
def setUp(self):
+2 -1
View File
@@ -1,7 +1,8 @@
from django.urls import path
from .views import NotificationLongPollView
from .views import NotificationLongPollView, NotificationMarkReadView
urlpatterns = [
path("long-poll/", NotificationLongPollView.as_view(), name="notification-long-poll"),
path("mark-as-read/", NotificationMarkReadView.as_view(), name="notification-mark-as-read"),
]
+59 -11
View File
@@ -1,14 +1,15 @@
from django.conf import settings
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from config.swagger import code_response
from farm_hub.models import FarmHub
from .serializers import FarmNotificationSerializer
from .services import create_notification_for_farm_uuid, long_poll_notifications
from .services import long_poll_notifications, mark_notifications_as_read
class NotificationLongPollQuerySerializer(serializers.Serializer):
@@ -17,12 +18,15 @@ class NotificationLongPollQuerySerializer(serializers.Serializer):
timeout = serializers.IntegerField(required=False, min_value=0, max_value=60)
class ExternalNotificationCreateSerializer(serializers.Serializer):
class NotificationMarkReadSerializer(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)
slice_id = serializers.IntegerField(min_value=1)
def get_owned_farm(*, farm_uuid, user):
return FarmHub.objects.filter(farm_uuid=farm_uuid, owner=user).first()
class NotificationLongPollView(APIView):
@@ -41,10 +45,10 @@ class NotificationLongPollView(APIView):
serializer = NotificationLongPollQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
farm = FarmHub.objects.filter(
farm = get_owned_farm(
farm_uuid=serializer.validated_data["farm_uuid"],
owner=request.user,
).first()
user=request.user,
)
if farm is None:
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
@@ -65,3 +69,47 @@ class NotificationLongPollView(APIView):
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
class NotificationMarkReadView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Notifications"],
request=NotificationMarkReadSerializer,
responses={
200: code_response(
"NotificationMarkReadResponse",
extra_fields={"marked_count": serializers.IntegerField()},
),
404: code_response("NotificationMarkReadNotFoundResponse"),
503: code_response("NotificationMarkReadUnavailableResponse"),
},
)
def post(self, request):
serializer = NotificationMarkReadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
farm = get_owned_farm(
farm_uuid=serializer.validated_data["farm_uuid"],
user=request.user,
)
if farm is None:
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
try:
marked_count = mark_notifications_as_read(
farm=farm,
slice_id=serializer.validated_data["slice_id"],
)
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
return Response(
{"code": 200, "msg": "success", "marked_count": marked_count},
status=status.HTTP_200_OK,
)
+4 -10
View File
@@ -4,6 +4,7 @@ from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
from rest_framework.permissions import IsAuthenticated
from config.swagger import code_response
from notifications.serializers import FarmNotificationSerializer
@@ -73,8 +74,8 @@ class SensorExternalAPIView(APIView):
class SensorExternalRequestLogListAPIView(APIView):
authentication_classes = [SensorExternalAPIKeyAuthentication]
permission_classes = [AllowAny]
permission_classes = [IsAuthenticated]
pagination_class = SensorExternalRequestLogPagination
@extend_schema(
@@ -83,14 +84,7 @@ class SensorExternalRequestLogListAPIView(APIView):
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True),
OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False),
OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False),
OpenApiParameter(
name="X-API-Key",
type=OpenApiTypes.STR,
location=OpenApiParameter.HEADER,
required=True,
default="12345",
description="API key for sensor external API.",
),
],
responses={
200: code_response(