UPDATE
This commit is contained in:
@@ -4,4 +4,4 @@ from django.apps import AppConfig
|
||||
class YieldHarvestConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "yield_harvest"
|
||||
verbose_name = "Yield & Harvest Prediction"
|
||||
verbose_name = "Yield, Harvest & Crop Simulation"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
CurrentFarmChartView,
|
||||
GrowthSimulationStatusView,
|
||||
GrowthSimulationView,
|
||||
HarvestPredictionView,
|
||||
YieldHarvestSummaryView,
|
||||
YieldPredictionView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("current-farm-chart/", CurrentFarmChartView.as_view(), name="crop-simulation-current-farm-chart"),
|
||||
path("growth/", GrowthSimulationView.as_view(), name="crop-simulation-growth"),
|
||||
path("growth/<str:task_id>/status/", GrowthSimulationStatusView.as_view(), name="crop-simulation-growth-status"),
|
||||
path("harvest-prediction/", HarvestPredictionView.as_view(), name="crop-simulation-harvest-prediction"),
|
||||
path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="crop-simulation-yield-harvest-summary"),
|
||||
path("yield-prediction/", YieldPredictionView.as_view(), name="crop-simulation-yield-prediction"),
|
||||
]
|
||||
@@ -48,9 +48,14 @@ class HarvestPredictionCardSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class YieldHarvestSummarySerializer(serializers.Serializer):
|
||||
yield_prediction_card = YieldPredictionCardSerializer(required=False)
|
||||
yield_prediction_chart = YieldPredictionChartSerializer(required=False)
|
||||
harvest_prediction_card = HarvestPredictionCardSerializer(required=False)
|
||||
farm_uuid = serializers.CharField(required=False, allow_blank=True)
|
||||
season_highlights_card = serializers.DictField(required=False)
|
||||
yield_prediction = serializers.DictField(required=False)
|
||||
harvest_prediction_card = serializers.DictField(required=False)
|
||||
harvest_readiness_zones = serializers.DictField(required=False)
|
||||
yield_quality_bands = serializers.DictField(required=False)
|
||||
harvest_operations_card = serializers.DictField(required=False)
|
||||
yield_prediction_chart = serializers.DictField(required=False)
|
||||
|
||||
|
||||
class CropSimulationRequestSerializer(serializers.Serializer):
|
||||
@@ -133,11 +138,11 @@ class CurrentFarmChartSerializer(serializers.Serializer):
|
||||
scenario_id = serializers.IntegerField(required=False)
|
||||
simulation_warning = serializers.CharField(required=False, allow_blank=True)
|
||||
categories = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
series = serializers.DictField(required=False)
|
||||
summary = serializers.DictField(required=False)
|
||||
series = serializers.ListField(child=serializers.DictField(), required=False)
|
||||
summary = serializers.ListField(child=serializers.DictField(), required=False)
|
||||
current_state = serializers.DictField(required=False)
|
||||
metrics = serializers.DictField(required=False)
|
||||
daily_output = serializers.DictField(required=False)
|
||||
daily_output = serializers.ListField(child=serializers.DictField(), required=False)
|
||||
|
||||
|
||||
class HarvestPredictionSerializer(serializers.Serializer):
|
||||
|
||||
+101
-12
@@ -1,9 +1,8 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
|
||||
|
||||
from external_api_adapter.adapter import AdapterResponse
|
||||
from farm_hub.models import FarmHub, FarmType
|
||||
@@ -13,12 +12,14 @@ from .views import (
|
||||
GrowthSimulationStatusView,
|
||||
GrowthSimulationView,
|
||||
HarvestPredictionView,
|
||||
YieldHarvestSummaryView,
|
||||
YieldPredictionView,
|
||||
)
|
||||
|
||||
|
||||
class CropSimulationViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.api_client = APIClient()
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="farmer",
|
||||
@@ -35,6 +36,7 @@ class CropSimulationViewTests(TestCase):
|
||||
self.farm_type = FarmType.objects.create(name="زراعی")
|
||||
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1")
|
||||
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2")
|
||||
self.api_client.force_authenticate(user=self.user)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_growth_queues_simulation_task(self, mock_external_api_request):
|
||||
@@ -54,6 +56,7 @@ class CropSimulationViewTests(TestCase):
|
||||
{"plant_name": "گوجهفرنگی", "dynamic_parameters": ["DVS", "LAI"], "farm_uuid": str(self.farm.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = GrowthSimulationView.as_view()(request)
|
||||
|
||||
@@ -84,16 +87,14 @@ class CropSimulationViewTests(TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
response = self.api_client.post(
|
||||
"/api/crop-simulation/growth/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"plant_name": "گوجهفرنگی",
|
||||
"dynamic_parameters": ["DVS", "LAI"],
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
{
|
||||
"plant_name": "گوجهفرنگی",
|
||||
"dynamic_parameters": ["DVS", "LAI"],
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
@@ -105,6 +106,7 @@ class CropSimulationViewTests(TestCase):
|
||||
{"plant_name": "گوجهفرنگی", "dynamic_parameters": ["DVS"]},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = GrowthSimulationView.as_view()(request)
|
||||
|
||||
@@ -131,6 +133,7 @@ class CropSimulationViewTests(TestCase):
|
||||
)
|
||||
|
||||
request = self.factory.get("/api/yield-harvest/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10")
|
||||
force_authenticate(request, user=self.user)
|
||||
response = GrowthSimulationStatusView.as_view()(request, task_id="growth-task-123")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -163,7 +166,7 @@ class CropSimulationViewTests(TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.get("/api/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10")
|
||||
response = self.api_client.get("/api/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["status"], "SUCCESS")
|
||||
@@ -218,6 +221,22 @@ class CropSimulationViewTests(TestCase):
|
||||
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"},
|
||||
)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_current_farm_chart_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}}},
|
||||
)
|
||||
|
||||
response = self.api_client.post(
|
||||
"/api/crop-simulation/current-farm-chart/",
|
||||
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_harvest_prediction_proxies_to_ai_service(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
@@ -252,6 +271,22 @@ class CropSimulationViewTests(TestCase):
|
||||
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": ""},
|
||||
)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_harvest_prediction_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={"data": {"result": {"date": "2026-07-15", "daysUntil": 96}}},
|
||||
)
|
||||
|
||||
response = self.api_client.post(
|
||||
"/api/crop-simulation/harvest-prediction/",
|
||||
{"farm_uuid": str(self.farm.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["daysUntil"], 96)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
@@ -279,6 +314,60 @@ class CropSimulationViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["data"]["predictedYieldTons"], 8.4)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_yield_prediction_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "predictedYieldTons": 8.4}}},
|
||||
)
|
||||
|
||||
response = self.api_client.post(
|
||||
"/api/crop-simulation/yield-prediction/",
|
||||
{"farm_uuid": str(self.farm.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["predictedYieldTons"], 8.4)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_yield_harvest_summary_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={
|
||||
"data": {
|
||||
"result": {
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"season_highlights_card": {"title": "Season highlights"},
|
||||
"yield_prediction": {"predicted_yield_tons": 5.1},
|
||||
"harvest_prediction_card": {"harvest_date": "2026-09-28", "days_until": 152},
|
||||
"harvest_readiness_zones": {"zones": []},
|
||||
"yield_quality_bands": {"primary_quality_grade": "B"},
|
||||
"harvest_operations_card": {"steps": []},
|
||||
"yield_prediction_chart": {"series": []},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
response = self.api_client.get(
|
||||
f"/api/crop-simulation/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}&season_year=1404&crop_name=wheat&include_narrative=true"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
||||
mock_external_api_request.assert_called_once_with(
|
||||
"ai",
|
||||
"/api/crop-simulation/yield-harvest-summary/",
|
||||
method="GET",
|
||||
query={
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"season_year": "1404",
|
||||
"crop_name": "wheat",
|
||||
"include_narrative": "true",
|
||||
},
|
||||
)
|
||||
|
||||
def test_crop_simulation_rejects_foreign_farm_uuid(self):
|
||||
request = self.factory.post(
|
||||
"/api/yield-harvest/crop-simulation/yield-prediction/",
|
||||
|
||||
+14
-1
@@ -1,7 +1,20 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import YieldHarvestSummaryView
|
||||
from .views import (
|
||||
CurrentFarmChartView,
|
||||
GrowthSimulationStatusView,
|
||||
GrowthSimulationView,
|
||||
HarvestPredictionView,
|
||||
YieldHarvestSummaryView,
|
||||
YieldPredictionView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
|
||||
path("crop-simulation/current-farm-chart/", CurrentFarmChartView.as_view(), name="yield-harvest-current-farm-chart"),
|
||||
path("crop-simulation/growth/", GrowthSimulationView.as_view(), name="yield-harvest-growth"),
|
||||
path("crop-simulation/growth/<str:task_id>/status/", GrowthSimulationStatusView.as_view(), name="yield-harvest-growth-status"),
|
||||
path("crop-simulation/harvest-prediction/", HarvestPredictionView.as_view(), name="yield-harvest-harvest-prediction"),
|
||||
path("crop-simulation/yield-prediction/", YieldPredictionView.as_view(), name="yield-harvest-yield-prediction"),
|
||||
path("crop-simulation/yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-crop-simulation-summary"),
|
||||
]
|
||||
|
||||
+61
-22
@@ -6,7 +6,7 @@ from rest_framework.views import APIView
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
|
||||
from config.swagger import farm_uuid_query_param, status_response
|
||||
from config.swagger import code_response, farm_uuid_query_param
|
||||
from external_api_adapter import request as external_api_request
|
||||
from farm_hub.models import FarmHub
|
||||
from .models import YieldHarvestPredictionLog
|
||||
@@ -46,28 +46,60 @@ class YieldHarvestSummaryView(APIView):
|
||||
@extend_schema(
|
||||
tags=["Yield & Harvest Prediction"],
|
||||
parameters=[
|
||||
farm_uuid_query_param(required=False, description="UUID of the farm for yield and harvest prediction."),
|
||||
farm_uuid_query_param(required=True, description="UUID of the farm for yield and harvest prediction."),
|
||||
OpenApiParameter(
|
||||
name="season_year",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="سال زراعی.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="crop_name",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="نام محصول.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="include_narrative",
|
||||
type=OpenApiTypes.BOOL,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="در صورت true بودن متن های narrative نیز اضافه می شوند.",
|
||||
),
|
||||
],
|
||||
responses={200: status_response("YieldHarvestSummaryResponse", data=YieldHarvestSummarySerializer())},
|
||||
responses={200: code_response("YieldHarvestSummaryResponse", data=YieldHarvestSummarySerializer())},
|
||||
)
|
||||
def get(self, request):
|
||||
farm_uuid = request.query_params.get("farm_uuid")
|
||||
query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {}
|
||||
farm, error_response = CropSimulationBaseView._get_farm(request, farm_uuid)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
query = {"farm_uuid": str(farm.farm_uuid)}
|
||||
if request.query_params.get("season_year"):
|
||||
query["season_year"] = request.query_params.get("season_year")
|
||||
if request.query_params.get("crop_name"):
|
||||
query["crop_name"] = request.query_params.get("crop_name")
|
||||
if request.query_params.get("include_narrative") is not None:
|
||||
query["include_narrative"] = request.query_params.get("include_narrative")
|
||||
|
||||
adapter_response = external_api_request(
|
||||
"ai",
|
||||
"/yield-harvest/summary",
|
||||
"/api/crop-simulation/yield-harvest-summary/",
|
||||
method="GET",
|
||||
query=query,
|
||||
)
|
||||
if adapter_response.status_code >= 400:
|
||||
return CropSimulationBaseView._error_response(adapter_response)
|
||||
|
||||
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
||||
summary = response_data.get("result", response_data.get("data", response_data))
|
||||
summary = CropSimulationBaseView._extract_result(adapter_response.data)
|
||||
|
||||
self._persist_log(farm_uuid, summary)
|
||||
self._persist_log(farm.farm_uuid, summary)
|
||||
|
||||
return Response(
|
||||
{"status": "success", "data": summary},
|
||||
{"code": 200, "msg": "success", "data": summary},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -80,18 +112,25 @@ class YieldHarvestSummaryView(APIView):
|
||||
except (FarmHub.DoesNotExist, Exception):
|
||||
pass
|
||||
|
||||
yield_card = summary.get("yield_prediction_card", {})
|
||||
yield_card = summary.get("yield_prediction") or summary.get("yield_prediction_card") or {}
|
||||
harvest_card = summary.get("harvest_prediction_card", {})
|
||||
yield_chart = summary.get("yield_prediction_chart", {})
|
||||
if not isinstance(yield_card, dict):
|
||||
yield_card = {}
|
||||
if not isinstance(harvest_card, dict):
|
||||
harvest_card = {}
|
||||
if not isinstance(yield_chart, dict):
|
||||
yield_chart = {}
|
||||
|
||||
YieldHarvestPredictionLog.objects.create(
|
||||
farm=farm,
|
||||
yield_stats=yield_card.get("stats", ""),
|
||||
yield_chip_text=yield_card.get("chipText", ""),
|
||||
harvest_date=harvest_card.get("date") or None,
|
||||
days_until_harvest=harvest_card.get("daysUntil"),
|
||||
optimal_window_start=harvest_card.get("optimalWindowStart") or None,
|
||||
optimal_window_end=harvest_card.get("optimalWindowEnd") or None,
|
||||
chart_data=summary.get("yield_prediction_chart", {}),
|
||||
yield_stats=str(yield_card.get("predicted_yield_tons") or yield_card.get("stats") or ""),
|
||||
yield_chip_text=str(yield_card.get("unit") or yield_card.get("chipText") or ""),
|
||||
harvest_date=harvest_card.get("harvest_date") or harvest_card.get("date") or None,
|
||||
days_until_harvest=harvest_card.get("days_until") or harvest_card.get("daysUntil"),
|
||||
optimal_window_start=harvest_card.get("optimal_window_start") or harvest_card.get("optimalWindowStart") or None,
|
||||
optimal_window_end=harvest_card.get("optimal_window_end") or harvest_card.get("optimalWindowEnd") or None,
|
||||
chart_data=yield_chart,
|
||||
)
|
||||
|
||||
|
||||
@@ -147,7 +186,7 @@ class CurrentFarmChartView(CropSimulationBaseView):
|
||||
@extend_schema(
|
||||
tags=["Crop Simulation"],
|
||||
request=CropSimulationRequestSerializer,
|
||||
responses={200: status_response("CurrentFarmChartResponse", data=CurrentFarmChartSerializer())},
|
||||
responses={200: code_response("CurrentFarmChartResponse", data=CurrentFarmChartSerializer())},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = CropSimulationRequestSerializer(data=request.data)
|
||||
@@ -176,7 +215,7 @@ class HarvestPredictionView(CropSimulationBaseView):
|
||||
@extend_schema(
|
||||
tags=["Crop Simulation"],
|
||||
request=CropSimulationRequestSerializer,
|
||||
responses={200: status_response("CropSimulationHarvestPredictionResponse", data=HarvestPredictionSerializer())},
|
||||
responses={200: code_response("CropSimulationHarvestPredictionResponse", data=HarvestPredictionSerializer())},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = CropSimulationRequestSerializer(data=request.data)
|
||||
@@ -205,7 +244,7 @@ class YieldPredictionView(CropSimulationBaseView):
|
||||
@extend_schema(
|
||||
tags=["Crop Simulation"],
|
||||
request=CropSimulationRequestSerializer,
|
||||
responses={200: status_response("CropSimulationYieldPredictionResponse", data=YieldPredictionSerializer())},
|
||||
responses={200: code_response("CropSimulationYieldPredictionResponse", data=YieldPredictionSerializer())},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = CropSimulationRequestSerializer(data=request.data)
|
||||
@@ -232,7 +271,7 @@ class GrowthSimulationView(APIView):
|
||||
@extend_schema(
|
||||
tags=["Crop Simulation"],
|
||||
request=GrowthSimulationRequestSerializer,
|
||||
responses={202: status_response("GrowthSimulationQueuedResponse", data=GrowthSimulationQueuedDataSerializer())},
|
||||
responses={202: code_response("GrowthSimulationQueuedResponse", data=GrowthSimulationQueuedDataSerializer())},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = GrowthSimulationRequestSerializer(data=request.data)
|
||||
@@ -279,7 +318,7 @@ class GrowthSimulationStatusView(APIView):
|
||||
description="اندازه صفحه بین 1 تا 50.",
|
||||
),
|
||||
],
|
||||
responses={200: status_response("GrowthSimulationStatusResponse", data=GrowthSimulationStatusDataSerializer())},
|
||||
responses={200: code_response("GrowthSimulationStatusResponse", data=GrowthSimulationStatusDataSerializer())},
|
||||
)
|
||||
def get(self, request, task_id):
|
||||
query = {}
|
||||
|
||||
Reference in New Issue
Block a user