UPDATE
This commit is contained in:
@@ -180,20 +180,39 @@ Content-Type: application/json
|
||||
}
|
||||
}
|
||||
],
|
||||
"area_geojson": {
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[51.418934, 35.706815],
|
||||
[51.423054, 35.691062],
|
||||
[51.384258, 35.689389],
|
||||
[51.418934, 35.706815]
|
||||
]
|
||||
"farm_boundary": {
|
||||
"corners": [
|
||||
{"lat": 35.70, "lon": 51.39},
|
||||
{"lat": 35.70, "lon": 51.41},
|
||||
{"lat": 35.72, "lon": 51.41},
|
||||
{"lat": 35.72, "lon": 51.39}
|
||||
]
|
||||
},
|
||||
"sensor_key": "sensor-7-1",
|
||||
"sensor_payload": {
|
||||
"soil_moisture": 45.2,
|
||||
"soil_temperature": 22.5
|
||||
},
|
||||
"irrigation_method_id": 3
|
||||
}
|
||||
```
|
||||
|
||||
برای `farm_boundary` هر دو فرم زیر پشتیبانی میشوند:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[51.418934, 35.706815],
|
||||
[51.423054, 35.691062],
|
||||
[51.384258, 35.689389],
|
||||
[51.418934, 35.706815]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -207,7 +226,11 @@ Content-Type: application/json
|
||||
| `farm_type_uuid` | uuid | بله | UUID نوع مزرعه |
|
||||
| `product_uuids` | array[uuid] | بله | لیست UUID محصولات؛ خالی بودن مجاز نیست |
|
||||
| `sensors` | array | خیر | لیست سنسورهای مزرعه |
|
||||
| `area_geojson` | object | خیر | محدوده زمین به صورت GeoJSON از نوع `Polygon` |
|
||||
| `area_geojson` | object | خیر | محدوده زمین به صورت GeoJSON از نوع `Polygon`؛ اگر `farm_boundary` هم ارسال شود، این فیلد override میشود |
|
||||
| `farm_boundary` | object | خیر | alias برای محدوده مزرعه؛ هم `Polygon` و هم فرم `corners` را میپذیرد |
|
||||
| `sensor_key` | string | خیر | کلید سنسور برای normalize کردن `sensor_payload`؛ پیش فرض `sensor-7-1` |
|
||||
| `sensor_payload` | object | خیر | داده سنسور که همراه ساخت مزرعه به Farm Data sync میشود |
|
||||
| `irrigation_method_id` | integer/null | خیر | شناسه روش آبیاری که همراه ساخت مزرعه به Farm Data sync میشود |
|
||||
|
||||
### فیلدهای هر سنسور در `sensors`
|
||||
|
||||
@@ -224,6 +247,8 @@ Content-Type: application/json
|
||||
### اعتبارسنجیها
|
||||
|
||||
- `farm_uuid` اگر از سمت کلاینت ارسال شود نادیده گرفته میشود.
|
||||
- اگر `farm_boundary` به فرم `corners` ارسال شود، به Polygon تبدیل میشود.
|
||||
- `sensor_payload` باید object باشد، وگرنه خطای validation برمیگردد.
|
||||
- `farm_type_uuid` باید معتبر باشد، وگرنه:
|
||||
|
||||
```json
|
||||
@@ -268,6 +293,11 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### رفتار داخلی
|
||||
|
||||
- بعد از ساخت مزرعه و zoning، backend درخواست `POST /api/farm-data/` را نیز با `farm_uuid`، `farm_boundary`، `plant_ids` و در صورت وجود `sensor_payload`/`irrigation_method_id` ارسال میکند.
|
||||
- اگر sync با Farm Data شکست بخورد، پاسخ endpoint با کد `502` برمیگردد.
|
||||
|
||||
- `area_geojson` باید object معتبر باشد.
|
||||
- اگر `area_geojson.type == "Feature"` باشد، مقدار `geometry` بررسی میشود.
|
||||
- `geometry.type` فقط باید `Polygon` باشد.
|
||||
@@ -551,7 +581,19 @@ Content-Type: application/json
|
||||
"type": "solar"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"farm_boundary": {
|
||||
"corners": [
|
||||
{"lat": 35.70, "lon": 51.39},
|
||||
{"lat": 35.70, "lon": 51.41},
|
||||
{"lat": 35.72, "lon": 51.41},
|
||||
{"lat": 35.72, "lon": 51.39}
|
||||
]
|
||||
},
|
||||
"sensor_payload": {
|
||||
"soil_moisture": 45.2
|
||||
},
|
||||
"irrigation_method_id": 3
|
||||
}
|
||||
```
|
||||
|
||||
@@ -561,7 +603,8 @@ Content-Type: application/json
|
||||
- اگر `farm_type_uuid` ارسال شود، نوع مزرعه بهروزرسانی میشود.
|
||||
- اگر `product_uuids` ارسال شود، همه محصولات مزرعه با لیست جدید جایگزین میشوند.
|
||||
- اگر `sensors` ارسال شود، همه سنسورهای قبلی حذف و سپس سنسورهای جدید از نو ساخته میشوند.
|
||||
- `area_geojson` در متد `update` دریافت میشود ولی در حال حاضر برای update نادیده گرفته میشود و zoning مجدد انجام نمیشود.
|
||||
- اگر `area_geojson` یا `farm_boundary` ارسال شود، zoning مجدد انجام میشود و `current_crop_area` بهروزرسانی میشود.
|
||||
- در هر update نیز درخواست sync به `POST /api/farm-data/` با `farm_uuid`، `farm_boundary`، `plant_ids` و در صورت وجود `sensor_payload`/`irrigation_method_id` ارسال میشود.
|
||||
|
||||
### اعتبارسنجی
|
||||
|
||||
@@ -570,6 +613,7 @@ Content-Type: application/json
|
||||
- در update، اگر `farm_type_uuid` ارسال نشود، از `farm_type` فعلی استفاده میشود.
|
||||
- در update، اگر `product_uuids` ارسال نشود، محصولات فعلی حفظ میشوند.
|
||||
- در update، اگر `sensors` ارسال نشود، سنسورهای فعلی حفظ میشوند.
|
||||
- در update نیز `sensor_payload` باید object باشد.
|
||||
|
||||
### Response 200
|
||||
|
||||
@@ -604,6 +648,15 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### Response 502
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 502,
|
||||
"msg": "Farm data API returned status 400: ..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) حذف مزرعه
|
||||
|
||||
+32
-13
@@ -5,6 +5,7 @@ from access_control.catalog import GOLD_PLAN_CODE
|
||||
from access_control.services import get_effective_subscription_plan
|
||||
|
||||
from .models import FarmHub, FarmSensor, FarmType, Product
|
||||
from .services import normalize_farm_boundary_input
|
||||
from sensor_catalog.models import SensorCatalog
|
||||
|
||||
|
||||
@@ -116,6 +117,7 @@ class FarmSensorWriteSerializer(serializers.ModelSerializer):
|
||||
|
||||
class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
area_geojson = serializers.JSONField(write_only=True, required=False)
|
||||
farm_boundary = serializers.JSONField(write_only=True, required=False)
|
||||
farm_type_uuid = serializers.UUIDField(write_only=True)
|
||||
subscription_plan_uuid = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
||||
product_uuids = serializers.ListField(
|
||||
@@ -124,6 +126,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
allow_empty=False,
|
||||
)
|
||||
sensors = FarmSensorWriteSerializer(many=True, required=False)
|
||||
sensor_key = serializers.CharField(write_only=True, required=False, allow_blank=True, default="sensor-7-1")
|
||||
sensor_payload = serializers.JSONField(write_only=True, required=False)
|
||||
irrigation_method_id = serializers.IntegerField(write_only=True, required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = FarmHub
|
||||
@@ -135,6 +140,10 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
"product_uuids",
|
||||
"sensors",
|
||||
"area_geojson",
|
||||
"farm_boundary",
|
||||
"sensor_key",
|
||||
"sensor_payload",
|
||||
"irrigation_method_id",
|
||||
]
|
||||
|
||||
def to_internal_value(self, data):
|
||||
@@ -144,23 +153,27 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate_area_geojson(self, value):
|
||||
try:
|
||||
return normalize_farm_boundary_input(value)
|
||||
except ValueError as exc:
|
||||
raise serializers.ValidationError(str(exc)) from exc
|
||||
|
||||
def validate_farm_boundary(self, value):
|
||||
try:
|
||||
return normalize_farm_boundary_input(value)
|
||||
except ValueError as exc:
|
||||
raise serializers.ValidationError(str(exc)) from exc
|
||||
|
||||
def validate_sensor_payload(self, value):
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("`area_geojson` must be a GeoJSON object.")
|
||||
|
||||
geometry = value.get("geometry") if value.get("type") == "Feature" else value
|
||||
if not isinstance(geometry, dict):
|
||||
raise serializers.ValidationError("`area_geojson.geometry` is required.")
|
||||
|
||||
if geometry.get("type") != "Polygon":
|
||||
raise serializers.ValidationError("`area_geojson.geometry.type` must be `Polygon`.")
|
||||
|
||||
coordinates = geometry.get("coordinates")
|
||||
if not isinstance(coordinates, list) or not coordinates or not isinstance(coordinates[0], list):
|
||||
raise serializers.ValidationError("`area_geojson.geometry.coordinates` must be a polygon ring.")
|
||||
|
||||
raise serializers.ValidationError("`sensor_payload` must be an object.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
farm_boundary = attrs.pop("farm_boundary", serializers.empty)
|
||||
if farm_boundary is not serializers.empty:
|
||||
attrs["area_geojson"] = farm_boundary
|
||||
|
||||
farm_type_uuid = attrs.get("farm_type_uuid")
|
||||
subscription_plan_uuid = attrs.get("subscription_plan_uuid", serializers.empty)
|
||||
product_uuids = attrs.get("product_uuids")
|
||||
@@ -208,6 +221,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop("area_geojson", None)
|
||||
validated_data.pop("sensor_key", None)
|
||||
validated_data.pop("sensor_payload", None)
|
||||
validated_data.pop("irrigation_method_id", None)
|
||||
sensors_data = validated_data.pop("sensors", [])
|
||||
products = validated_data.pop("products", [])
|
||||
validated_data["farm_type"] = validated_data.pop("farm_type")
|
||||
@@ -225,6 +241,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data.pop("area_geojson", None)
|
||||
validated_data.pop("sensor_key", None)
|
||||
validated_data.pop("sensor_payload", None)
|
||||
validated_data.pop("irrigation_method_id", None)
|
||||
sensors_data = validated_data.pop("sensors", None)
|
||||
products = validated_data.pop("products", None)
|
||||
farm_type = validated_data.pop("farm_type", None)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from crop_zoning.services import (
|
||||
@@ -6,6 +7,12 @@ from crop_zoning.services import (
|
||||
get_initial_zones_payload,
|
||||
normalize_area_feature,
|
||||
)
|
||||
from external_api_adapter import request as external_api_request
|
||||
from external_api_adapter.exceptions import ExternalAPIRequestError
|
||||
|
||||
|
||||
class FarmDataSyncError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def dispatch_farm_zoning(area_feature, farm):
|
||||
@@ -13,13 +20,147 @@ def dispatch_farm_zoning(area_feature, farm):
|
||||
return crop_area, get_initial_zones_payload(crop_area)
|
||||
|
||||
|
||||
def normalize_farm_boundary_input(area_feature):
|
||||
if area_feature is None:
|
||||
return get_default_area_feature()
|
||||
|
||||
if not isinstance(area_feature, dict):
|
||||
raise ValueError("`farm_boundary` must be a GeoJSON object or corners payload.")
|
||||
|
||||
corners = area_feature.get("corners")
|
||||
if isinstance(corners, list) and corners:
|
||||
ring = []
|
||||
for corner in corners:
|
||||
if not isinstance(corner, dict):
|
||||
raise ValueError("Each farm boundary corner must be an object.")
|
||||
lat = corner.get("lat")
|
||||
lon = corner.get("lon")
|
||||
if lat is None or lon is None:
|
||||
raise ValueError("Each farm boundary corner must include `lat` and `lon`.")
|
||||
ring.append([float(lon), float(lat)])
|
||||
|
||||
if ring[0] != ring[-1]:
|
||||
ring.append(ring[0])
|
||||
|
||||
area_feature = {
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {"type": "Polygon", "coordinates": [ring]},
|
||||
}
|
||||
|
||||
return normalize_area_feature(area_feature)
|
||||
|
||||
|
||||
def sync_farm_data(
|
||||
*,
|
||||
farm,
|
||||
area_feature=None,
|
||||
sensor_key="sensor-7-1",
|
||||
sensor_payload=None,
|
||||
plant_ids=None,
|
||||
irrigation_method_id=None,
|
||||
):
|
||||
request_payload = {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"farm_boundary": _extract_boundary_geometry(area_feature, farm=farm),
|
||||
}
|
||||
|
||||
normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=sensor_payload)
|
||||
if normalized_sensor_payload:
|
||||
request_payload["sensor_key"] = sensor_key or "sensor-7-1"
|
||||
request_payload["sensor_payload"] = normalized_sensor_payload
|
||||
|
||||
if plant_ids:
|
||||
request_payload["plant_ids"] = [int(plant_id) for plant_id in plant_ids]
|
||||
|
||||
if irrigation_method_id is not None:
|
||||
request_payload["irrigation_method_id"] = int(irrigation_method_id)
|
||||
|
||||
if not any(key in request_payload for key in ("sensor_payload", "plant_ids", "irrigation_method_id")):
|
||||
raise FarmDataSyncError(
|
||||
"At least one of `sensor_payload`, `plant_ids`, or `irrigation_method_id` is required for farm data sync."
|
||||
)
|
||||
|
||||
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
|
||||
if not api_key:
|
||||
raise FarmDataSyncError("FARM_DATA_API_KEY is not configured.")
|
||||
|
||||
try:
|
||||
response = external_api_request(
|
||||
"ai",
|
||||
_get_farm_data_path(),
|
||||
method="POST",
|
||||
payload=request_payload,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": api_key,
|
||||
"Authorization": f"Api-Key {api_key}",
|
||||
},
|
||||
)
|
||||
except ExternalAPIRequestError as exc:
|
||||
raise FarmDataSyncError(f"Farm data API request failed: {exc}") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
response_body = response.data
|
||||
raise FarmDataSyncError(f"Farm data API returned status {response.status_code}: {response_body}")
|
||||
|
||||
return request_payload
|
||||
|
||||
|
||||
def create_farm_with_zoning(serializer, owner):
|
||||
area_feature = serializer.validated_data.pop("area_geojson", None) or get_default_area_feature()
|
||||
sensor_key = serializer.validated_data.pop("sensor_key", "sensor-7-1")
|
||||
sensor_payload = serializer.validated_data.pop("sensor_payload", None)
|
||||
irrigation_method_id = serializer.validated_data.pop("irrigation_method_id", None)
|
||||
|
||||
with transaction.atomic():
|
||||
farm = serializer.save(owner=owner)
|
||||
crop_area, zoning_payload = dispatch_farm_zoning(area_feature, farm)
|
||||
farm.current_crop_area = crop_area
|
||||
farm.save(update_fields=["current_crop_area", "updated_at"])
|
||||
sync_farm_data(
|
||||
farm=farm,
|
||||
area_feature=area_feature,
|
||||
sensor_key=sensor_key,
|
||||
sensor_payload=sensor_payload,
|
||||
plant_ids=[product.id for product in farm.products.all()],
|
||||
irrigation_method_id=irrigation_method_id,
|
||||
)
|
||||
|
||||
return farm, zoning_payload
|
||||
|
||||
|
||||
def _normalize_sensor_payload(*, sensor_key, sensor_payload):
|
||||
if not sensor_payload:
|
||||
return None
|
||||
if not isinstance(sensor_payload, dict):
|
||||
raise ValueError("`sensor_payload` must be an object.")
|
||||
|
||||
normalized_sensor_key = sensor_key or "sensor-7-1"
|
||||
if all(isinstance(value, dict) for value in sensor_payload.values()):
|
||||
return sensor_payload
|
||||
return {normalized_sensor_key: sensor_payload}
|
||||
|
||||
|
||||
def _extract_boundary_geometry(area_feature, *, farm):
|
||||
if area_feature is not None:
|
||||
geometry = (area_feature.get("geometry") or {}) if area_feature.get("type") == "Feature" else area_feature
|
||||
if geometry.get("type") != "Polygon":
|
||||
raise FarmDataSyncError("Farm boundary geometry must be a Polygon.")
|
||||
return geometry
|
||||
|
||||
crop_area = farm.current_crop_area or farm.crop_areas.order_by("-created_at", "-id").first()
|
||||
if crop_area is None:
|
||||
raise FarmDataSyncError("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 FarmDataSyncError("Farm boundary geometry must be a Polygon.")
|
||||
return geometry
|
||||
|
||||
|
||||
def _get_farm_data_path():
|
||||
return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/")
|
||||
|
||||
+117
-5
@@ -1,15 +1,17 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
from unittest.mock import patch
|
||||
|
||||
from access_control.models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
|
||||
from access_control.services import build_farm_access_profile
|
||||
from access_control.views import FarmAccessProfileView
|
||||
from crop_zoning.models import CropArea
|
||||
from external_api_adapter.adapter import AdapterResponse
|
||||
from farm_hub.models import FarmHub, FarmType, Product
|
||||
from farm_hub.serializers import FarmHubSerializer
|
||||
from farm_hub.seeds import seed_admin_farm
|
||||
from farm_hub.views import FarmListCreateView, FarmTypeListView, FarmTypeProductsView
|
||||
from farm_hub.views import FarmDetailView, FarmListCreateView, FarmTypeListView, FarmTypeProductsView
|
||||
from sensor_catalog.models import SensorCatalog
|
||||
|
||||
|
||||
@@ -33,6 +35,7 @@ AREA_GEOJSON = {
|
||||
@override_settings(
|
||||
USE_EXTERNAL_API_MOCK=True,
|
||||
CROP_ZONE_CHUNK_AREA_SQM=200000,
|
||||
FARM_DATA_API_KEY="farm-data-key",
|
||||
)
|
||||
class FarmListCreateViewTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -52,7 +55,9 @@ class FarmListCreateViewTests(TestCase):
|
||||
defaults={"supported_power_sources": ["solar", "direct_power"]},
|
||||
)
|
||||
|
||||
def test_create_farm_with_area_geojson_creates_crop_zoning_payload(self):
|
||||
@patch("farm_hub.services.external_api_request")
|
||||
def test_create_farm_with_area_geojson_creates_crop_zoning_payload(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(status_code=201, data={})
|
||||
physical_device_uuid = "33333333-3333-3333-3333-333333333333"
|
||||
request = self.factory.post(
|
||||
"/api/farm-hub/",
|
||||
@@ -94,8 +99,26 @@ class FarmListCreateViewTests(TestCase):
|
||||
CropArea.objects.get().zone_count,
|
||||
)
|
||||
self.assertEqual(CropArea.objects.count(), 1)
|
||||
mock_external_api_request.assert_called_once_with(
|
||||
"ai",
|
||||
"/api/farm-data/",
|
||||
method="POST",
|
||||
payload={
|
||||
"farm_uuid": response.data["data"]["farm_uuid"],
|
||||
"farm_boundary": AREA_GEOJSON["geometry"],
|
||||
"plant_ids": [self.wheat.id],
|
||||
},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": "farm-data-key",
|
||||
"Authorization": "Api-Key farm-data-key",
|
||||
},
|
||||
)
|
||||
|
||||
def test_create_farm_ignores_client_farm_uuid_and_generates_new_one(self):
|
||||
@patch("farm_hub.services.external_api_request")
|
||||
def test_create_farm_ignores_client_farm_uuid_and_generates_new_one(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(status_code=201, data={})
|
||||
request = self.factory.post(
|
||||
"/api/farm-hub/",
|
||||
{
|
||||
@@ -114,7 +137,9 @@ class FarmListCreateViewTests(TestCase):
|
||||
self.assertNotEqual(response.data["data"]["farm_uuid"], "11111111-1111-1111-1111-111111111111")
|
||||
self.assertIsNotNone(response.data["data"]["area_uuid"])
|
||||
|
||||
def test_create_farm_rejects_unknown_sensor_catalog_uuid(self):
|
||||
@patch("farm_hub.services.external_api_request")
|
||||
def test_create_farm_rejects_unknown_sensor_catalog_uuid(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(status_code=201, data={})
|
||||
request = self.factory.post(
|
||||
"/api/farm-hub/",
|
||||
{
|
||||
@@ -137,7 +162,9 @@ class FarmListCreateViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("sensor_catalog_uuid", response.data["sensors"][0])
|
||||
|
||||
def test_create_farm_defaults_to_gold_plan_when_not_provided(self):
|
||||
@patch("farm_hub.services.external_api_request")
|
||||
def test_create_farm_defaults_to_gold_plan_when_not_provided(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(status_code=201, data={})
|
||||
request = self.factory.post(
|
||||
"/api/farm-hub/",
|
||||
{
|
||||
@@ -154,6 +181,91 @@ class FarmListCreateViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["data"]["subscription_plan"]["code"], "gold")
|
||||
|
||||
def test_create_farm_rejects_non_object_sensor_payload(self):
|
||||
request = self.factory.post(
|
||||
"/api/farm-hub/",
|
||||
{
|
||||
"name": "farm-invalid-sensor-payload",
|
||||
"farm_type_uuid": str(self.farm_type.uuid),
|
||||
"product_uuids": [str(self.wheat.uuid)],
|
||||
"sensor_payload": ["invalid"],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = FarmListCreateView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["sensor_payload"], ["`sensor_payload` must be an object."])
|
||||
|
||||
@patch("farm_hub.services.external_api_request")
|
||||
def test_patch_farm_forwards_farm_data_fields(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(status_code=201, data={})
|
||||
farm = FarmHub.objects.create(
|
||||
owner=self.user,
|
||||
farm_type=self.farm_type,
|
||||
subscription_plan=self.plan,
|
||||
name="patch-target",
|
||||
)
|
||||
farm.products.add(self.wheat)
|
||||
|
||||
request = self.factory.patch(
|
||||
f"/api/farm-hub/{farm.farm_uuid}/",
|
||||
{
|
||||
"farm_boundary": {
|
||||
"corners": [
|
||||
{"lat": 35.70, "lon": 51.39},
|
||||
{"lat": 35.70, "lon": 51.41},
|
||||
{"lat": 35.72, "lon": 51.41},
|
||||
{"lat": 35.72, "lon": 51.39},
|
||||
]
|
||||
},
|
||||
"sensor_payload": {"soil_moisture": 45.2},
|
||||
"irrigation_method_id": 3,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = FarmDetailView.as_view()(request, farm_uuid=farm.farm_uuid)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
farm.refresh_from_db()
|
||||
self.assertIsNotNone(farm.current_crop_area)
|
||||
mock_external_api_request.assert_called_once_with(
|
||||
"ai",
|
||||
"/api/farm-data/",
|
||||
method="POST",
|
||||
payload={
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"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_key": "sensor-7-1",
|
||||
"sensor_payload": {
|
||||
"sensor-7-1": {"soil_moisture": 45.2},
|
||||
},
|
||||
"plant_ids": [self.wheat.id],
|
||||
"irrigation_method_id": 3,
|
||||
},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": "farm-data-key",
|
||||
"Authorization": "Api-Key farm-data-key",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
USE_EXTERNAL_API_MOCK=True,
|
||||
|
||||
+25
-2
@@ -1,3 +1,4 @@
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -14,7 +15,7 @@ from .serializers import (
|
||||
FarmTypeSerializer,
|
||||
ProductSerializer,
|
||||
)
|
||||
from .services import create_farm_with_zoning
|
||||
from .services import FarmDataSyncError, create_farm_with_zoning, dispatch_farm_zoning, sync_farm_data
|
||||
|
||||
|
||||
class FarmHubBaseView(APIView):
|
||||
@@ -64,6 +65,8 @@ class FarmListCreateView(FarmHubBaseView):
|
||||
farm, zoning_payload = create_farm_with_zoning(serializer, owner=request.user)
|
||||
except ValueError as exc:
|
||||
raise serializers.ValidationError({"area_geojson": [str(exc)]}) from exc
|
||||
except FarmDataSyncError as exc:
|
||||
return Response({"code": 502, "msg": str(exc)}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
except ImproperlyConfigured as exc:
|
||||
return Response({"code": 500, "msg": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
data = FarmHubSerializer(farm).data
|
||||
@@ -137,7 +140,27 @@ class FarmDetailView(FarmHubBaseView):
|
||||
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = FarmHubCreateSerializer(farm, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
area_feature = serializer.validated_data.get("area_geojson", None)
|
||||
sensor_key = serializer.validated_data.get("sensor_key", "sensor-7-1")
|
||||
sensor_payload = serializer.validated_data.get("sensor_payload", None)
|
||||
irrigation_method_id = serializer.validated_data.get("irrigation_method_id", None)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
serializer.save()
|
||||
if area_feature is not None:
|
||||
crop_area, _zoning_payload = dispatch_farm_zoning(area_feature, serializer.instance)
|
||||
serializer.instance.current_crop_area = crop_area
|
||||
serializer.instance.save(update_fields=["current_crop_area", "updated_at"])
|
||||
sync_farm_data(
|
||||
farm=serializer.instance,
|
||||
area_feature=area_feature,
|
||||
sensor_key=sensor_key,
|
||||
sensor_payload=sensor_payload,
|
||||
plant_ids=[product.id for product in serializer.instance.products.all()],
|
||||
irrigation_method_id=irrigation_method_id,
|
||||
)
|
||||
except FarmDataSyncError as exc:
|
||||
return Response({"code": 502, "msg": str(exc)}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
farm.refresh_from_db()
|
||||
data = FarmHubSerializer(farm).data
|
||||
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user