This commit is contained in:
2026-04-08 23:00:54 +03:30
parent d95eff3187
commit c60a1555e2
5 changed files with 672 additions and 140 deletions
@@ -0,0 +1,282 @@
# Crop Zoning Layer Area API Changes For Frontend
این فایل برای تیم فرانت نوشته شده و فقط تغییرات جدیدی را توضیح می‌دهد که برای endpointهای لایه‌ای ماژول `crop-zoning` اضافه شده‌اند.
## خلاصه تغییر
سه API جدید اضافه شده‌اند که از نظر ساختار response دقیقا شبیه `GET /area/` هستند:
- `GET /api/crop-zoning/water-need/`
- `GET /api/crop-zoning/soil-quality/`
- `GET /api/crop-zoning/cultivation-risk/`
هر سه endpoint:
- به `farm_uuid` نیاز دارند
- از `page` و `page_size` پشتیبانی می‌کنند
- در صورت نبود داده، همان روند ساخت area و zone و dispatch task را مثل `area/` انجام می‌دهند
- همان ساختار `task`, `area`, `zones`, `pagination` را برمی‌گردانند
## هدف این تغییر
قبلا فرانت برای داده‌های لایه‌ای بیشتر به endpointهای `zones/...` متکی بود که خروجی آن‌ها فقط لیست زون‌ها بود.
الان برای هر لایه یک endpoint جدید دارید که خروجی آن برای صفحه map دقیقا با `area/` هم‌فرمت است و استفاده در UI ساده‌تر می‌شود.
## Base Path
```text
/api/crop-zoning/
```
## Authentication
```http
Authorization: Bearer <access_token>
Content-Type: application/json
```
## Endpointهای جدید
### 1) Water Need
```http
GET /api/crop-zoning/water-need/?farm_uuid=<farm_uuid>&page=1&page_size=10
```
### 2) Soil Quality
```http
GET /api/crop-zoning/soil-quality/?farm_uuid=<farm_uuid>&page=1&page_size=10
```
### 3) Cultivation Risk
```http
GET /api/crop-zoning/cultivation-risk/?farm_uuid=<farm_uuid>&page=1&page_size=10
```
## Query Params
- `farm_uuid`: اجباری، UUID مزرعه
- `page`: اختیاری، شماره صفحه زون‌ها، پیش‌فرض `1`
- `page_size`: اختیاری، تعداد زون در هر صفحه، پیش‌فرض `10`
## ساختار کلی response
ساختار کلی هر سه API:
```json
{
"status": "success",
"data": {
"task": {},
"area": {},
"zones": [],
"pagination": {}
}
}
```
یعنی برای فرانت:
- `task` دقیقا مثل `area/` است
- `area` دقیقا مثل `area/` است
- `pagination` دقیقا مثل `area/` است
- فقط shape آیتم‌های داخل `zones` بر اساس نوع لایه فرق می‌کند
## تفاوت `zones` در هر endpoint
### `GET /water-need/`
هر آیتم در `zones` این فیلدها را دارد:
- `zoneId`
- `zoneUuid`
- `geometry`
- `center`
- `area_sqm`
- `area_hectares`
- `sequence`
- `processing_status`
- `processing_error`
- `waterNeedLayer`
نمونه:
```json
{
"zoneId": "zone-0",
"zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940",
"geometry": {
"type": "Polygon",
"coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]]
},
"center": {
"latitude": 35.68983816,
"longitude": 51.38481102
},
"area_sqm": 9999.91,
"area_hectares": 1,
"sequence": 0,
"processing_status": "completed",
"processing_error": "",
"waterNeedLayer": {
"level": "medium",
"value": "4820-5820 m³/ha",
"color": "#0ea5e9"
}
}
```
### `GET /soil-quality/`
هر آیتم در `zones` این فیلدها را دارد:
- `zoneId`
- `zoneUuid`
- `geometry`
- `center`
- `area_sqm`
- `area_hectares`
- `sequence`
- `processing_status`
- `processing_error`
- `soilQualityLayer`
نمونه:
```json
{
"zoneId": "zone-0",
"zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940",
"geometry": {
"type": "Polygon",
"coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]]
},
"center": {
"latitude": 35.68983816,
"longitude": 51.38481102
},
"area_sqm": 9999.91,
"area_hectares": 1,
"sequence": 0,
"processing_status": "completed",
"processing_error": "",
"soilQualityLayer": {
"level": "high",
"score": 89,
"color": "#22c55e"
}
}
```
### `GET /cultivation-risk/`
هر آیتم در `zones` این فیلدها را دارد:
- `zoneId`
- `zoneUuid`
- `geometry`
- `center`
- `area_sqm`
- `area_hectares`
- `sequence`
- `processing_status`
- `processing_error`
- `cultivationRiskLayer`
نمونه:
```json
{
"zoneId": "zone-0",
"zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940",
"geometry": {
"type": "Polygon",
"coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]]
},
"center": {
"latitude": 35.68983816,
"longitude": 51.38481102
},
"area_sqm": 9999.91,
"area_hectares": 1,
"sequence": 0,
"processing_status": "completed",
"processing_error": "",
"cultivationRiskLayer": {
"level": "low",
"color": "#22c55e"
}
}
```
## نکته مهم برای فرانت
این endpointها عمدا شبیه `area/` طراحی شده‌اند تا فرانت بتواند با یک data flow یکسان کار کند:
- polygon اصلی را از `data.area` بگیرد
- task status را از `data.task` بخواند
- pagination را از `data.pagination` بخواند
- فقط renderer مربوط به هر لایه را روی `data.zones` اعمال کند
## پیشنهاد استفاده در UI
### اگر صفحه overview اصلی دارید
- همچنان `GET /area/` بهترین گزینه برای صفحه overview کامل است، چون علاوه بر layerها فیلدهای crop و recommendation را هم داخل هر zone دارد.
### اگر صفحه یا tab مخصوص هر layer دارید
- برای تب نیاز آبی: `GET /water-need/`
- برای تب کیفیت خاک: `GET /soil-quality/`
- برای تب ریسک کشت: `GET /cultivation-risk/`
این کار باعث می‌شود فرانت فقط داده موردنیاز همان layer را بگیرد.
## وضعیت backward compatibility
- endpoint قدیمی `GET /area/` بدون تغییر باقی مانده است
- endpointهای جدید breaking change ایجاد نمی‌کنند
- فقط سه مسیر جدید به API اضافه شده است
## خطاها
رفتار خطاها مثل `area/` است.
### نبودن `farm_uuid`
```json
{
"status": "error",
"message": "farm_uuid is required."
}
```
### پیدا نشدن مزرعه
```json
{
"status": "error",
"message": "Farm not found."
}
```
### نامعتبر بودن `page` یا `page_size`
```json
{
"status": "error",
"message": "page must be a positive integer."
}
```
## جمع‌بندی
تغییر جدید برای فرانت این است که الان به جز `area/`، سه API جدید هم دارید که:
- از نظر query params شبیه `area/` هستند
- از نظر response wrapper شبیه `area/` هستند
- فقط payload داخلی `zones` را بر اساس نوع layer تخصصی می‌کنند
در نتیجه اگر UI شما برای `area/` آماده است، اتصال این سه endpoint جدید باید با کمترین تغییر انجام شود.
+114 -37
View File
@@ -514,20 +514,13 @@ def build_initial_zone_payload(zone):
def build_area_zone_payload(zone):
base_payload = _build_area_layer_zone_base_payload(zone)
recommendation = getattr(zone, "recommendation", None)
water_need_layer = getattr(zone, "water_need_layer", None)
soil_quality_layer = getattr(zone, "soil_quality_layer", None)
cultivation_risk_layer = getattr(zone, "cultivation_risk_layer", None)
return {
"zoneId": zone.zone_id,
"zoneUuid": str(zone.uuid),
"geometry": zone.geometry,
"center": zone.center,
"area_sqm": zone.area_sqm,
"area_hectares": zone.area_hectares,
"sequence": zone.sequence,
"processing_status": zone.processing_status,
"processing_error": zone.processing_error,
base_payload.update(
{
"crop": recommendation.product.product_id if recommendation else "",
"matchPercent": recommendation.match_percent if recommendation else 0,
"waterNeed": recommendation.water_need if recommendation else "",
@@ -547,6 +540,54 @@ def build_area_zone_payload(zone):
"color": getattr(cultivation_risk_layer, "color", ""),
},
}
)
return base_payload
def _build_area_layer_zone_base_payload(zone):
return {
"zoneId": zone.zone_id,
"zoneUuid": str(zone.uuid),
"geometry": zone.geometry,
"center": zone.center,
"area_sqm": zone.area_sqm,
"area_hectares": zone.area_hectares,
"sequence": zone.sequence,
"processing_status": zone.processing_status,
"processing_error": zone.processing_error,
}
def build_water_need_area_zone_payload(zone):
base_payload = _build_area_layer_zone_base_payload(zone)
water_need_layer = getattr(zone, "water_need_layer", None)
base_payload["waterNeedLayer"] = {
"level": getattr(water_need_layer, "level", ""),
"value": getattr(water_need_layer, "value", ""),
"color": getattr(water_need_layer, "color", ""),
}
return base_payload
def build_soil_quality_area_zone_payload(zone):
base_payload = _build_area_layer_zone_base_payload(zone)
soil_quality_layer = getattr(zone, "soil_quality_layer", None)
base_payload["soilQualityLayer"] = {
"level": getattr(soil_quality_layer, "level", ""),
"score": getattr(soil_quality_layer, "score", 0),
"color": getattr(soil_quality_layer, "color", ""),
}
return base_payload
def build_cultivation_risk_area_zone_payload(zone):
base_payload = _build_area_layer_zone_base_payload(zone)
cultivation_risk_layer = getattr(zone, "cultivation_risk_layer", None)
base_payload["cultivationRiskLayer"] = {
"level": getattr(cultivation_risk_layer, "level", ""),
"color": getattr(cultivation_risk_layer, "color", ""),
}
return base_payload
def persist_zone_analysis_metrics(zone, metrics):
@@ -949,9 +990,38 @@ def _zones_queryset(zone_ids=None):
return queryset
def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
def _get_idle_area_payload(page, page_size):
return {
"task": {
"status": "IDLE",
"area_uuid": "",
"total_zones": 0,
"completed_zones": 0,
"processing_zones": 0,
"pending_zones": 0,
"failed_zones": 0,
"failed_zone_errors": [],
"cell_side_km": round(get_default_cell_side_km(), 4),
},
"area": get_default_area_feature(),
"zones": [],
"pagination": {
"page": page,
"page_size": page_size,
"total_pages": 0,
"total_zones": 0,
"returned_zones": 0,
"has_next": False,
"has_previous": False,
},
}
def _build_latest_area_layer_payload(zone_builder, area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
area = area or CropArea.objects.order_by("-created_at", "-id").first()
if area:
if not area:
return _get_idle_area_payload(page, page_size)
status_zones = list(area.zones.only("zone_id", "task_id", "processing_status", "processing_error"))
total_zones = len(status_zones)
completed_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_COMPLETED)
@@ -1026,7 +1096,7 @@ def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE)
"cell_side_km": round(math.sqrt(max(area.chunk_area_sqm, 1)) / 1000.0, 4),
},
"area": area.geometry,
"zones": [build_area_zone_payload(zone) for zone in zones],
"zones": [zone_builder(zone) for zone in zones],
"pagination": {
"page": page,
"page_size": page_size,
@@ -1037,30 +1107,37 @@ def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE)
"has_previous": page > 1 and total_pages > 0,
},
}
return {
"task": {
"status": "IDLE",
"area_uuid": "",
"total_zones": 0,
"completed_zones": 0,
"processing_zones": 0,
"pending_zones": 0,
"failed_zones": 0,
"failed_zone_errors": [],
"cell_side_km": round(get_default_cell_side_km(), 4),
},
"area": get_default_area_feature(),
"zones": [],
"pagination": {
"page": page,
"page_size": page_size,
"total_pages": 0,
"total_zones": 0,
"returned_zones": 0,
"has_next": False,
"has_previous": False,
},
}
def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _build_latest_area_layer_payload(build_area_zone_payload, area=area, page=page, page_size=page_size)
def get_latest_water_need_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _build_latest_area_layer_payload(
build_water_need_area_zone_payload,
area=area,
page=page,
page_size=page_size,
)
def get_latest_soil_quality_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _build_latest_area_layer_payload(
build_soil_quality_area_zone_payload,
area=area,
page=page,
page_size=page_size,
)
def get_latest_cultivation_risk_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _build_latest_area_layer_payload(
build_cultivation_risk_area_zone_payload,
area=area,
page=page,
page_size=page_size,
)
def get_initial_zones_payload(crop_area):
+105 -1
View File
@@ -8,7 +8,13 @@ from kombu.exceptions import OperationalError
from rest_framework.test import APIRequestFactory, force_authenticate
from crop_zoning.models import CropArea, CropZone
from crop_zoning.views import AreaView, ZonesInitialView
from crop_zoning.views import (
AreaView,
CultivationRiskView,
SoilQualityView,
WaterNeedView,
ZonesInitialView,
)
from farm_hub.models import FarmHub, FarmType
@@ -313,3 +319,101 @@ class AreaViewTests(TestCase):
self.assertEqual(response.status_code, 200)
mock_dispatch.assert_called_once_with(zone_ids=[stale_zone.id], force=True)
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class LayerAreaViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="layer-farmer",
password="secret123",
email="layer@example.com",
phone_number="09120000002",
)
self.farm_type = FarmType.objects.create(name="باغی")
self.farm = FarmHub.objects.create(owner=self.user, name="layer-farm", farm_type=self.farm_type)
def _create_area(self, **kwargs):
defaults = {
"farm": self.farm,
"geometry": AREA_GEOJSON,
"points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
"center": {"longitude": 51.40874867, "latitude": 35.69575533},
"area_sqm": 300000,
"area_hectares": 30,
"chunk_area_sqm": 200000,
"zone_count": 1,
}
defaults.update(kwargs)
return CropArea.objects.create(**defaults)
def _create_completed_zone(self):
crop_area = self._create_area()
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=300000,
area_hectares=30,
sequence=0,
processing_status=CropZone.STATUS_COMPLETED,
task_id="celery-task-1",
)
return crop_area
def _request(self, path):
request = self.factory.get(f"{path}?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
return request
def test_water_need_view_requires_farm_uuid(self):
request = self.factory.get("/api/crop-zoning/water-need/")
force_authenticate(request, user=self.user)
response = WaterNeedView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "farm_uuid is required.")
def test_water_need_view_returns_area_style_payload(self):
self._create_completed_zone()
response = WaterNeedView.as_view()(self._request("/api/crop-zoning/water-need/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0])
self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0])
def test_soil_quality_view_returns_area_style_payload(self):
self._create_completed_zone()
response = SoilQualityView.as_view()(self._request("/api/crop-zoning/soil-quality/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("soilQualityLayer", response.data["data"]["zones"][0])
self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0])
def test_cultivation_risk_view_returns_area_style_payload(self):
self._create_completed_zone()
response = CultivationRiskView.as_view()(self._request("/api/crop-zoning/cultivation-risk/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("cultivationRiskLayer", response.data["data"]["zones"][0])
self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0])
+6
View File
@@ -2,7 +2,10 @@ from django.urls import path
from .views import (
AreaView,
CultivationRiskView,
ProductsView,
SoilQualityView,
WaterNeedView,
ZoneDetailsView,
ZonesCultivationRiskView,
ZonesInitialView,
@@ -12,6 +15,9 @@ from .views import (
urlpatterns = [
path("area/", AreaView.as_view(), name="crop-zoning-area"),
path("water-need/", WaterNeedView.as_view(), name="crop-zoning-water-need"),
path("soil-quality/", SoilQualityView.as_view(), name="crop-zoning-soil-quality"),
path("cultivation-risk/", CultivationRiskView.as_view(), name="crop-zoning-cultivation-risk"),
path("products/", ProductsView.as_view(), name="crop-zoning-products"),
# path("zones/initial/", ZonesInitialView.as_view(), name="crop-zoning-zones-initial"),
# path(
+75 -12
View File
@@ -10,10 +10,13 @@ from config.swagger import status_response
from .services import (
create_zones_and_dispatch,
ensure_latest_area_ready_for_processing,
get_latest_cultivation_risk_payload,
get_cultivation_risk_payload,
get_default_area_feature,
get_initial_zones_payload,
get_latest_area_payload,
get_latest_soil_quality_payload,
get_latest_water_need_payload,
get_products_payload,
get_soil_quality_payload,
get_water_need_payload,
@@ -22,10 +25,7 @@ from .services import (
)
class AreaView(APIView):
@extend_schema(
tags=["Crop Zoning"],
parameters=[
AREA_QUERY_PARAMETERS = [
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
@@ -47,13 +47,12 @@ class AreaView(APIView):
required=False,
description="تعداد زون در هر صفحه. مقدار پيش فرض 10 است.",
),
],
responses={
200: status_response("CropZoningAreaResponse", data=serializers.JSONField()),
400: status_response("CropZoningAreaValidationError", data=serializers.JSONField()),
500: status_response("CropZoningAreaServerError", data=serializers.JSONField()),
},
)
]
class BaseAreaDataView(APIView):
payload_getter = None
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
try:
@@ -65,11 +64,75 @@ class AreaView(APIView):
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(
{"status": "success", "data": get_latest_area_payload(crop_area, page=page, page_size=page_size)},
{"status": "success", "data": self.payload_getter(crop_area, page=page, page_size=page_size)},
status=status.HTTP_200_OK,
)
class AreaView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_area_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningAreaResponse", data=serializers.JSONField()),
400: status_response("CropZoningAreaValidationError", data=serializers.JSONField()),
500: status_response("CropZoningAreaServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class WaterNeedView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_water_need_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningWaterNeedResponse", data=serializers.JSONField()),
400: status_response("CropZoningWaterNeedValidationError", data=serializers.JSONField()),
500: status_response("CropZoningWaterNeedServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class SoilQualityView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_soil_quality_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningSoilQualityResponse", data=serializers.JSONField()),
400: status_response("CropZoningSoilQualityValidationError", data=serializers.JSONField()),
500: status_response("CropZoningSoilQualityServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class CultivationRiskView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_cultivation_risk_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningCultivationRiskResponse", data=serializers.JSONField()),
400: status_response("CropZoningCultivationRiskValidationError", data=serializers.JSONField()),
500: status_response("CropZoningCultivationRiskServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class ProductsView(APIView):
@extend_schema(
tags=["Crop Zoning"],