diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3a9c14e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This repository is a Django REST backend organized by app domains. Core configuration lives in `config/` (`settings.py`, `urls.py`, `celery.py`). Feature apps (for example `account/`, `farm_hub/`, `sensor_catalog/`, `crop_zoning/`, `notifications/`) each contain `models.py`, `serializers.py`, `views.py`, `urls.py`, and app-specific `migrations/`. + +Tests are mostly colocated in each app as `tests.py`. Mock payloads and integration fixtures are stored under `json/mock_data/` and `external_api_adapter/json/`. API collections are under `*/postman/`. + +## Build, Test, and Development Commands +- `python -m venv .venv && source .venv/bin/activate` - create and activate a virtual environment. +- `pip install -r requirements.txt` - install runtime and development dependencies. +- `python manage.py migrate` - apply database migrations. +- `python manage.py runserver` - start the local API server. +- `python manage.py test` - run the full Django test suite. +- `python manage.py test farm_hub` - run tests for a single app. +- `docker compose up --build` - run the backend and dependencies via Docker. + +## Coding Style & Naming Conventions +Follow standard Django/Python conventions: +- 4-space indentation, snake_case for functions/variables, PascalCase for classes. +- Keep serializers in `serializers.py`, business logic in `services.py`, and routing in `urls.py`. +- Name management commands as verbs (for example `seed_admin_user`). +- Prefer small, app-scoped modules over cross-app imports unless shared behavior is intentional. + +## Testing Guidelines +Use Django’s built-in test runner (`unittest` style). Place tests in each app’s `tests.py` (or `tests/` package if expanded). Name tests as `test_` and cover serializers, view responses, and service edge cases. Use `unittest.mock.patch` for external integrations (AI adapters, SMS, or HTTP services). + +## Commit & Pull Request Guidelines +Current history uses generic messages (`UPDATE`), but contributors should use clear, imperative commits such as `add sensor catalog seed command`. + +For PRs, include: +- concise description of scope and affected apps, +- migration notes (`manage.py makemigrations`/`migrate` impact), +- test evidence (`python manage.py test ...` output), +- linked issue/task ID when available, +- request/response examples for API changes (Postman or JSON sample paths). + +## Security & Configuration Tips +Copy `.env.example` to `.env` and never commit secrets. Validate CORS/JWT settings in `config/settings.py` per environment. Keep mock JSON and seed data free of production credentials or personal data. diff --git a/NOTIFICATIONS_FRONTEND_USAGE_FA.md b/NOTIFICATIONS_FRONTEND_USAGE_FA.md deleted file mode 100644 index e4d3010..0000000 --- a/NOTIFICATIONS_FRONTEND_USAGE_FA.md +++ /dev/null @@ -1,115 +0,0 @@ -# راهنمای استفاده فرانت از سیستم نوتیفیکیشن (SSE + Redis) - -این سند روش اتصال فرانت به سیستم نوتیفیکیشن جدید را توضیح می‌دهد. - -## 1) APIهای موجود - -- استریم نوتیفیکیشن (SSE): - - `GET /api/notifications/stream/?channel=` -- ارسال نوتیفیکیشن (برای تست/ادمین): - - `POST /api/notifications/publish/` - -هر دو endpoint نیازمند احراز هویت هستند. - -## 2) فرمت پیام دریافتی در SSE - -payload نمونه: - -```json -{ - "id": "f6f5d6ca-54f1-4d0e-8d29-5ef5760a3b40", - "event": "notification", - "title": "آبیاری", - "message": "زمان آبیاری مزرعه فرا رسیده است.", - "level": "info", - "metadata": { - "farm_uuid": "11111111-1111-1111-1111-111111111111" - }, - "created_at": "2026-04-03T20:00:00.000000+00:00" -} -``` - -`event` به‌صورت SSE event name هم ارسال می‌شود. - -## 3) انتخاب channel - -الگوی پیشنهادی: - -- کانال کاربر: `user-` -- کانال مزرعه: `farm-` - -در وضعیت فعلی backend اگر `channel` نفرستید، پیش‌فرض روی `user-` است. - -## 4) اتصال در فرانت - -نکته مهم: چون backend روی JWT (`Authorization: Bearer ...`) است، `EventSource` پیش‌فرض مرورگر امکان ارسال header سفارشی ندارد. -پس یا از polyfill/library استفاده کنید، یا مکانیزم auth مبتنی بر cookie داشته باشید. - -نمونه با `@microsoft/fetch-event-source`: - -```js -import { fetchEventSource } from "@microsoft/fetch-event-source"; - -const API_BASE = "https://your-domain.com"; -const token = localStorage.getItem("access_token"); -const channel = "user-123"; - -await fetchEventSource(`${API_BASE}/api/notifications/stream/?channel=${channel}`, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - Accept: "text/event-stream" - }, - onopen(response) { - if (!response.ok) throw new Error("SSE connection failed"); - }, - onmessage(event) { - if (!event.data) return; - const payload = JSON.parse(event.data); - // نمایش toast / badge / in-app notification - console.log("notification:", payload); - }, - onerror(err) { - console.error("SSE error", err); - // کتابخانه به شکل پیش‌فرض reconnect می‌کند - } -}); -``` - -## 5) ارسال نوتیفیکیشن (برای تست از فرانت یا پنل ادمین) - -درخواست: - -```http -POST /api/notifications/publish/ -Authorization: Bearer -Content-Type: application/json -``` - -بدنه: - -```json -{ - "channel": "user-123", - "title": "نمونه نوتیف", - "message": "این پیام تستی است", - "level": "info", - "event": "notification", - "metadata": { - "farm_uuid": "11111111-1111-1111-1111-111111111111" - } -} -``` - -## 6) پیشنهاد UX در فرانت - -- روی `level`، رنگ‌بندی toast انجام دهید (`info/success/warning/error`). -- `metadata` را برای deep-link استفاده کنید (مثلاً رفتن به صفحه مزرعه). -- هنگام logout، اتصال SSE را قطع کنید. -- هنگام تغییر کاربر یا مزرعه، channel را عوض کنید و اتصال قبلی را ببندید. - -## 7) خطاهای رایج - -- `401 Unauthorized`: توکن نامعتبر/منقضی شده. -- `403 Forbidden`: کاربر دسترسی لازم به endpoint ندارد. -- اتصال برقرار می‌شود ولی پیامی نمی‌آید: channel اشتباه است یا پیام روی channel دیگری publish شده است. diff --git a/config/settings.py b/config/settings.py index 6c4bab8..dfa18f7 100644 --- a/config/settings.py +++ b/config/settings.py @@ -39,6 +39,7 @@ INSTALLED_APPS = [ "farm_ai_assistant", "notifications.apps.NotificationsConfig", "external_api_adapter.apps.ExternalApiAdapterConfig", + "sensor_external_api.apps.SensorExternalApiConfig", "rest_framework", "drf_spectacular", "drf_spectacular_sidecar", @@ -134,6 +135,19 @@ SPECTACULAR_SETTINGS = { "REDOC_DIST": "SIDECAR", "SCHEMA_PATH_PREFIX": r"/api/", "SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"], + "APPEND_COMPONENTS": { + "securitySchemes": { + "SensorExternalApiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Use API key 12345 for sensor external API endpoints.", + } + } + }, + "SWAGGER_UI_SETTINGS": { + "persistAuthorization": True, + }, } @@ -169,6 +183,8 @@ CROP_ZONE_TASK_STALE_SECONDS = int(os.getenv("CROP_ZONE_TASK_STALE_SECONDS", "30 CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0") CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) NOTIFICATION_REDIS_URL = os.getenv("NOTIFICATION_REDIS_URL", CELERY_BROKER_URL) +EXTERNAL_NOTIFICATION_API_KEY = os.getenv("EXTERNAL_NOTIFICATION_API_KEY", "12345") +SENSOR_EXTERNAL_API_KEY = os.getenv("SENSOR_EXTERNAL_API_KEY", "12345") CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "default") CELERY_TASK_ACKS_LATE = True CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1")) diff --git a/config/urls.py b/config/urls.py index ed27d18..85f3b70 100644 --- a/config/urls.py +++ b/config/urls.py @@ -21,4 +21,5 @@ urlpatterns = [ path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")), path("api/farm-ai-assistant/", include("farm_ai_assistant.urls")), path("api/notifications/", include("notifications.urls")), + path("api/sensor-external-api/", include("sensor_external_api.urls")), ] diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 257e9cd..5a68f9b 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -45,6 +45,7 @@ services: CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0} QDRANT_HOST: ${QDRANT_HOST:-qdrant} QDRANT_PORT: ${QDRANT_PORT:-6333} + SKIP_MIGRATE: "0" depends_on: db: condition: service_healthy diff --git a/docker-compose.yaml b/docker-compose.yaml index e8a9643..6ada480 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -68,6 +68,7 @@ services: CELERY_RESULT_BACKEND: redis://redis:6379/0 QDRANT_HOST: qdrant QDRANT_PORT: 6333 + SKIP_MIGRATE: "0" depends_on: db: condition: service_healthy diff --git a/notifications/__init__.py b/notifications/__init__.py index 8b13789..e69de29 100644 --- a/notifications/__init__.py +++ b/notifications/__init__.py @@ -1 +0,0 @@ - diff --git a/notifications/apps.py b/notifications/apps.py index 3a08476..ef841d8 100644 --- a/notifications/apps.py +++ b/notifications/apps.py @@ -4,3 +4,4 @@ from django.apps import AppConfig class NotificationsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "notifications" + verbose_name = "Notifications" diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..7bc4cb8 --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -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"], + }, + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/notifications/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000..a12f748 --- /dev/null +++ b/notifications/models.py @@ -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}" diff --git a/notifications/serializers.py b/notifications/serializers.py index 2f22165..408d7eb 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -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", + ] diff --git a/notifications/services.py b/notifications/services.py index 899c0b6..14a0349 100644 --- a/notifications/services.py +++ b/notifications/services.py @@ -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)) diff --git a/notifications/tests.py b/notifications/tests.py index 410b6b3..3968bb5 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -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.") diff --git a/notifications/urls.py b/notifications/urls.py index 18c8e8a..2990ed0 100644 --- a/notifications/urls.py +++ b/notifications/urls.py @@ -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"), ] diff --git a/notifications/views.py b/notifications/views.py index 7cda767..d62ff67 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -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) + + diff --git a/prm.md b/prm.md new file mode 100644 index 0000000..235ebc2 --- /dev/null +++ b/prm.md @@ -0,0 +1,261 @@ +# گزارش بررسی معماری و پیاده سازی پروژه CropLogic + +## دامنه بررسی + +- این بررسی فقط روی کد موجود در ریپازیتوری انجام شد. +- طبق درخواست شما هیچ تغییری در کد اپلیکیشن داده نشد. +- برای این گزارش تست اجرایی سراسری اجرا نشد؛ جمع بندی بر پایه بازبینی ساختار، تنظیمات، مدل ها، ویوها، سرویس ها و تست های موجود است. + +## جمع بندی سریع + +پروژه از نظر تفکیک دامنه ها به چند اپ Django ساختار قابل فهمی دارد، اما چند ضعف جدی در مرزهای معماری، امنیت APIها، پایداری در کار با سرویس های خارجی، و یکپارچگی داده ها دیده می شود. مهم ترین ریسک ها این ها هستند: + +1. سیستم نوتیفیکیشن از نظر مجوزدهی ناامن است و کاربر احراز هویت شده می تواند به کانال دلخواه subscribe/publish کند. +2. ویرایش سنسورهای مزرعه باعث حذف و بازسازی کامل سنسورها می شود و به خاطر `CASCADE` شدن، تاریخچه داده های سنسور هم از بین می رود. +3. ساخت مزرعه و زون بندی، dispatch تسک های async را قبل از commit نهایی تراکنش بالادستی شروع می کند و مستعد race condition است. +4. چند API وابسته به سرویس خارجی، خطاهای شبکه/پیکربندی را handle نمی کنند و به احتمال زیاد با 500 خام fail می شوند. + +### 3) dispatch تسک های زون بندی داخل جریان تراکنش بالادستی انجام می شود + +شدت: بالا + +شواهد: + +- در `farm_hub/services.py:19` تا `farm_hub/services.py:23` ساخت مزرعه داخل `transaction.atomic()` انجام می شود. +- همانجا `dispatch_farm_zoning` صدا زده می شود. +- در `crop_zoning/services.py:897` تا `crop_zoning/services.py:935` بعد از ساخت `CropArea` و `CropZone`ها، تابع `dispatch_zone_processing_tasks` اجرا می شود. + +اثر: + +- `create_zones_and_dispatch` داخل یک atomic داخلی صدا زده می شود، اما هنوز transaction بیرونی `create_farm_with_zoning` commit نشده است. +- worker ممکن است قبل از commit نهایی شروع شود و داده های وابسته را نبیند یا وضعیت ناقص ببیند. +- این مشکل در محیط های واقعی، intermittent و سخت برای دیباگ خواهد بود. + +پیشنهاد: + +- dispatch تسک ها باید با `transaction.on_commit(...)` انجام شود. +- ساخت entityها و شروع پردازش async باید کاملا از هم جدا شوند. +- بهتر است workflow ایجاد مزرعه -> commit -> enqueue zoning -> poll status به صورت صریح طراحی شود. + +### 4) وابستگی به سرویس خارجی در چند endpoint بدون fail-safe مناسب است + +شدت: بالا + +شواهد: + +- `dashboard/views.py:126` تا `dashboard/views.py:134` +- `irrigation_recommendation/views.py:63` تا `irrigation_recommendation/views.py:79` +- `irrigation_recommendation/views.py:125` تا `irrigation_recommendation/views.py:136` +- `fertilization_recommendation/views.py:63` تا `fertilization_recommendation/views.py:80` +- `fertilization_recommendation/views.py:94` تا `fertilization_recommendation/views.py:105` +- در `external_api_adapter/adapter.py:50` و `external_api_adapter/adapter.py:70` خطاهای config/network به `ExternalAPIRequestError` تبدیل می شوند، اما endpoint های بالا آن را catch نمی کنند. + +اثر: + +- اگر `base_url` تنظیم نشده باشد یا شبکه/سرویس خارجی down باشد، پاسخ API داخلی احتمالا 500 خام می شود. +- رفتار endpoint ها ناهمگون است: مثلا `farm_ai_assistant` این خطا را handle می کند، اما dashboard/irrigation/fertilization نه. +- این ناهمگونی نگهداری و مانیتورینگ را سخت می کند. + +پیشنهاد: + +- یک لایه مشترک برای map کردن خطاهای external dependency به 502/503 ایجاد شود. +- همه endpoint های adapter-based رفتار یکسان داشته باشند. +- timeout، retry policy، circuit breaker و logging ساختاریافته برای این لایه لازم است. + +### 5) GET مربوط به crop zoning دارای side effect است + +شدت: متوسط رو به بالا + +شواهد: + +- در `crop_zoning/views.py:57` تا `crop_zoning/views.py:68` endpoint `GET /area/` فقط read نیست. +- در `crop_zoning/services.py:869` تا `crop_zoning/services.py:892` این GET می تواند: + - area جدید بسازد + - zone بسازد + - rule-based data تولید کند + - task جدید dispatch کند + +اثر: + +- semantics REST شکسته می شود؛ GET دیگر safe/read-only نیست. +- cache کردن، replay، tracing و حتی load-testing این endpoint رفتار غیرمنتظره پیدا می کند. +- اگر frontend چند بار polling کند، endpoint read عملا orchestration engine می شود. + +پیشنهاد: + +- endpoint creation/recompute باید POST باشد. +- GET فقط باید status/result را برگرداند. +- orchestration crop zoning بهتر است state machine یا task-oriented API شفاف داشته باشد. + +### 7) خطای یکتایی email در update پروفایل می تواند 500 بدهد + +شدت: متوسط + +شواهد: + +- در `account/views.py:53` تا `account/views.py:60` فیلدهای کاربر مستقیم set و save می شوند. +- مدل `User` در `account/models.py` ایمیل unique دارد. +- در این مسیر هیچ `IntegrityError`ی handle نشده است. + +اثر: + +- اگر کاربر ایمیلی را بگذارد که قبلا ثبت شده، به جای خطای business-level، احتمالا 500 برمی گردد. +- تجربه API ناهمگون می شود، چون در `RegisterView` برای uniqueness handling وجود دارد ولی در profile update نه. + +پیشنهاد: + +- validation uniqueness باید در serializer انجام شود. +- `save()` هم باید با handling مناسب برای race condition همراه باشد. + +### 8) ingestion سنسور به availability سرویس نوتیفیکیشن گره خورده است + +شدت: متوسط + +شواهد: + +- در `external_sensor_api/views.py:53` تا `external_sensor_api/views.py:80` ابتدا reading در DB ذخیره می شود و بعد `publish_notification` صدا زده می شود. +- در `notifications/services.py:24` تا `notifications/services.py:25` publish بدون try/except انجام می شود. + +اثر: + +- اگر Redis قطع باشد، ingest ممکن است بعد از ذخیره شدن داده با exception fail کند. +- نتیجه می تواند پاسخ 500 به sender باشد، در حالی که reading واقعا ثبت شده است. +- این باعث رفتار non-idempotent و retry خطرناک می شود: فرستنده دوباره retry می کند و رکورد duplicate می سازد. + +پیشنهاد: + +- publish notification باید best-effort و جدا از مسیر اصلی ingestion باشد. +- برای sensor ingest بهتر است notification async شود. +- اگر notification شکست خورد، ثبت reading نباید rollback منطقی API را مخدوش کند. + +### 9) طراحی authentication برای OTP در مقیاس چند پردازه/چند نود پایدار نیست + +شدت: متوسط + +شواهد: + +- در `config/settings.py:110` تا `config/settings.py:115` cache از نوع `LocMemCache` است. +- منطق OTP در `auth/views.py` از cache برای نگهداری کد استفاده می کند. +- هرچند routeهای OTP در `auth/urls.py:8` تا `auth/urls.py:9` فعلا کامنت شده اند، طراحی فعلی برای production-ready بودن کافی نیست. + +اثر: + +- در deployment چند worker یا چند instance، درخواست `request-otp` و `verify-otp` ممکن است به نودهای مختلف بخورند و verify شکست بخورد. +- این یعنی طراحی احراز هویت به process-local memory وابسته است. + +پیشنهاد: + +- cache/shared store مثل Redis برای OTP استفاده شود. +- rate limit، lockout، replay protection و audit logging هم به این flow اضافه شود. + +### 10) نشانه های واضحی از ناتمام بودن مرزهای API و drift بین کد و قرارداد وجود دارد + +شدت: متوسط + +شواهد: + +- routeهای مهمی کامنت شده اند: `auth/urls.py:8` تا `auth/urls.py:9`، `account/urls.py:7` تا `account/urls.py:8`، `crop_zoning/urls.py:16` تا `crop_zoning/urls.py:31`، `farm_ai_assistant/urls.py:15` +- کلاس ها و viewهای مرتبط هنوز در کد حضور دارند، اما publicly exposed نیستند. + +اثر: + +- فهمیدن وضعیت واقعی featureها برای توسعه دهنده جدید سخت می شود. +- schema و مستندات ممکن است با قابلیت واقعی محصول همگام نباشد. +- این وضعیت معمولا نشانه این است که API lifecycle و deprecation strategy شفاف نیست. + +پیشنهاد: + +- featureهای غیرفعال یا باید حذف شوند، یا با feature flag و مستندات واضح مدیریت شوند. +- برای endpointهای deprecated یا موقت، policy مشخص publish/unpublish لازم است. + +## ضعف های معماری کلان + +### 1) تکرار زیاد منطق دسترسی به مزرعه + +در چند اپ مختلف mixin مشابه برای resolve کردن `farm_uuid` و ownership تکرار شده: + +- `dashboard/views.py:20` +- `irrigation_recommendation/views.py:24` +- `fertilization_recommendation/views.py:24` +- `farm_ai_assistant/views.py:28` +- `farm_hub/views.py:16` +- `access_control/views.py:15` + +اثر: + +- رفتار خطاها یکدست نیست. +- هر اپ interpretation متفاوتی از “farm not found / access denied / validation error” دارد. +- تغییر policy ownership یا prefetching باید در چند نقطه تکرار شود. + +پیشنهاد: + +- یک service/mixin/shared permission مشترک برای farm-scoped access ساخته شود. + +### 2) مرز بین دامنه های business و integration واضح نیست + +نمونه ها: + +- viewها مستقیم با `external_api_request(...)` کار می کنند. +- persistence، orchestration، و response-shaping در یک متد جمع شده است. +- در crop zoning هم business logic سنگین، persistence و async dispatch در یک service بزرگ متمرکز شده است. + +اثر: + +- تست واحد سخت تر می شود. +- وابستگی به provider خارجی به لایه API نشت کرده است. +- refactor یا جایگزینی provider بیرونی پرهزینه تر می شود. + +پیشنهاد: + +- use-case/service layer مشخص برای هر دامنه تعریف شود. +- adapter خارجی فقط در لایه integration بماند. +- view صرفا orchestration نهایی HTTP را انجام دهد. + +## وضعیت تست ها + +تست برای بعضی اپ ها وجود دارد، اما پوشش پروژه ناهمگن است. بر اساس ساختار فعلی، این اپ ها فاقد فایل تست قابل مشاهده هستند: + +- `access_control` +- `account` +- `auth` +- `external_api_adapter` +- `fertilization_recommendation` +- `irrigation_recommendation` +- `plant_simulator` +- `pest_detection` +- `sensor_ingest` + +این شکاف مخصوصا برای بخش های زیر نگران کننده است: + +- مجوزدهی و access control +- authentication +- integration با سرویس خارجی +- رفتار خطا و fallback +- data integrity سنسورها و readingها + +## اولویت پیشنهادی برای اصلاح + +### فوری + +1. بستن ضعف امنیتی notification stream/publish +2. جلوگیری از delete شدن سنسورها در update و حفظ تاریخچه reading +3. انتقال dispatch تسک ها به `transaction.on_commit` +4. یکسان سازی خطاهای external service و جلوگیری از 500 خام + +### بعدی + +1. جدا کردن endpointهای mock از API اصلی +2. حذف side effect از GETهای crop zoning +3. یکپارچه سازی farm access logic +4. افزودن تست برای auth, access_control, recommendations, integrations + +## نتیجه نهایی + +پروژه از نظر تقسیم اپ ها و شفاف بودن domain names شروع خوبی دارد، اما هنوز چند بخش مهم آن بیشتر شبیه نسخه integration-heavy و نیمه محصولی است تا یک backend production-hardened. بزرگ ترین ریسک های فعلی مربوط به: + +- امنیت notificationها +- از دست رفتن داده در lifecycle سنسورها +- race condition در zoning async flow +- ناپایداری در برابر خطاهای سرویس های خارجی + +اگر بخواهم فقط یک جمع بندی کوتاه بدهم: مشکل اصلی پروژه نه در syntax یا ساختار فایل ها، بلکه در مرزبندی responsibilityها، lifecycle داده ها، و رفتار fail-safe سیستم است. diff --git a/sensor_external_api/__init__.py b/sensor_external_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_external_api/apps.py b/sensor_external_api/apps.py new file mode 100644 index 0000000..7b5d409 --- /dev/null +++ b/sensor_external_api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SensorExternalApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "sensor_external_api" diff --git a/sensor_external_api/authentication.py b/sensor_external_api/authentication.py new file mode 100644 index 0000000..ee45007 --- /dev/null +++ b/sensor_external_api/authentication.py @@ -0,0 +1,22 @@ +from django.conf import settings +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + + +class SensorExternalAPIKeyAuthentication(BaseAuthentication): + keyword = "Api-Key" + + def authenticate(self, request): + provided_key = request.headers.get("X-API-Key") or request.headers.get("Authorization") + expected_key = getattr(settings, "SENSOR_EXTERNAL_API_KEY", "12345") + + if not provided_key: + raise AuthenticationFailed("API key is required.") + + if provided_key.startswith(f"{self.keyword} "): + provided_key = provided_key[len(self.keyword) + 1 :] + + if provided_key != expected_key: + raise AuthenticationFailed("Invalid API key.") + + return (None, None) diff --git a/sensor_external_api/migrations/__init__.py b/sensor_external_api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_external_api/serializers.py b/sensor_external_api/serializers.py new file mode 100644 index 0000000..6763bc8 --- /dev/null +++ b/sensor_external_api/serializers.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class SensorExternalRequestSerializer(serializers.Serializer): + payload = serializers.JSONField(required=False, default=dict) diff --git a/sensor_external_api/services.py b/sensor_external_api/services.py new file mode 100644 index 0000000..582220a --- /dev/null +++ b/sensor_external_api/services.py @@ -0,0 +1,20 @@ +from django.db import ProgrammingError, OperationalError + +from notifications.services import create_notification_for_farm_uuid + + +DEFAULT_SENSOR_EXTERNAL_FARM_UUID = "11111111-1111-1111-1111-111111111111" + + +def create_sensor_external_notification(*, payload=None): + payload = payload or {} + try: + return create_notification_for_farm_uuid( + farm_uuid=DEFAULT_SENSOR_EXTERNAL_FARM_UUID, + title="Sensor external API request", + message="A request was received by sensor_external_api.", + level="info", + metadata={"payload": payload}, + ) + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Notifications table is not migrated.") from exc diff --git a/sensor_external_api/tests.py b/sensor_external_api/tests.py new file mode 100644 index 0000000..bb1a1f5 --- /dev/null +++ b/sensor_external_api/tests.py @@ -0,0 +1,52 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework.test import APIRequestFactory + +from farm_hub.models import FarmHub, FarmType +from notifications.models import FarmNotification + +from .views import SensorExternalAPIView + + +@override_settings(SENSOR_EXTERNAL_API_KEY="12345") +class SensorExternalAPIViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="sensor-external-user", + password="secret123", + email="sensor-external@example.com", + phone_number="09120000015", + ) + self.farm_type = FarmType.objects.create(name="سنسور خارجی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm External", + farm_uuid="11111111-1111-1111-1111-111111111111", + ) + + def test_requires_api_key(self): + request = self.factory.post("/api/sensor-external-api/", {"payload": {"temp": 12}}, format="json") + + response = SensorExternalAPIView.as_view()(request) + + self.assertEqual(response.status_code, 401) + + def test_creates_notification_for_fixed_farm_uuid(self): + request = self.factory.post( + "/api/sensor-external-api/", + {"payload": {"temp": 12}}, + format="json", + HTTP_X_API_KEY="12345", + ) + + response = SensorExternalAPIView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + FarmNotification.objects.filter( + farm=self.farm, + title="Sensor external API request", + ).exists() + ) diff --git a/sensor_external_api/urls.py b/sensor_external_api/urls.py new file mode 100644 index 0000000..05e08df --- /dev/null +++ b/sensor_external_api/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import SensorExternalAPIView + +urlpatterns = [ + path("", SensorExternalAPIView.as_view(), name="sensor-external-api"), +] diff --git a/sensor_external_api/views.py b/sensor_external_api/views.py new file mode 100644 index 0000000..f410fa3 --- /dev/null +++ b/sensor_external_api/views.py @@ -0,0 +1,54 @@ +from rest_framework import status +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 config.swagger import code_response +from notifications.serializers import FarmNotificationSerializer + +from .authentication import SensorExternalAPIKeyAuthentication +from .serializers import SensorExternalRequestSerializer +from .services import create_sensor_external_notification + + +class SensorExternalAPIView(APIView): + authentication_classes = [SensorExternalAPIKeyAuthentication] + permission_classes = [AllowAny] + + @extend_schema( + tags=["Sensor External API"], + request=SensorExternalRequestSerializer, + parameters=[ + OpenApiParameter( + name="X-API-Key", + type=OpenApiTypes.STR, + location=OpenApiParameter.HEADER, + required=True, + default="12345", + description="API key for sensor external API.", + ) + ], + responses={ + 201: code_response("SensorExternalAPIResponse", data=FarmNotificationSerializer()), + 401: code_response("SensorExternalAPIUnauthorizedResponse"), + 404: code_response("SensorExternalAPIFarmNotFoundResponse"), + 503: code_response("SensorExternalAPINotificationsUnavailableResponse"), + }, + ) + def post(self, request): + serializer = SensorExternalRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + notification = create_sensor_external_notification(payload=serializer.validated_data.get("payload")) + 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, + ) + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + data = FarmNotificationSerializer(notification).data + return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)