From ec8e72737b890ba464d3da7769ed6d8ae7474476 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Sun, 5 Apr 2026 04:20:58 +0330 Subject: [PATCH] UPDATE --- notifications/serializers.py | 2 + notifications/services.py | 11 +++++ notifications/tests.py | 88 ++++++++++++++++++++++++++++++++++-- notifications/urls.py | 3 +- notifications/views.py | 70 +++++++++++++++++++++++----- sensor_external_api/views.py | 14 ++---- 6 files changed, 163 insertions(+), 25 deletions(-) diff --git a/notifications/serializers.py b/notifications/serializers.py index 408d7eb..023bc5e 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -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", diff --git a/notifications/services.py b/notifications/services.py index 14a0349..c0e450d 100644 --- a/notifications/services.py +++ b/notifications/services.py @@ -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: diff --git a/notifications/tests.py b/notifications/tests.py index 3968bb5..3bd3c99 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -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): diff --git a/notifications/urls.py b/notifications/urls.py index 2990ed0..5d7ef66 100644 --- a/notifications/urls.py +++ b/notifications/urls.py @@ -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"), ] diff --git a/notifications/views.py b/notifications/views.py index d62ff67..7d598f4 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -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, + ) + diff --git a/sensor_external_api/views.py b/sensor_external_api/views.py index 1c85380..3871387 100644 --- a/sensor_external_api/views.py +++ b/sensor_external_api/views.py @@ -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(