From 6f098a10213105827a181501a9b381901245cd8f Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Sun, 5 Apr 2026 05:10:17 +0330 Subject: [PATCH] UPDATE --- NOTIFICATIONS_FRONTEND_API.md | 214 ++++++++++++++++++++++ notifications/NOTIFICATION_API_CHANGES.md | 80 ++++++++ notifications/services.py | 31 +++- notifications/tests.py | 89 ++++++++- notifications/urls.py | 3 +- notifications/views.py | 46 +++++ 6 files changed, 457 insertions(+), 6 deletions(-) create mode 100644 NOTIFICATIONS_FRONTEND_API.md create mode 100644 notifications/NOTIFICATION_API_CHANGES.md diff --git a/NOTIFICATIONS_FRONTEND_API.md b/NOTIFICATIONS_FRONTEND_API.md new file mode 100644 index 0000000..e733b91 --- /dev/null +++ b/NOTIFICATIONS_FRONTEND_API.md @@ -0,0 +1,214 @@ +# راهنمای استفاده فرانت از APIهای Notifications + +این فایل برای تیم فرانت نوشته شده تا بدون بررسی کد بک‌اند، بتواند APIهای ماژول `notifications` را مصرف کند. + +--- + +## خلاصه خیلی کوتاه + +- تمام APIهای فعلی نوتیفیکیشن بر اساس `farm_uuid` کار می‌کنند. +- برای گرفتن نوتیفیکیشن‌ها باید `GET /api/notifications/long-poll/` را صدا بزنید. +- در هر آیتم نوتیفیکیشن، فیلد `since_id` برگردانده می‌شود. +- برای علامت‌زدن نوتیفیکیشن‌ها به‌عنوان خوانده‌شده باید `POST /api/notifications/mark-as-read/` را با `farm_uuid` و `slice_id` صدا بزنید. +- هر نوتیفیکیشن دارای وضعیت `is_read` است: + - `false` یعنی خوانده نشده + - `true` یعنی خوانده شده + +--- + +## Base Path + +```text +/api/notifications/ +``` + +--- + +## احراز هویت + +هر دو API فعلی نیاز به کاربر لاگین‌شده دارند. + +یعنی فرانت باید توکن کاربر را مثل بقیه endpointهای protected ارسال کند. + +--- + +## 1) گرفتن نوتیفیکیشن‌ها + +### Endpoint + +```http +GET /api/notifications/long-poll/?farm_uuid=&since_id=&timeout= +``` + +### Query Params + +| نام | اجباری | توضیح | +|---|---|---| +| `farm_uuid` | بله | شناسه مزرعه انتخاب‌شده | +| `since_id` | خیر | فقط نوتیفیکیشن‌های جدیدتر از این مقدار برگردانده می‌شوند | +| `timeout` | خیر | زمان long-poll بر حسب ثانیه، بین `0` تا `60` | + +### رفتار + +- اگر `since_id` ارسال نشود، همه نوتیفیکیشن‌های مزرعه برمی‌گردند. +- اگر `since_id` ارسال شود، فقط نوتیفیکیشن‌هایی که `id > since_id` دارند برمی‌گردند. +- اگر نوتیفیکیشن جدیدی وجود نداشته باشد، تا زمان `timeout` منتظر می‌ماند و بعد آرایه خالی برمی‌گرداند. +- این endpoint فقط برای مزرعه‌ای جواب می‌دهد که متعلق به همان کاربر باشد. + +### نمونه درخواست + +```http +GET /api/notifications/long-poll/?farm_uuid=550e8400-e29b-41d4-a716-446655440000&since_id=12&timeout=15 +``` + +### نمونه response موفق + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "uuid": "0d4f68d0-8a49-4d5c-9d1c-1d4fd6d5e3a1", + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "since_id": 13, + "title": "هشدار آبیاری", + "message": "رطوبت خاک پایین است", + "level": "warning", + "is_read": false, + "metadata": { + "sensor": "soil-1" + }, + "created_at": "2025-02-20T10:30:00Z" + } + ] +} +``` + +### معنی فیلدهای مهم + +| فیلد | توضیح | +|---|---| +| `since_id` | شناسه ترتیبی نوتیفیکیشن برای polling بعدی | +| `is_read` | وضعیت خوانده‌شدن نوتیفیکیشن | +| `level` | سطح نوتیفیکیشن مثل `info`، `warning`، `critical` | +| `metadata` | اطلاعات تکمیلی برای UI یا رفتارهای خاص | + +### نکته مهم برای فرانت + +بعد از هر بار دریافت response: + +1. اگر `data` خالی نبود، `since_id` آخرین آیتم را نگه دارید. +2. در درخواست بعدی، همان مقدار را به‌عنوان `since_id` بفرستید. +3. برای badge یا شمارنده unread، از `is_read` استفاده کنید. + +--- + +## 2) خوانده‌کردن نوتیفیکیشن‌ها + +### Endpoint + +```http +POST /api/notifications/mark-as-read/ +``` + +### Body + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "slice_id": 13 +} +``` + +### معنی `slice_id` + +`slice_id` یعنی: + +- همه نوتیفیکیشن‌های همان مزرعه که `id <= slice_id` دارند +- و هنوز `is_read=false` هستند +- به `is_read=true` تغییر می‌کنند + +به بیان ساده، اگر کاربر تا یک نقطه از لیست نوتیفیکیشن‌ها را دیده، فرانت می‌تواند `since_id` آخرین آیتم دیده‌شده را به‌عنوان `slice_id` ارسال کند. + +### نمونه response موفق + +```json +{ + "code": 200, + "msg": "success", + "marked_count": 4 +} +``` + +### معنی `marked_count` + +تعداد نوتیفیکیشن‌هایی که واقعا در دیتابیس از unread به read تغییر کرده‌اند. + +--- + +## خطاهای رایج + +### مزرعه پیدا نشد + +اگر `farm_uuid` متعلق به کاربر نباشد یا وجود نداشته باشد: + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +### جدول نوتیفیکیشن آماده نیست + +اگر migrationهای بک‌اند اجرا نشده باشند: + +```json +{ + "code": 503, + "msg": "Notifications table is not ready. Run migrations." +} +``` + +--- + +## پیشنهاد Flow برای فرانت + +### سناریوی پیشنهادی + +1. کاربر یک مزرعه active انتخاب می‌کند. +2. فرانت `farm_uuid` آن مزرعه را در state نگه می‌دارد. +3. اولین بار: + +```http +GET /api/notifications/long-poll/?farm_uuid=&timeout=0 +``` + +4. آخرین `since_id` را ذخیره می‌کند. +5. بعد از آن polling را با `since_id` ادامه می‌دهد: + +```http +GET /api/notifications/long-poll/?farm_uuid=&since_id=&timeout=15 +``` + +6. وقتی کاربر نوتیفیکیشن‌ها را دید، فرانت آخرین آیتم دیده‌شده را با `slice_id` به endpoint خوانده‌شدن می‌فرستد. + +--- + +## پیشنهاد پیاده‌سازی در فرانت + +- برای هر `farm_uuid` یک `last_since_id` جدا نگه دارید. +- لیست نوتیفیکیشن‌ها را per-farm در state نگه دارید. +- unread count را از روی آیتم‌هایی که `is_read=false` دارند محاسبه کنید. +- وقتی کاربر صفحه نوتیفیکیشن را باز کرد یا لیست را تا انتها دید، `slice_id` آخرین آیتم دیده‌شده را ارسال کنید. + +--- + +## routeهای فعال فعلی + +| Method | Path | توضیح | +|---|---|---| +| GET | `/api/notifications/long-poll/` | دریافت نوتیفیکیشن‌ها با پشتیبانی از `since_id` | +| POST | `/api/notifications/mark-as-read/` | خوانده‌کردن نوتیفیکیشن‌ها تا `slice_id` | + diff --git a/notifications/NOTIFICATION_API_CHANGES.md b/notifications/NOTIFICATION_API_CHANGES.md new file mode 100644 index 0000000..de7aef4 --- /dev/null +++ b/notifications/NOTIFICATION_API_CHANGES.md @@ -0,0 +1,80 @@ +# Notification API Changes + +## Added paginated notification list API + +A new endpoint was added to return all notifications for a farm using pagination. + +### Endpoint + +`GET /api/notifications/list/` + +### Query params + +- `farm_uuid` (required): UUID of the farm +- `page` (optional): page number, default depends on DRF pagination behavior +- `page_size` (optional): number of items per page, default `10`, max `100` + +### Behavior + +- Requires authenticated user +- Returns notifications only if the farm belongs to the authenticated user +- Orders notifications by newest first using `created_at DESC, id DESC` +- Returns paginated response +- Returns `404` if the farm is not found or does not belong to the user + +### Response shape + +```json +{ + "count": 12, + "next": "http://localhost:8000/api/notifications/list/?farm_uuid=&page=2&page_size=5", + "previous": null, + "results": { + "code": 200, + "msg": "success", + "data": [ + { + "uuid": "...", + "farm_uuid": "...", + "since_id": 12, + "title": "Alert", + "message": "Check sensor", + "level": "info", + "is_read": false, + "metadata": {}, + "created_at": "2025-01-01T10:00:00Z" + } + ] + } +} +``` + +## Long-poll behavior update + +The `long-poll` notification logic was updated to prioritize unread notifications. + +### Updated behavior + +- Returns unread notifications first +- If unread notifications are fewer than `5`, fills the remaining slots with read notifications +- If unread notifications are `5` or more, returns only the first `5` unread notifications + +### Notes + +This behavior was implemented in the notification service layer so the existing long-poll endpoint automatically uses it. + +## Files changed + +- `notifications/views.py` +- `notifications/urls.py` +- `notifications/services.py` +- `notifications/tests.py` + +## Tests added + +Added tests for: + +- paginated notification list for owned farm +- `404` for unowned farm on list API +- unread-first ordering in long-poll +- long-poll fallback with read notifications when unread count is below `5` diff --git a/notifications/services.py b/notifications/services.py index c0e450d..0f74e9a 100644 --- a/notifications/services.py +++ b/notifications/services.py @@ -1,7 +1,8 @@ import time from django.db import OperationalError, ProgrammingError -from django.db.models import QuerySet +from django.db.models import Case, IntegerField, QuerySet, Value, When + from farm_hub.models import FarmHub @@ -39,6 +40,30 @@ def get_notifications_for_farm(*, farm: FarmHub, since_id=None) -> QuerySet[Farm raise ValueError("Notifications table is not migrated.") from exc +def get_prioritized_notifications_for_farm(*, farm: FarmHub, since_id=None, limit=5) -> QuerySet[FarmNotification]: + try: + unread_queryset = get_notifications_for_farm(farm=farm, since_id=since_id).filter(is_read=False) + unread_count = unread_queryset.count() + + if unread_count >= limit: + return unread_queryset[:limit] + + fallback_limit = max(limit - unread_count, 0) + if fallback_limit == 0: + return unread_queryset + + queryset = get_notifications_for_farm(farm=farm, since_id=since_id).annotate( + priority=Case( + When(is_read=False, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + return queryset.order_by("priority", "created_at", "id")[:limit] + except (ProgrammingError, OperationalError) as exc: + 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( @@ -50,10 +75,10 @@ def mark_notifications_as_read(*, farm: FarmHub, slice_id: int) -> int: 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): +def long_poll_notifications(*, farm: FarmHub, since_id=None, timeout_seconds=DEFAULT_POLL_TIMEOUT_SECONDS, interval_seconds=DEFAULT_POLL_INTERVAL_SECONDS, limit=5): deadline = time.monotonic() + max(timeout_seconds, 0) while True: - notifications = list(get_notifications_for_farm(farm=farm, since_id=since_id)) + notifications = list(get_prioritized_notifications_for_farm(farm=farm, since_id=since_id, limit=limit)) if notifications: return notifications if time.monotonic() >= deadline: diff --git a/notifications/tests.py b/notifications/tests.py index 3bd3c99..0eb174d 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, mark_notifications_as_read -from .views import ExternalNotificationIngestView, NotificationLongPollView, NotificationMarkReadView +from .services import create_notification_for_farm_uuid, get_prioritized_notifications_for_farm, long_poll_notifications, mark_notifications_as_read +from .views import ExternalNotificationIngestView, NotificationListView, NotificationLongPollView, NotificationMarkReadView class NotificationServiceTests(TestCase): @@ -72,6 +72,26 @@ class NotificationServiceTests(TestCase): self.assertFalse(third.is_read) + def test_get_prioritized_notifications_for_farm_returns_unread_first_and_fills_with_read(self): + unread_one = FarmNotification.objects.create(farm=self.farm, title="Unread 1", message="A") + unread_two = FarmNotification.objects.create(farm=self.farm, title="Unread 2", message="B") + read_one = FarmNotification.objects.create(farm=self.farm, title="Read 1", message="C", is_read=True) + read_two = FarmNotification.objects.create(farm=self.farm, title="Read 2", message="D", is_read=True) + + notifications = list(get_prioritized_notifications_for_farm(farm=self.farm, limit=5)) + + self.assertEqual([notification.id for notification in notifications], [unread_one.id, unread_two.id, read_one.id, read_two.id]) + + def test_long_poll_notifications_limits_to_five_unread_notifications(self): + for index in range(6): + FarmNotification.objects.create(farm=self.farm, title=f"Unread {index}", message="B") + + notifications = long_poll_notifications(farm=self.farm, timeout_seconds=0, limit=5) + + self.assertEqual(len(notifications), 5) + self.assertTrue(all(notification.is_read is False for notification in notifications)) + + class NotificationLongPollViewTests(TestCase): def setUp(self): self.factory = APIRequestFactory() @@ -107,6 +127,23 @@ class NotificationLongPollViewTests(TestCase): self.assertEqual(response.data["data"][0]["since_id"], notification.id) self.assertFalse(response.data["data"][0]["is_read"]) + def test_long_poll_view_returns_unread_first_then_read_when_unread_is_less_than_five(self): + unread_one = FarmNotification.objects.create(farm=self.farm, title="Unread 1", message="A") + unread_two = FarmNotification.objects.create(farm=self.farm, title="Unread 2", message="B") + read_one = FarmNotification.objects.create(farm=self.farm, title="Read 1", message="C", is_read=True) + read_two = FarmNotification.objects.create(farm=self.farm, title="Read 2", message="D", is_read=True) + 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( + [item["since_id"] for item in response.data["data"]], + [unread_one.id, unread_two.id, read_one.id, read_two.id], + ) + self.assertEqual([item["is_read"] for item in response.data["data"]], [False, False, True, True]) + 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) @@ -196,6 +233,54 @@ class NotificationMarkReadViewTests(TestCase): self.assertEqual(response.data["msg"], "Farm not found.") +class NotificationListViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="notif-list-user", + password="secret123", + email="notif-list@example.com", + phone_number="09120000017", + ) + self.other_user = get_user_model().objects.create_user( + username="notif-list-other-user", + password="secret123", + email="notif-list-other@example.com", + phone_number="09120000018", + ) + self.farm_type = FarmType.objects.create(name="مرغداری") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm E", + ) + + def test_list_view_returns_paginated_notifications_for_owned_farm(self): + for index in range(12): + FarmNotification.objects.create(farm=self.farm, title=f"Alert {index}", message="Check sensor") + + request = self.factory.get( + f"/api/notifications/list/?farm_uuid={self.farm.farm_uuid}&page=2&page_size=5" + ) + force_authenticate(request, user=self.user) + + response = NotificationListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 12) + self.assertEqual(len(response.data["results"]["data"]), 5) + self.assertEqual(response.data["results"]["code"], 200) + + def test_list_view_returns_404_for_unowned_farm(self): + request = self.factory.get(f"/api/notifications/list/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.other_user) + + response = NotificationListView.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 5d7ef66..9de53bf 100644 --- a/notifications/urls.py +++ b/notifications/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from .views import NotificationLongPollView, NotificationMarkReadView +from .views import NotificationListView, NotificationLongPollView, NotificationMarkReadView urlpatterns = [ + path("list/", NotificationListView.as_view(), name="notification-list"), 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 7d598f4..66e2cf7 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -1,6 +1,7 @@ from django.conf import settings from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -18,6 +19,16 @@ class NotificationLongPollQuerySerializer(serializers.Serializer): timeout = serializers.IntegerField(required=False, min_value=0, max_value=60) +class NotificationListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + page = serializers.IntegerField(required=False, min_value=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100) + + +class NotificationPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = "page_size" + max_page_size = 100 class NotificationMarkReadSerializer(serializers.Serializer): @@ -69,6 +80,41 @@ class NotificationLongPollView(APIView): return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) +class NotificationListView(APIView): + permission_classes = [IsAuthenticated] + pagination_class = NotificationPagination + + @extend_schema( + tags=["Notifications"], + parameters=[NotificationListQuerySerializer], + responses={ + 200: code_response("NotificationListResponse"), + 404: code_response("NotificationListNotFoundResponse"), + }, + ) + def get(self, request): + serializer = NotificationListQuerySerializer(data=request.query_params) + 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) + + paginator = self.pagination_class() + notifications = farm.notifications.all().order_by("-created_at", "-id") + page = paginator.paginate_queryset(notifications, request, view=self) + data = FarmNotificationSerializer(page, many=True).data + + return paginator.get_paginated_response({ + "code": 200, + "msg": "success", + "data": data, + }) + + class NotificationMarkReadView(APIView): permission_classes = [IsAuthenticated]