UPDATE
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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,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(
|
||||
|
||||
Reference in New Issue
Block a user