diff --git a/crop_zoning/CROP_ZONING_FRONTEND_LAYER_AREA_CHANGES.md b/crop_zoning/CROP_ZONING_FRONTEND_LAYER_AREA_CHANGES.md new file mode 100644 index 0000000..016c121 --- /dev/null +++ b/crop_zoning/CROP_ZONING_FRONTEND_LAYER_AREA_CHANGES.md @@ -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 +Content-Type: application/json +``` + +## Endpointهای جدید + +### 1) Water Need + +```http +GET /api/crop-zoning/water-need/?farm_uuid=&page=1&page_size=10 +``` + +### 2) Soil Quality + +```http +GET /api/crop-zoning/soil-quality/?farm_uuid=&page=1&page_size=10 +``` + +### 3) Cultivation Risk + +```http +GET /api/crop-zoning/cultivation-risk/?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 جدید باید با کمترین تغییر انجام شود. diff --git a/crop_zoning/services.py b/crop_zoning/services.py index 8441b2e..a4b2aa3 100644 --- a/crop_zoning/services.py +++ b/crop_zoning/services.py @@ -514,10 +514,37 @@ 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) + 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 "", + "estimatedProfit": recommendation.estimated_profit if recommendation else "", + "waterNeedLayer": { + "level": getattr(water_need_layer, "level", ""), + "value": getattr(water_need_layer, "value", ""), + "color": getattr(water_need_layer, "color", ""), + }, + "soilQualityLayer": { + "level": getattr(soil_quality_layer, "level", ""), + "score": getattr(soil_quality_layer, "score", 0), + "color": getattr(soil_quality_layer, "color", ""), + }, + "cultivationRiskLayer": { + "level": getattr(cultivation_risk_layer, "level", ""), + "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), @@ -528,27 +555,41 @@ def build_area_zone_payload(zone): "sequence": zone.sequence, "processing_status": zone.processing_status, "processing_error": zone.processing_error, - "crop": recommendation.product.product_id if recommendation else "", - "matchPercent": recommendation.match_percent if recommendation else 0, - "waterNeed": recommendation.water_need if recommendation else "", - "estimatedProfit": recommendation.estimated_profit if recommendation else "", - "waterNeedLayer": { - "level": getattr(water_need_layer, "level", ""), - "value": getattr(water_need_layer, "value", ""), - "color": getattr(water_need_layer, "color", ""), - }, - "soilQualityLayer": { - "level": getattr(soil_quality_layer, "level", ""), - "score": getattr(soil_quality_layer, "score", 0), - "color": getattr(soil_quality_layer, "color", ""), - }, - "cultivationRiskLayer": { - "level": getattr(cultivation_risk_layer, "level", ""), - "color": getattr(cultivation_risk_layer, "color", ""), - }, } +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): ensure_products_exist() product = CropProduct.objects.get(product_id=metrics["recommended_crop"]) @@ -949,94 +990,7 @@ def _zones_queryset(zone_ids=None): return queryset -def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): - area = area or CropArea.objects.order_by("-created_at", "-id").first() - if area: - 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) - processing_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PROCESSING) - failed_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_FAILED) - pending_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PENDING) - total_pages = math.ceil(total_zones / page_size) if total_zones else 0 - start_index = (page - 1) * page_size - end_index = start_index + page_size - zones = list(_zones_queryset().filter(crop_area=area)[start_index:end_index]) - - if failed_zones: - task_status = "FAILURE" - elif total_zones and completed_zones == total_zones: - task_status = "SUCCESS" - elif processing_zones or completed_zones: - task_status = "PROCESSING" - else: - task_status = "PENDING" - - current_stage = "waiting_to_start" - if failed_zones: - current_stage = "failed" - elif total_zones and completed_zones == total_zones: - current_stage = "completed" - elif processing_zones: - current_stage = "processing_zones" - elif pending_zones and completed_zones: - current_stage = "continuing_processing" - elif pending_zones: - current_stage = "queued" - - progress_percent = 0 - if total_zones: - progress_percent = round((completed_zones / total_zones) * 100, 2) - - return { - "task": { - "status": task_status, - "stage": current_stage, - "stage_label": { - "waiting_to_start": "در انتظار شروع پردازش", - "queued": "تسک ساخته شده و در صف پردازش است", - "processing_zones": "در حال پردازش زون‌ها", - "continuing_processing": "بخشی از زون‌ها پردازش شده و بقیه در صف هستند", - "completed": "پردازش همه زون‌ها کامل شده است", - "failed": "پردازش بعضی زون‌ها با خطا مواجه شده است", - }[current_stage], - "area_uuid": str(area.uuid), - "total_zones": total_zones, - "completed_zones": completed_zones, - "processing_zones": processing_zones, - "pending_zones": pending_zones, - "failed_zones": failed_zones, - "remaining_zones": max(total_zones - completed_zones, 0), - "progress_percent": progress_percent, - "summary": { - "done": completed_zones, - "in_progress": processing_zones, - "remaining": pending_zones, - "failed": failed_zones, - }, - "message": f"از مجموع {total_zones} زون، {completed_zones} زون پردازش شده، {processing_zones} زون در حال پردازش و {pending_zones} زون باقی مانده است.", - "failed_zone_errors": [ - { - "zoneId": zone.zone_id, - "error": zone.processing_error, - } - for zone in status_zones - if zone.processing_status == CropZone.STATUS_FAILED and zone.processing_error - ], - "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], - "pagination": { - "page": page, - "page_size": page_size, - "total_pages": total_pages, - "total_zones": total_zones, - "returned_zones": len(zones), - "has_next": page < total_pages, - "has_previous": page > 1 and total_pages > 0, - }, - } +def _get_idle_area_payload(page, page_size): return { "task": { "status": "IDLE", @@ -1063,6 +1017,129 @@ def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE) } +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 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) + processing_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PROCESSING) + failed_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_FAILED) + pending_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PENDING) + total_pages = math.ceil(total_zones / page_size) if total_zones else 0 + start_index = (page - 1) * page_size + end_index = start_index + page_size + zones = list(_zones_queryset().filter(crop_area=area)[start_index:end_index]) + + if failed_zones: + task_status = "FAILURE" + elif total_zones and completed_zones == total_zones: + task_status = "SUCCESS" + elif processing_zones or completed_zones: + task_status = "PROCESSING" + else: + task_status = "PENDING" + + current_stage = "waiting_to_start" + if failed_zones: + current_stage = "failed" + elif total_zones and completed_zones == total_zones: + current_stage = "completed" + elif processing_zones: + current_stage = "processing_zones" + elif pending_zones and completed_zones: + current_stage = "continuing_processing" + elif pending_zones: + current_stage = "queued" + + progress_percent = 0 + if total_zones: + progress_percent = round((completed_zones / total_zones) * 100, 2) + + return { + "task": { + "status": task_status, + "stage": current_stage, + "stage_label": { + "waiting_to_start": "در انتظار شروع پردازش", + "queued": "تسک ساخته شده و در صف پردازش است", + "processing_zones": "در حال پردازش زون‌ها", + "continuing_processing": "بخشی از زون‌ها پردازش شده و بقیه در صف هستند", + "completed": "پردازش همه زون‌ها کامل شده است", + "failed": "پردازش بعضی زون‌ها با خطا مواجه شده است", + }[current_stage], + "area_uuid": str(area.uuid), + "total_zones": total_zones, + "completed_zones": completed_zones, + "processing_zones": processing_zones, + "pending_zones": pending_zones, + "failed_zones": failed_zones, + "remaining_zones": max(total_zones - completed_zones, 0), + "progress_percent": progress_percent, + "summary": { + "done": completed_zones, + "in_progress": processing_zones, + "remaining": pending_zones, + "failed": failed_zones, + }, + "message": f"از مجموع {total_zones} زون، {completed_zones} زون پردازش شده، {processing_zones} زون در حال پردازش و {pending_zones} زون باقی مانده است.", + "failed_zone_errors": [ + { + "zoneId": zone.zone_id, + "error": zone.processing_error, + } + for zone in status_zones + if zone.processing_status == CropZone.STATUS_FAILED and zone.processing_error + ], + "cell_side_km": round(math.sqrt(max(area.chunk_area_sqm, 1)) / 1000.0, 4), + }, + "area": area.geometry, + "zones": [zone_builder(zone) for zone in zones], + "pagination": { + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "total_zones": total_zones, + "returned_zones": len(zones), + "has_next": page < total_pages, + "has_previous": page > 1 and total_pages > 0, + }, + } + + +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): zones = _zones_queryset().filter(crop_area=crop_area) return { diff --git a/crop_zoning/tests.py b/crop_zoning/tests.py index 9b4a012..5ce6089 100644 --- a/crop_zoning/tests.py +++ b/crop_zoning/tests.py @@ -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]) diff --git a/crop_zoning/urls.py b/crop_zoning/urls.py index 9234db9..23f485d 100644 --- a/crop_zoning/urls.py +++ b/crop_zoning/urls.py @@ -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( diff --git a/crop_zoning/views.py b/crop_zoning/views.py index a73263b..a13d2c8 100644 --- a/crop_zoning/views.py +++ b/crop_zoning/views.py @@ -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,38 +25,34 @@ from .services import ( ) -class AreaView(APIView): - @extend_schema( - tags=["Crop Zoning"], - parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=True, - description="UUID مزرعه برای گرفتن يا ساخت آخرين پردازش محدوده همان مزرعه.", - ), - OpenApiParameter( - name="page", - type=OpenApiTypes.INT, - location=OpenApiParameter.QUERY, - required=False, - description="شماره صفحه زون ها. مقدار پيش فرض 1 است.", - ), - OpenApiParameter( - name="page_size", - type=OpenApiTypes.INT, - location=OpenApiParameter.QUERY, - 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()), - }, - ) +AREA_QUERY_PARAMETERS = [ + OpenApiParameter( + name="farm_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=True, + description="UUID مزرعه برای گرفتن يا ساخت آخرين پردازش محدوده همان مزرعه.", + ), + OpenApiParameter( + name="page", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + required=False, + description="شماره صفحه زون ها. مقدار پيش فرض 1 است.", + ), + OpenApiParameter( + name="page_size", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + required=False, + description="تعداد زون در هر صفحه. مقدار پيش فرض 10 است.", + ), +] + + +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"],