diff --git a/.env.example b/.env.example index bcd0d5d..9a71f0f 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,12 @@ AI_SERVICE_API_KEY= FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com FARM_HUB_SERVICE_API_KEY= +FARM_DATA_API_HOST=http://localhost +FARM_DATA_API_PORT=8020 +FARM_DATA_API_PATH=/api/farm-data/ +FARM_DATA_API_KEY= +FARM_DATA_API_TIMEOUT=30 + CROP_ZONE_CHUNK_AREA_SQM=10000 CROP_ZONE_TASK_STALE_SECONDS=300 diff --git a/config/settings.py b/config/settings.py index dfa18f7..3f5ede2 100644 --- a/config/settings.py +++ b/config/settings.py @@ -185,6 +185,11 @@ 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") +FARM_DATA_API_HOST = os.getenv("FARM_DATA_API_HOST", "") +FARM_DATA_API_PORT = os.getenv("FARM_DATA_API_PORT", "") +FARM_DATA_API_PATH = os.getenv("FARM_DATA_API_PATH", "/api/farm-data/") +FARM_DATA_API_KEY = os.getenv("FARM_DATA_API_KEY", "") +FARM_DATA_API_TIMEOUT = int(os.getenv("FARM_DATA_API_TIMEOUT", str(EXTERNAL_API_TIMEOUT))) 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/sensor_external_api/SensorExternalRequestLogListAPIView.md b/sensor_external_api/SensorExternalRequestLogListAPIView.md new file mode 100644 index 0000000..f6a4234 --- /dev/null +++ b/sensor_external_api/SensorExternalRequestLogListAPIView.md @@ -0,0 +1,317 @@ +# مستند API لاگ درخواست های سنسور خارجی + +این فایل نحوه کار endpoint زیر را توضیح می دهد: + +`GET /sensor_external_api/logs/` + +مسیر مربوطه در فایل `sensor_external_api/urls.py` به این صورت ثبت شده است: + +```python +path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list") +``` + +## هدف API + +این API برای مشاهده لیست لاگ درخواست هایی استفاده می شود که از سنسورهای خارجی برای یک مزرعه مشخص ثبت شده اند. + +هر لاگ شامل اطلاعات زیر است: +- شناسه لاگ +- `farm_uuid` +- `sensor_catalog_uuid` +- `physical_device_uuid` +- `payload` دریافتی از سنسور +- زمان ثبت لاگ +- اطلاعات سنسور مزرعه (`farm_sensor`) +- اطلاعات کاتالوگ سنسور (`sensor_catalog`) + +## کلاس View + +این endpoint در کلاس `SensorExternalRequestLogListAPIView` داخل فایل `sensor_external_api/views.py` پیاده سازی شده است. + +ویژگی های مهم این View: +- فقط متد `GET` را پشتیبانی می کند. +- نیاز به احراز هویت دارد. +- از صفحه بندی استفاده می کند. +- لاگ ها را بر اساس `farm_uuid` فیلتر می کند. + +## احراز هویت و دسترسی + +در این View مقدار زیر تعریف شده است: + +```python +permission_classes = [IsAuthenticated] +``` + +یعنی کاربر باید authenticated باشد تا بتواند این API را صدا بزند. + +نکته مهم: +در تست ها، اگر درخواست بدون اعتبارنامه ارسال شود، پاسخ `401 Unauthorized` برمی گردد. + +## پارامترهای ورودی + +این API پارامترهای query string زیر را دریافت می کند: + +- `farm_uuid` اجباری +- `page` اختیاری، پیش فرض `1` +- `page_size` اختیاری، پیش فرض `20` و حداکثر `100` + +اعتبارسنجی پارامترها توسط `SensorExternalRequestLogQuerySerializer` در فایل `sensor_external_api/serializers.py` انجام می شود: + +```python +class SensorExternalRequestLogQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + page = serializers.IntegerField(required=False, min_value=1, default=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, default=20) +``` + +### نمونه درخواست + +```http +GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=20 +``` + +## روند اجرای API + +### 1) اعتبارسنجی query params +ابتدا `request.query_params` توسط serializer اعتبارسنجی می شود: + +```python +serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params) +serializer.is_valid(raise_exception=True) +``` + +اگر `farm_uuid` معتبر نباشد یا `page_size` خارج از بازه باشد، پاسخ خطای validation از DRF برمی گردد. + +### 2) گرفتن لاگ های مربوط به مزرعه +سپس سرویس `get_sensor_external_request_logs_for_farm` فراخوانی می شود: + +```python +queryset = get_sensor_external_request_logs_for_farm( + farm_uuid=serializer.validated_data["farm_uuid"], +) +``` + +این سرویس در فایل `sensor_external_api/services.py` تعریف شده و لاگ ها را از جدول `sensor_external_request_logs` می خواند: + +```python +SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id") +``` + +پس ترتیب خروجی به این صورت است: +- جدیدترین لاگ ها اول نمایش داده می شوند. +- اگر `created_at` برابر باشد، لاگ با `id` بزرگ تر زودتر می آید. + +### 3) مدیریت خطای migration +اگر جدول های لازم هنوز migrate نشده باشند، سرویس خطا را به `ValueError` تبدیل می کند و View این پاسخ را برمی گرداند: + +```json +{ + "code": 503, + "msg": "Required tables are not ready. Run migrations." +} +``` + +با status code برابر با `503 Service Unavailable`. + +### 4) صفحه بندی نتایج +این View از paginator زیر استفاده می کند: + +```python +class SensorExternalRequestLogPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 +``` + +و در View نیز `page_size` از داده معتبرشده serializer روی paginator اعمال می شود: + +```python +paginator = self.pagination_class() +paginator.page_size = serializer.validated_data["page_size"] +page = paginator.paginate_queryset(queryset, request, view=self) +``` + +### 5) ساخت map از سنسورهای مزرعه +برای اینکه هر لاگ همراه با اطلاعات سنسور مزرعه و کاتالوگ سنسور برگردد، این سرویس صدا زده می شود: + +```python +farm_sensor_map = get_farm_sensor_map_for_logs(logs=page) +``` + +این سرویس: +- لاگ های همان page را می گیرد. +- `FarmSensor` های متناظر را از دیتابیس پیدا می کند. +- یک map با کلید زیر می سازد: + +```python +(farm_uuid, sensor_catalog_uuid, physical_device_uuid) +``` + +به کمک این map، serializer می تواند برای هر لاگ اطلاعات تکمیلی را پر کند. + +## ساختار serializer خروجی + +خروجی هر آیتم با `SensorExternalRequestLogSerializer` ساخته می شود. + +فیلدهای اصلی: + +```python +fields = [ + "id", + "farm_uuid", + "sensor_catalog_uuid", + "physical_device_uuid", + "farm_sensor", + "sensor_catalog", + "payload", + "created_at", +] +``` + +### فیلد `farm_sensor` +این فیلد از نوع `SerializerMethodField` است. +اگر سنسور متناظر پیدا شود، اطلاعاتی مثل موارد زیر را برمی گرداند: +- `uuid` +- `sensor_catalog_uuid` +- `physical_device_uuid` +- `name` +- `sensor_type` +- `is_active` +- `specifications` +- `power_source` +- `created_at` +- `updated_at` + +اگر سنسور پیدا نشود، مقدار آن `null` خواهد بود. + +### فیلد `sensor_catalog` +این فیلد هم از نوع `SerializerMethodField` است. +اگر `farm_sensor` و `sensor_catalog` موجود باشند، اطلاعات کاتالوگ سنسور برمی گردد، مثل: +- `uuid` +- `code` +- `name` +- `description` +- `customizable_fields` +- `supported_power_sources` +- `returned_data_fields` +- `sample_payload` +- `is_active` +- `created_at` +- `updated_at` + +اگر داده متناظر وجود نداشته باشد، مقدار آن `null` خواهد بود. + +## مدل لاگ + +لاگ ها در مدل `SensorExternalRequestLog` ذخیره می شوند: + +```python +class SensorExternalRequestLog(models.Model): + farm_uuid = models.UUIDField(db_index=True) + sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True) + physical_device_uuid = models.UUIDField(db_index=True) + payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) +``` + +ویژگی مهم مدل: +- جدول دیتابیس: `sensor_external_request_logs` +- ترتیب پیش فرض: `ordering = ["-created_at", "-id"]` + +## نمونه پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "count": 2, + "next": "http://example.com/api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=2&page_size=1", + "previous": null, + "data": [ + { + "id": 12, + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "farm_sensor": { + "uuid": "99999999-9999-9999-9999-999999999999", + "sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "External device 2", + "sensor_type": "soil_sensor", + "is_active": true, + "specifications": { + "model": "FH-2" + }, + "power_source": { + "type": "solar" + }, + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z" + }, + "sensor_catalog": { + "uuid": "22222222-2222-2222-2222-222222222222", + "code": "ext-sensor-log-2", + "name": "External Sensor Log 2", + "description": "Sensor catalog for second log", + "customizable_fields": [], + "supported_power_sources": [], + "returned_data_fields": ["humidity"], + "sample_payload": {}, + "is_active": true, + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z" + }, + "payload": { + "temp": 18 + }, + "created_at": "2024-01-02T10:00:00Z" + } + ] +} +``` + +## خطاهای احتمالی + +### 401 Unauthorized +اگر کاربر احراز هویت نشده باشد: + +```json +{ + "detail": "Authentication credentials were not provided." +} +``` + +### 400 Bad Request +اگر پارامترها نامعتبر باشند، مثلا `farm_uuid` فرمت درست نداشته باشد یا `page_size` بیشتر از `100` باشد، خطای validation برگردانده می شود. + +### 503 Service Unavailable +اگر migration های مربوط به جدول ها اجرا نشده باشند: + +```json +{ + "code": 503, + "msg": "Required tables are not ready. Run migrations." +} +``` + +## نکات مهم پیاده سازی + +- این API فقط لاگ های مربوط به یک `farm_uuid` را برمی گرداند. +- پاسخ به صورت paginated است. +- داده های `farm_sensor` و `sensor_catalog` به صورت enrich شده به هر لاگ اضافه می شوند. +- اگر برای یک لاگ، سنسور متناظر در `FarmSensor` پیدا نشود، فیلدهای `farm_sensor` و `sensor_catalog` ممکن است `null` باشند. +- ترتیب نمایش لاگ ها نزولی و از جدیدترین به قدیمی ترین است. + +## فایل های مرتبط + +- `sensor_external_api/urls.py` +- `sensor_external_api/views.py` +- `sensor_external_api/serializers.py` +- `sensor_external_api/services.py` +- `sensor_external_api/models.py` +- `sensor_external_api/tests.py` + +## جمع بندی + +API مربوط به `logs/` برای گزارش گیری و مشاهده تاریخچه درخواست های ورودی از سنسورهای خارجی یک مزرعه استفاده می شود. این endpoint با دریافت `farm_uuid`، لاگ های مرتبط را از دیتابیس می خواند، آن ها را صفحه بندی می کند، اطلاعات سنسور و کاتالوگ را به خروجی اضافه می کند و در نهایت پاسخ استاندارد برمی گرداند. diff --git a/sensor_external_api/services.py b/sensor_external_api/services.py index ba239e1..262552e 100644 --- a/sensor_external_api/services.py +++ b/sensor_external_api/services.py @@ -1,3 +1,5 @@ +import requests +from django.conf import settings from django.db import OperationalError, ProgrammingError, transaction from farm_hub.models import FarmSensor @@ -6,6 +8,10 @@ from notifications.services import create_notification_for_farm_uuid from .models import SensorExternalRequestLog +class FarmDataForwardError(Exception): + pass + + def get_sensor_external_request_logs_for_farm(*, farm_uuid): try: return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id") @@ -60,7 +66,7 @@ def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, ph def create_sensor_external_notification(*, physical_device_uuid, payload=None): payload = payload or {} sensor = ( - FarmSensor.objects.select_related("farm", "sensor_catalog") + FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog") .filter(physical_device_uuid=physical_device_uuid) .first() ) @@ -89,3 +95,79 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None): ) except (ProgrammingError, OperationalError) as exc: raise ValueError("Sensor external API tables are not migrated.") from exc + + +def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None): + payload = payload or {} + sensor = ( + FarmSensor.objects.select_related("farm", "farm__current_crop_area") + .filter(physical_device_uuid=physical_device_uuid) + .first() + ) + if sensor is None: + raise ValueError("Physical device not found.") + + farm_boundary = _get_farm_boundary(sensor=sensor) + url = _build_farm_data_url() + api_key = getattr(settings, "FARM_DATA_API_KEY", "") + if not api_key: + raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.") + + request_payload = { + "farm_uuid": str(sensor.farm.farm_uuid), + "farm_boundary": farm_boundary, + "sensor_payload": { + sensor.name or str(sensor.physical_device_uuid): payload, + }, + } + + try: + response = requests.post( + url, + json=request_payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": api_key, + "Authorization": f"Api-Key {api_key}", + }, + timeout=getattr(settings, "FARM_DATA_API_TIMEOUT", 30), + ) + except requests.RequestException as exc: + raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc + + if response.status_code >= 400: + try: + response_body = response.json() + except ValueError: + response_body = response.text + raise FarmDataForwardError( + f"Farm data API returned status {response.status_code}: {response_body}" + ) + + return request_payload + + +def _get_farm_boundary(*, sensor): + crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first() + if crop_area is None: + raise FarmDataForwardError("Farm boundary is not configured for this farm.") + + geometry = crop_area.geometry or {} + if geometry.get("type") == "Feature": + geometry = geometry.get("geometry") or {} + + if geometry.get("type") != "Polygon": + raise FarmDataForwardError("Farm boundary geometry must be a Polygon.") + + return geometry + + +def _build_farm_data_url(): + base_url = getattr(settings, "AI_SERVICE_BASE_URL", "").rstrip("/") + path = "/api/farm-data/" + + if not base_url: + raise FarmDataForwardError("FARM_DATA_API_HOST is not configured.") + + return f"{base_url}/{path.lstrip('/')}" diff --git a/sensor_external_api/tests.py b/sensor_external_api/tests.py index 0a378c7..b80c0d2 100644 --- a/sensor_external_api/tests.py +++ b/sensor_external_api/tests.py @@ -1,7 +1,10 @@ +import requests from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory +from unittest.mock import Mock, patch +from crop_zoning.models import CropArea from farm_hub.models import FarmHub, FarmSensor, FarmType from notifications.models import FarmNotification from sensor_catalog.models import SensorCatalog @@ -11,7 +14,12 @@ from .services import get_latest_sensor_external_request_log from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView -@override_settings(SENSOR_EXTERNAL_API_KEY="12345") +@override_settings( + SENSOR_EXTERNAL_API_KEY="12345", + FARM_DATA_API_HOST="http://localhost", + FARM_DATA_API_PORT="8020", + FARM_DATA_API_KEY="farm-data-key", +) class SensorExternalAPIViewTests(TestCase): def setUp(self): self.factory = APIRequestFactory() @@ -31,11 +39,38 @@ class SensorExternalAPIViewTests(TestCase): code="ext-sensor-v1", name="External Sensor", ) + self.crop_area = CropArea.objects.create( + farm=self.farm, + geometry={ + "type": "Polygon", + "coordinates": [ + [ + [51.39, 35.7], + [51.41, 35.7], + [51.41, 35.72], + [51.39, 35.72], + [51.39, 35.7], + ] + ], + }, + points=[ + [51.39, 35.7], + [51.41, 35.7], + [51.41, 35.72], + [51.39, 35.72], + ], + center={"lat": 35.71, "lng": 51.4}, + area_sqm=1000, + area_hectares=0.1, + chunk_area_sqm=1000, + ) + self.farm.current_crop_area = self.crop_area + self.farm.save(update_fields=["current_crop_area"]) self.sensor = FarmSensor.objects.create( farm=self.farm, sensor_catalog=self.sensor_catalog, physical_device_uuid="11111111-1111-1111-1111-111111111111", - name="External device", + name="sensor-7-1", sensor_type="weather_station", ) @@ -50,7 +85,9 @@ class SensorExternalAPIViewTests(TestCase): self.assertEqual(response.status_code, 401) - def test_creates_notification_and_request_log_for_device_uuid(self): + @patch("sensor_external_api.services.requests.post") + def test_creates_notification_and_request_log_for_device_uuid(self, mock_post): + mock_post.return_value = Mock(status_code=201) request = self.factory.post( "/api/sensor-external-api/", {"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}}, @@ -75,6 +112,23 @@ class SensorExternalAPIViewTests(TestCase): payload={"temp": 12}, ).exists() ) + mock_post.assert_called_once_with( + "http://localhost:8020/api/farm-data/", + json={ + "farm_uuid": str(self.farm.farm_uuid), + "farm_boundary": self.crop_area.geometry, + "sensor_payload": { + "sensor-7-1": {"temp": 12}, + }, + }, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": "farm-data-key", + "Authorization": "Api-Key farm-data-key", + }, + timeout=30, + ) def test_returns_404_for_unknown_device_uuid(self): request = self.factory.post( @@ -88,6 +142,23 @@ class SensorExternalAPIViewTests(TestCase): self.assertEqual(response.status_code, 404) + @patch("sensor_external_api.services.requests.post") + def test_returns_503_when_farm_data_api_is_unavailable(self, mock_post): + mock_post.side_effect = requests.RequestException("connection error") + + request = self.factory.post( + "/api/sensor-external-api/", + {"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}}, + format="json", + HTTP_X_API_KEY="12345", + ) + + response = SensorExternalAPIView.as_view()(request) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.data["code"], 503) + self.assertIn("Farm data API request failed", response.data["msg"]) + class SensorExternalServiceTests(TestCase): def test_get_latest_sensor_external_request_log_returns_latest_matching_record(self): diff --git a/sensor_external_api/views.py b/sensor_external_api/views.py index 3871387..b634b92 100644 --- a/sensor_external_api/views.py +++ b/sensor_external_api/views.py @@ -16,7 +16,9 @@ from .serializers import ( SensorExternalRequestSerializer, ) from .services import ( + FarmDataForwardError, create_sensor_external_notification, + forward_sensor_payload_to_farm_data, get_farm_sensor_map_for_logs, get_sensor_external_request_logs_for_farm, ) @@ -61,6 +63,10 @@ class SensorExternalAPIView(APIView): physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"), ) + forward_sensor_payload_to_farm_data( + physical_device_uuid=serializer.validated_data["uuid"], + payload=serializer.validated_data.get("payload"), + ) except ValueError as exc: if "not migrated" in str(exc): return Response( @@ -68,6 +74,11 @@ class SensorExternalAPIView(APIView): status=status.HTTP_503_SERVICE_UNAVAILABLE, ) return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND) + except FarmDataForwardError as exc: + return Response( + {"code": 503, "msg": str(exc)}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) data = FarmNotificationSerializer(notification).data return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)