This commit is contained in:
2026-04-29 01:27:16 +03:30
parent a75c4ca9c8
commit f0f2ac34b7
20 changed files with 2840 additions and 65 deletions
@@ -0,0 +1,356 @@
# مستند API دریافت داده سنسور خارجی
این فایل رفتار endpoint زیر را توضیح می‌دهد:
`POST /api/sensor-external-api/`
این API برای دریافت payload از یک سنسور فیزیکی، ثبت آن داخل دیتابیس، ساخت نوتیفیکیشن برای مزرعه، و سپس ارسال همان داده به سرویس AI/Farm Data استفاده می‌شود.
## هدف API
این endpoint وقتی صدا زده می‌شود که یک سنسور خارجی داده جدیدی تولید کرده باشد. بک‌اند در این مسیر چند کار پشت سر هم انجام می‌دهد:
1. اعتبارسنجی API key
2. اعتبارسنجی `uuid` و `payload`
3. پیدا کردن سنسور بر اساس `physical_device_uuid`
4. ذخیره لاگ درخواست در جدول `sensor_external_request_logs`
5. ساخت notification برای مزرعه
6. ارسال داده به سرویس AI در endpoint مربوط به farm data
## مسیر و View
این endpoint در فایل `sensor_external_api/urls.py` ثبت شده است:
```python
path("", SensorExternalAPIView.as_view(), name="sensor-external-api")
```
پیاده‌سازی view در فایل `sensor_external_api/views.py` قرار دارد:
```python
class SensorExternalAPIView(APIView):
authentication_classes = [SensorExternalAPIKeyAuthentication]
permission_classes = [AllowAny]
```
## احراز هویت
این API از هدر `X-API-Key` استفاده می‌کند.
کلاس احراز هویت:
`sensor_external_api/authentication.py`
رفتار آن:
- اگر `X-API-Key` یا `Authorization` ارسال نشود، پاسخ `401` می‌دهد.
- اگر مقدار کلید اشتباه باشد، پاسخ `401` می‌دهد.
- مقدار مورد انتظار از `SENSOR_EXTERNAL_API_KEY` خوانده می‌شود.
## ورودی درخواست
serializer ورودی در فایل `sensor_external_api/serializers.py` تعریف شده است:
```python
class SensorExternalRequestSerializer(serializers.Serializer):
uuid = serializers.UUIDField()
payload = serializers.JSONField(required=False, default=dict)
```
### بدنه نمونه درخواست
```json
{
"uuid": "22222222-2222-2222-2222-222222222222",
"payload": {
"moisture_percent": 32.5,
"temperature_c": 21.3,
"ph": 6.7,
"ec_ds_m": 1.1,
"nitrogen_mg_kg": 42,
"phosphorus_mg_kg": 18,
"potassium_mg_kg": 210
}
}
```
نکته:
- `uuid` در این API همان `physical_device_uuid` سنسور است.
- `payload` به همان شکلی که از سنسور می‌آید ذخیره و forward می‌شود.
## روند اجرای API
### 1) اعتبارسنجی request
در متد `post` ابتدا داده ورودی validate می‌شود:
```python
serializer = SensorExternalRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
```
اگر `uuid` معتبر نباشد یا ساختار body خراب باشد، DRF خطای `400` برمی‌گرداند.
### 2) ثبت لاگ و ساخت نوتیفیکیشن
سپس این سرویس صدا زده می‌شود:
```python
notification = create_sensor_external_notification(
physical_device_uuid=serializer.validated_data["uuid"],
payload=serializer.validated_data.get("payload"),
)
```
این تابع در فایل `sensor_external_api/services.py` قرار دارد.
کارهایی که انجام می‌دهد:
- سنسور را از جدول `FarmSensor` با `physical_device_uuid` پیدا می‌کند.
- اگر سنسور پیدا نشود، `ValueError("Physical device not found.")` می‌دهد.
- یک رکورد در جدول `sensor_external_request_logs` می‌سازد.
- یک notification برای مزرعه می‌سازد.
### رکوردی که در دیتابیس ذخیره می‌شود
مدل ذخیره‌سازی:
`sensor_external_api/models.py`
```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)
```
یعنی payload خام سنسور برای گزارش‌گیری و استفاده‌های بعدی نگه داشته می‌شود.
### 3) ارسال داده به سرویس AI / Farm Data
بعد از ثبت لاگ، این سرویس صدا زده می‌شود:
```python
forward_sensor_payload_to_farm_data(
physical_device_uuid=serializer.validated_data["uuid"],
payload=serializer.validated_data.get("payload"),
)
```
این قسمت مهم‌ترین call خارجی endpoint است.
## این API چه آدرسی از AI را صدا می‌زند؟
سرویس خارجی از طریق `external_api_adapter.request` صدا زده می‌شود:
```python
response = external_api_request(
"ai",
_get_farm_data_path(),
method="POST",
payload=request_payload,
headers={...},
)
```
### service name
مقدار service برابر است با:
`"ai"`
یعنی این درخواست به سرویسی می‌رود که در تنظیمات به عنوان AI service تعریف شده است.
### base URL سرویس AI
در `config/settings.py`:
```python
"ai": {
"base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"),
"api_key": os.getenv("AI_SERVICE_API_KEY", ""),
"host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"),
}
```
پس base URL به‌صورت پیش‌فرض این است:
`http://ai-web:8000`
### path مقصد
path از این تنظیم خوانده می‌شود:
```python
FARM_DATA_API_PATH = os.getenv("FARM_DATA_API_PATH", "/api/farm-data/")
```
پس path پیش‌فرض این است:
`/api/farm-data/`
### آدرس نهایی که صدا زده می‌شود
در حالت پیش‌فرض، آدرس نهایی به این صورت است:
`POST http://ai-web:8000/api/farm-data/`
اگر متغیرهای environment تغییر کرده باشند، این آدرس هم تغییر می‌کند.
## چرا این آدرس صدا زده می‌شود؟
هدف از این call این است که داده سنسور خام فقط در بک‌اند ذخیره نشود، بلکه برای پردازش downstream هم به سرویس AI/Farm Data فرستاده شود.
این سرویس AI احتمالا برای کارهای زیر استفاده می‌شود:
- تحلیل داده سنسورها در سطح مزرعه
- ساخت داده تجمیعی farm data
- تغذیه dashboardها و مدل‌های AI
- محاسبه شاخص‌ها یا توصیه‌های بعدی
خود این endpoint در این پروژه فقط داده را forward می‌کند و پردازش AI داخل همین اپ انجام نمی‌شود.
## چه payloadی به AI ارسال می‌شود؟
قبل از ارسال، بک‌اند این ساختار را می‌سازد:
```python
request_payload = {
"farm_uuid": str(sensor.farm.farm_uuid),
"farm_boundary": farm_boundary,
"sensor_payload": {
sensor.name or str(sensor.physical_device_uuid): payload,
},
}
```
یعنی payload ارسال‌شده به AI دقیقا body اولیه کاربر نیست، بلکه این wrapper را دارد:
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"farm_boundary": {
"type": "Polygon",
"coordinates": [[[51.39, 35.7], [51.41, 35.7], [51.41, 35.72], [51.39, 35.72], [51.39, 35.7]]]
},
"sensor_payload": {
"Soil Sensor 7-in-1": {
"moisture_percent": 32.5,
"temperature_c": 21.3,
"ph": 6.7,
"ec_ds_m": 1.1,
"nitrogen_mg_kg": 42,
"phosphorus_mg_kg": 18,
"potassium_mg_kg": 210
}
}
}
```
## farm_boundary از کجا می‌آید؟
سرویس `_get_farm_boundary` این منطق را دارد:
- اگر `farm.current_crop_area` وجود داشته باشد، از آن استفاده می‌کند.
- اگر وجود نداشته باشد، آخرین crop area مزرعه را برمی‌دارد.
- اگر هیچ boundary وجود نداشته باشد، خطا می‌دهد.
- اگر geometry از نوع `Polygon` نباشد، خطا می‌دهد.
پس سرویس AI فقط وقتی صدا زده می‌شود که مرز مزرعه معتبر وجود داشته باشد.
## هدرهایی که به AI ارسال می‌شوند
در زمان forward کردن، این هدرها ارسال می‌شوند:
```python
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"X-API-Key": api_key,
"Authorization": f"Api-Key {api_key}",
}
```
`api_key` از این setting می‌آید:
`FARM_DATA_API_KEY`
اگر این مقدار ست نشده باشد، پاسخ `503` برمی‌گردد.
## پاسخ موفق
اگر همه چیز درست باشد:
- لاگ ذخیره می‌شود
- notification ساخته می‌شود
- داده به AI forward می‌شود
- پاسخ `201` برمی‌گردد
نمونه ساختار پاسخ:
```json
{
"code": 201,
"msg": "success",
"data": {
"...": "serialized notification object"
}
}
```
نکته:
data خروجی این endpoint نتیجه AI نیست. خروجی، notification ساخته‌شده در سیستم خود بک‌اند است.
## خطاهای ممکن
### 401 Unauthorized
اگر API key ارسال نشود یا اشتباه باشد.
### 404 Not Found
اگر `physical_device_uuid` در جدول `FarmSensor` پیدا نشود.
پاسخ:
```json
{
"code": 404,
"msg": "Physical device not found."
}
```
### 503 Service Unavailable
در چند حالت:
- migration جدول‌ها انجام نشده باشد
- `FARM_DATA_API_KEY` تنظیم نشده باشد
- مرز مزرعه موجود نباشد
- geometry مزرعه `Polygon` نباشد
- سرویس AI در دسترس نباشد
- سرویس AI پاسخ خطای 4xx/5xx بدهد
نمونه خطا:
```json
{
"code": 503,
"msg": "Farm data API request failed: connection error"
}
```
## خلاصه رفتاری endpoint
`POST /api/sensor-external-api/` این کارها را انجام می‌دهد:
1. داده سنسور را از بیرون می‌گیرد.
2. سنسور را با `physical_device_uuid` پیدا می‌کند.
3. payload را در جدول لاگ ذخیره می‌کند.
4. برای مزرعه notification می‌سازد.
5. داده را به سرویس AI در آدرس پیش‌فرض `POST http://ai-web:8000/api/farm-data/` می‌فرستد.
6. در نهایت نتیجه موفقیت را با notification برمی‌گرداند.
+19 -4
View File
@@ -13,8 +13,19 @@ class SensorExternalRequestSerializer(serializers.Serializer):
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)
page = serializers.IntegerField(min_value=1)
page_size = serializers.IntegerField(min_value=1, max_value=100)
physical_device_uuid = serializers.UUIDField(required=False)
sensor_type = serializers.CharField(required=False, allow_blank=False)
date_from = serializers.DateField(required=False)
date_to = serializers.DateField(required=False)
def validate(self, attrs):
date_from = attrs.get("date_from")
date_to = attrs.get("date_to")
if date_from and date_to and date_from > date_to:
raise serializers.ValidationError({"date_to": "date_to must be greater than or equal to date_from."})
return attrs
class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
@@ -36,14 +47,18 @@ class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
def get_farm_sensor(self, obj):
farm_sensor_map = self.context.get("farm_sensor_map", {})
farm_sensor = farm_sensor_map.get((obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid))
farm_sensor = farm_sensor_map.get(
(obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)
) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
if farm_sensor is None:
return None
return FarmSensorLogSerializer(farm_sensor).data
def get_sensor_catalog(self, obj):
farm_sensor_map = self.context.get("farm_sensor_map", {})
farm_sensor = farm_sensor_map.get((obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid))
farm_sensor = farm_sensor_map.get(
(obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)
) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
if farm_sensor is None or farm_sensor.sensor_catalog is None:
return None
return SensorCatalogLogSerializer(farm_sensor.sensor_catalog).data
+134 -9
View File
@@ -1,3 +1,5 @@
import logging
from django.conf import settings
from django.db import OperationalError, ProgrammingError, transaction
@@ -9,13 +11,41 @@ from notifications.services import create_notification_for_farm_uuid
from .models import SensorExternalRequestLog
logger = logging.getLogger(__name__)
class FarmDataForwardError(Exception):
pass
def get_sensor_external_request_logs_for_farm(*, farm_uuid):
def get_sensor_external_request_logs_for_farm(
*,
farm_uuid,
physical_device_uuid=None,
sensor_type=None,
date_from=None,
date_to=None,
):
try:
return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id")
queryset = SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid)
if physical_device_uuid:
queryset = queryset.filter(physical_device_uuid=physical_device_uuid)
if sensor_type:
physical_device_uuids = FarmSensor.objects.filter(
farm__farm_uuid=farm_uuid,
sensor_type=sensor_type,
).values_list("physical_device_uuid", flat=True)
queryset = queryset.filter(physical_device_uuid__in=physical_device_uuids)
if date_from:
queryset = queryset.filter(created_at__date__gte=date_from)
if date_to:
queryset = queryset.filter(created_at__date__lte=date_to)
return queryset.order_by("-created_at", "-id")
except (ProgrammingError, OperationalError) as exc:
raise ValueError("Sensor external API tables are not migrated.") from exc
@@ -37,12 +67,18 @@ def get_farm_sensor_map_for_logs(*, logs):
farm_sensor_map = {}
for farm_sensor in farm_sensor_queryset:
key = (
exact_key = (
farm_sensor.farm.farm_uuid,
farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None,
farm_sensor.physical_device_uuid,
)
farm_sensor_map.setdefault(key, farm_sensor)
fallback_key = (
farm_sensor.farm.farm_uuid,
None,
farm_sensor.physical_device_uuid,
)
farm_sensor_map.setdefault(exact_key, farm_sensor)
farm_sensor_map.setdefault(fallback_key, farm_sensor)
return farm_sensor_map
except (ProgrammingError, OperationalError) as exc:
@@ -66,12 +102,22 @@ 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 {}
logger.warning(
"Sensor external notification start: physical_device_uuid=%s payload_type=%s payload_keys=%s",
physical_device_uuid,
type(payload).__name__,
sorted(payload.keys()) if isinstance(payload, dict) else None,
)
sensor = (
FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog")
.filter(physical_device_uuid=physical_device_uuid)
.first()
)
if sensor is None:
logger.error(
"Sensor external notification failed: physical device not found for uuid=%s",
physical_device_uuid,
)
raise ValueError("Physical device not found.")
try:
@@ -82,7 +128,7 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None):
physical_device_uuid=sensor.physical_device_uuid,
payload=payload,
)
return create_notification_for_farm_uuid(
notification = create_notification_for_farm_uuid(
farm_uuid=sensor.farm.farm_uuid,
title="Sensor external API request",
message=f"Payload received from device {sensor.physical_device_uuid}.",
@@ -94,32 +140,62 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None):
"payload": payload,
},
)
logger.warning(
"Sensor external notification created: farm_uuid=%s sensor_catalog_uuid=%s physical_device_uuid=%s",
sensor.farm.farm_uuid,
sensor.sensor_catalog.uuid if sensor.sensor_catalog else None,
sensor.physical_device_uuid,
)
return notification
except (ProgrammingError, OperationalError) as exc:
logger.exception(
"Sensor external notification failed due to database readiness: physical_device_uuid=%s",
physical_device_uuid,
)
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")
FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog")
.filter(physical_device_uuid=physical_device_uuid)
.first()
)
if sensor is None:
logger.error(
"Farm data forward failed: physical device not found for uuid=%s",
physical_device_uuid,
)
raise ValueError("Physical device not found.")
farm_boundary = _get_farm_boundary(sensor=sensor)
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
if not api_key:
logger.error(
"Farm data forward failed: FARM_DATA_API_KEY missing for farm_uuid=%s physical_device_uuid=%s",
sensor.farm.farm_uuid,
physical_device_uuid,
)
raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.")
sensor_key = _get_sensor_key(sensor=sensor)
normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=payload)
request_payload = {
"farm_uuid": str(sensor.farm.farm_uuid),
"farm_boundary": farm_boundary,
"sensor_payload": {
sensor.name or str(sensor.physical_device_uuid): payload,
},
"sensor_key": sensor_key,
"sensor_payload": normalized_sensor_payload,
}
logger.warning(
"Farm data forward start: farm_uuid=%s physical_device_uuid=%s sensor_key=%s payload_keys=%s boundary_type=%s boundary_points=%s",
sensor.farm.farm_uuid,
physical_device_uuid,
sensor_key,
sorted(normalized_sensor_payload.keys()) if isinstance(normalized_sensor_payload, dict) else None,
farm_boundary.get("type") if isinstance(farm_boundary, dict) else None,
len(farm_boundary.get("coordinates", [[]])[0]) if isinstance(farm_boundary, dict) and farm_boundary.get("coordinates") else None,
)
try:
response = external_api_request(
@@ -135,20 +211,46 @@ def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None):
},
)
except ExternalAPIRequestError as exc:
logger.exception(
"Farm data forward request exception: farm_uuid=%s physical_device_uuid=%s sensor_key=%s",
sensor.farm.farm_uuid,
physical_device_uuid,
sensor_key,
)
raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc
if response.status_code >= 400:
response_body = response.data
logger.error(
"Farm data forward rejected: farm_uuid=%s physical_device_uuid=%s sensor_key=%s status_code=%s response=%s",
sensor.farm.farm_uuid,
physical_device_uuid,
sensor_key,
response.status_code,
response_body,
)
raise FarmDataForwardError(
f"Farm data API returned status {response.status_code}: {response_body}"
)
logger.warning(
"Farm data forward success: farm_uuid=%s physical_device_uuid=%s sensor_key=%s status_code=%s",
sensor.farm.farm_uuid,
physical_device_uuid,
sensor_key,
response.status_code,
)
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:
logger.error(
"Farm data forward failed: no farm boundary configured for farm_uuid=%s physical_device_uuid=%s",
sensor.farm.farm_uuid,
sensor.physical_device_uuid,
)
raise FarmDataForwardError("Farm boundary is not configured for this farm.")
geometry = crop_area.geometry or {}
@@ -156,10 +258,33 @@ def _get_farm_boundary(*, sensor):
geometry = geometry.get("geometry") or {}
if geometry.get("type") != "Polygon":
logger.error(
"Farm data forward failed: invalid boundary geometry type=%s for farm_uuid=%s physical_device_uuid=%s",
geometry.get("type"),
sensor.farm.farm_uuid,
sensor.physical_device_uuid,
)
raise FarmDataForwardError("Farm boundary geometry must be a Polygon.")
return geometry
def _normalize_sensor_payload(*, sensor_key, sensor_payload):
if not sensor_payload:
return {}
if not isinstance(sensor_payload, dict):
raise FarmDataForwardError("`payload` must be a JSON object.")
if all(isinstance(value, dict) for value in sensor_payload.values()):
return sensor_payload
return {sensor_key: sensor_payload}
def _get_sensor_key(*, sensor):
if sensor.sensor_catalog and sensor.sensor_catalog.code:
return sensor.sensor_catalog.code
return "sensor-7-1"
def _get_farm_data_path():
return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/")
+81 -4
View File
@@ -1,6 +1,9 @@
from datetime import datetime, timezone as dt_timezone
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory
from rest_framework_simplejwt.tokens import AccessToken
from unittest.mock import patch
from external_api_adapter.adapter import AdapterResponse
@@ -118,8 +121,9 @@ class SensorExternalAPIViewTests(TestCase):
payload={
"farm_uuid": str(self.farm.farm_uuid),
"farm_boundary": self.crop_area.geometry,
"sensor_key": self.sensor_catalog.code,
"sensor_payload": {
"sensor-7-1": {"temp": 12},
self.sensor_catalog.code: {"temp": 12},
},
},
headers={
@@ -202,6 +206,7 @@ class SensorExternalRequestLogListAPIViewTests(TestCase):
email="sensor-external-log@example.com",
phone_number="09120000016",
)
self.access_token = str(AccessToken.for_user(self.user))
self.farm_type = FarmType.objects.create(name="لاگ سنسور خارجی")
self.farm = FarmHub.objects.create(
owner=self.user,
@@ -261,17 +266,31 @@ class SensorExternalRequestLogListAPIViewTests(TestCase):
payload={"temp": 24},
)
def test_requires_api_key(self):
request = self.factory.get(f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}")
def test_requires_bearer_token(self):
request = self.factory.get(
f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=20"
)
response = SensorExternalRequestLogListAPIView.as_view()(request)
self.assertEqual(response.status_code, 401)
def test_requires_page_and_page_size(self):
request = self.factory.get(
f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}",
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
)
response = SensorExternalRequestLogListAPIView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertIn("page", response.data)
self.assertIn("page_size", response.data)
def test_returns_paginated_logs_for_farm_uuid(self):
request = self.factory.get(
f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=1",
HTTP_X_API_KEY="12345",
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
)
response = SensorExternalRequestLogListAPIView.as_view()(request)
@@ -301,3 +320,61 @@ class SensorExternalRequestLogListAPIViewTests(TestCase):
response.data["data"][0]["farm_sensor"]["physical_device_uuid"],
str(self.second_sensor.physical_device_uuid),
)
self.assertEqual(response.data["data"][0]["payload"]["temp"], 18)
self.assertIsInstance(response.data["data"][0]["payload"]["temp"], int)
def test_filters_logs_by_physical_device_uuid(self):
request = self.factory.get(
(
"/api/sensor-external-api/logs/"
f"?farm_uuid={self.farm_uuid}"
f"&physical_device_uuid={self.first_sensor.physical_device_uuid}"
"&page=1&page_size=20"
),
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
)
response = SensorExternalRequestLogListAPIView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["data"][0]["id"], self.first_log.id)
def test_filters_logs_by_sensor_type(self):
request = self.factory.get(
(
"/api/sensor-external-api/logs/"
f"?farm_uuid={self.farm_uuid}"
"&sensor_type=soil_sensor"
"&page=1&page_size=20"
),
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
)
response = SensorExternalRequestLogListAPIView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["data"][0]["id"], self.second_log.id)
def test_filters_logs_by_date_range(self):
older_timestamp = datetime(2025, 5, 1, 10, 0, tzinfo=dt_timezone.utc)
newer_timestamp = datetime(2025, 5, 2, 11, 0, tzinfo=dt_timezone.utc)
SensorExternalRequestLog.objects.filter(id=self.first_log.id).update(created_at=older_timestamp)
SensorExternalRequestLog.objects.filter(id=self.second_log.id).update(created_at=newer_timestamp)
request = self.factory.get(
(
"/api/sensor-external-api/logs/"
f"?farm_uuid={self.farm_uuid}"
"&date_from=2025-05-02&date_to=2025-05-02"
"&page=1&page_size=20"
),
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
)
response = SensorExternalRequestLogListAPIView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["data"][0]["id"], self.second_log.id)
+55 -5
View File
@@ -1,10 +1,12 @@
import logging
from rest_framework import serializers, status
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import AllowAny
from rest_framework.permissions import IsAuthenticated
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 drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes, extend_schema
from config.swagger import code_response
from notifications.serializers import FarmNotificationSerializer
@@ -24,6 +26,9 @@ from .services import (
)
logger = logging.getLogger(__name__)
class SensorExternalRequestLogPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
@@ -37,6 +42,24 @@ class SensorExternalAPIView(APIView):
@extend_schema(
tags=["Sensor External API"],
request=SensorExternalRequestSerializer,
examples=[
OpenApiExample(
"Sensor External API Request",
value={
"uuid": "22222222-2222-2222-2222-222222222222",
"payload": {
"moisture_percent": 32.5,
"temperature_c": 21.3,
"ph": 6.7,
"ec_ds_m": 1.1,
"nitrogen_mg_kg": 42,
"phosphorus_mg_kg": 18,
"potassium_mg_kg": 210,
},
},
request_only=True,
)
],
parameters=[
OpenApiParameter(
name="X-API-Key",
@@ -57,6 +80,13 @@ class SensorExternalAPIView(APIView):
def post(self, request):
serializer = SensorExternalRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
logger.warning(
"Sensor external API POST received: uuid=%s payload_keys=%s",
serializer.validated_data["uuid"],
sorted(serializer.validated_data.get("payload", {}).keys())
if isinstance(serializer.validated_data.get("payload"), dict)
else None,
)
try:
notification = create_sensor_external_notification(
@@ -69,18 +99,31 @@ class SensorExternalAPIView(APIView):
)
except ValueError as exc:
if "not migrated" in str(exc):
logger.exception(
"Sensor external API POST failed due to missing migrations: uuid=%s",
serializer.validated_data["uuid"],
)
return Response(
{"code": 503, "msg": "Required tables are not ready. Run migrations."},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
logger.exception(
"Sensor external API POST failed due to missing physical device: uuid=%s",
serializer.validated_data["uuid"],
)
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
except FarmDataForwardError as exc:
logger.exception(
"Sensor external API POST failed while forwarding to farm data: uuid=%s",
serializer.validated_data["uuid"],
)
return Response(
{"code": 503, "msg": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
data = FarmNotificationSerializer(notification).data
logger.warning("Sensor external API POST succeeded: uuid=%s", serializer.validated_data["uuid"])
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
@@ -93,9 +136,12 @@ class SensorExternalRequestLogListAPIView(APIView):
tags=["Sensor External API"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"),
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="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True),
OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True),
OpenApiParameter(name="physical_device_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
OpenApiParameter(name="sensor_type", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False),
OpenApiParameter(name="date_from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False),
OpenApiParameter(name="date_to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False),
],
responses={
200: code_response(
@@ -118,6 +164,10 @@ class SensorExternalRequestLogListAPIView(APIView):
try:
queryset = get_sensor_external_request_logs_for_farm(
farm_uuid=serializer.validated_data["farm_uuid"],
physical_device_uuid=serializer.validated_data.get("physical_device_uuid"),
sensor_type=serializer.validated_data.get("sensor_type"),
date_from=serializer.validated_data.get("date_from"),
date_to=serializer.validated_data.get("date_to"),
)
except ValueError:
return Response(