UPDATE
This commit is contained in:
@@ -28,6 +28,12 @@ AI_SERVICE_API_KEY=
|
|||||||
FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com
|
FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com
|
||||||
FARM_HUB_SERVICE_API_KEY=
|
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_CHUNK_AREA_SQM=10000
|
||||||
CROP_ZONE_TASK_STALE_SECONDS=300
|
CROP_ZONE_TASK_STALE_SECONDS=300
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
NOTIFICATION_REDIS_URL = os.getenv("NOTIFICATION_REDIS_URL", CELERY_BROKER_URL)
|
||||||
EXTERNAL_NOTIFICATION_API_KEY = os.getenv("EXTERNAL_NOTIFICATION_API_KEY", "12345")
|
EXTERNAL_NOTIFICATION_API_KEY = os.getenv("EXTERNAL_NOTIFICATION_API_KEY", "12345")
|
||||||
SENSOR_EXTERNAL_API_KEY = os.getenv("SENSOR_EXTERNAL_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_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "default")
|
||||||
CELERY_TASK_ACKS_LATE = True
|
CELERY_TASK_ACKS_LATE = True
|
||||||
CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1"))
|
CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1"))
|
||||||
|
|||||||
@@ -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`، لاگ های مرتبط را از دیتابیس می خواند، آن ها را صفحه بندی می کند، اطلاعات سنسور و کاتالوگ را به خروجی اضافه می کند و در نهایت پاسخ استاندارد برمی گرداند.
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
from django.db import OperationalError, ProgrammingError, transaction
|
from django.db import OperationalError, ProgrammingError, transaction
|
||||||
|
|
||||||
from farm_hub.models import FarmSensor
|
from farm_hub.models import FarmSensor
|
||||||
@@ -6,6 +8,10 @@ from notifications.services import create_notification_for_farm_uuid
|
|||||||
from .models import SensorExternalRequestLog
|
from .models import SensorExternalRequestLog
|
||||||
|
|
||||||
|
|
||||||
|
class FarmDataForwardError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_sensor_external_request_logs_for_farm(*, farm_uuid):
|
def get_sensor_external_request_logs_for_farm(*, farm_uuid):
|
||||||
try:
|
try:
|
||||||
return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id")
|
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):
|
def create_sensor_external_notification(*, physical_device_uuid, payload=None):
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
sensor = (
|
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)
|
.filter(physical_device_uuid=physical_device_uuid)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -89,3 +95,79 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None):
|
|||||||
)
|
)
|
||||||
except (ProgrammingError, OperationalError) as exc:
|
except (ProgrammingError, OperationalError) as exc:
|
||||||
raise ValueError("Sensor external API tables are not migrated.") from 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('/')}"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import requests
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from rest_framework.test import APIRequestFactory
|
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 farm_hub.models import FarmHub, FarmSensor, FarmType
|
||||||
from notifications.models import FarmNotification
|
from notifications.models import FarmNotification
|
||||||
from sensor_catalog.models import SensorCatalog
|
from sensor_catalog.models import SensorCatalog
|
||||||
@@ -11,7 +14,12 @@ from .services import get_latest_sensor_external_request_log
|
|||||||
from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView
|
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):
|
class SensorExternalAPIViewTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.factory = APIRequestFactory()
|
self.factory = APIRequestFactory()
|
||||||
@@ -31,11 +39,38 @@ class SensorExternalAPIViewTests(TestCase):
|
|||||||
code="ext-sensor-v1",
|
code="ext-sensor-v1",
|
||||||
name="External Sensor",
|
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(
|
self.sensor = FarmSensor.objects.create(
|
||||||
farm=self.farm,
|
farm=self.farm,
|
||||||
sensor_catalog=self.sensor_catalog,
|
sensor_catalog=self.sensor_catalog,
|
||||||
physical_device_uuid="11111111-1111-1111-1111-111111111111",
|
physical_device_uuid="11111111-1111-1111-1111-111111111111",
|
||||||
name="External device",
|
name="sensor-7-1",
|
||||||
sensor_type="weather_station",
|
sensor_type="weather_station",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,7 +85,9 @@ class SensorExternalAPIViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 401)
|
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(
|
request = self.factory.post(
|
||||||
"/api/sensor-external-api/",
|
"/api/sensor-external-api/",
|
||||||
{"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}},
|
{"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}},
|
||||||
@@ -75,6 +112,23 @@ class SensorExternalAPIViewTests(TestCase):
|
|||||||
payload={"temp": 12},
|
payload={"temp": 12},
|
||||||
).exists()
|
).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):
|
def test_returns_404_for_unknown_device_uuid(self):
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
@@ -88,6 +142,23 @@ class SensorExternalAPIViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
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):
|
class SensorExternalServiceTests(TestCase):
|
||||||
def test_get_latest_sensor_external_request_log_returns_latest_matching_record(self):
|
def test_get_latest_sensor_external_request_log_returns_latest_matching_record(self):
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ from .serializers import (
|
|||||||
SensorExternalRequestSerializer,
|
SensorExternalRequestSerializer,
|
||||||
)
|
)
|
||||||
from .services import (
|
from .services import (
|
||||||
|
FarmDataForwardError,
|
||||||
create_sensor_external_notification,
|
create_sensor_external_notification,
|
||||||
|
forward_sensor_payload_to_farm_data,
|
||||||
get_farm_sensor_map_for_logs,
|
get_farm_sensor_map_for_logs,
|
||||||
get_sensor_external_request_logs_for_farm,
|
get_sensor_external_request_logs_for_farm,
|
||||||
)
|
)
|
||||||
@@ -61,6 +63,10 @@ class SensorExternalAPIView(APIView):
|
|||||||
physical_device_uuid=serializer.validated_data["uuid"],
|
physical_device_uuid=serializer.validated_data["uuid"],
|
||||||
payload=serializer.validated_data.get("payload"),
|
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:
|
except ValueError as exc:
|
||||||
if "not migrated" in str(exc):
|
if "not migrated" in str(exc):
|
||||||
return Response(
|
return Response(
|
||||||
@@ -68,6 +74,11 @@ class SensorExternalAPIView(APIView):
|
|||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
|
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
|
data = FarmNotificationSerializer(notification).data
|
||||||
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
|
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
|
||||||
|
|||||||
Reference in New Issue
Block a user