UPDATE
This commit is contained in:
@@ -64,6 +64,16 @@ class CropSimulationRequestSerializer(serializers.Serializer):
|
||||
initial="11111111-1111-1111-1111-111111111111",
|
||||
help_text="UUID مزرعه برای اجرای شبیهسازی.",
|
||||
)
|
||||
irrigation_plan_id = serializers.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
help_text="شناسه داخلی برنامه آبیاری برای ارسال context به AI.",
|
||||
)
|
||||
fertilization_plan_id = serializers.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
help_text="شناسه داخلی برنامه کودی برای ارسال context به AI.",
|
||||
)
|
||||
|
||||
|
||||
class GrowthSimulationRequestSerializer(serializers.Serializer):
|
||||
|
||||
@@ -6,6 +6,8 @@ from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
|
||||
|
||||
from external_api_adapter.adapter import AdapterResponse
|
||||
from farm_hub.models import FarmHub, FarmType, Product
|
||||
from fertilization.models import FertilizationPlan
|
||||
from irrigation.models import IrrigationPlan
|
||||
|
||||
from .views import (
|
||||
CurrentFarmChartView,
|
||||
@@ -378,6 +380,52 @@ class CropSimulationViewTests(TestCase):
|
||||
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجهفرنگی"},
|
||||
)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_current_farm_chart_includes_selected_plans(self, mock_external_api_request):
|
||||
irrigation_plan = IrrigationPlan.objects.create(
|
||||
farm=self.farm,
|
||||
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||
title="برنامه آبیاری",
|
||||
plan_payload={"plan": {"durationMinutes": 20}},
|
||||
)
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "series": []}}},
|
||||
)
|
||||
|
||||
response = self.api_client.post(
|
||||
"/api/yield-harvest/current-farm-chart/",
|
||||
{"farm_uuid": str(self.farm.farm_uuid), "irrigation_plan_id": irrigation_plan.id},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
sent_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||
self.assertEqual(sent_payload["irrigation_plan"]["id"], irrigation_plan.id)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_harvest_prediction_includes_selected_plans(self, mock_external_api_request):
|
||||
fertilization_plan = FertilizationPlan.objects.create(
|
||||
farm=self.farm,
|
||||
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||
title="برنامه کودی",
|
||||
plan_payload={"primary_recommendation": {"fertilizer_code": "npk-151515"}},
|
||||
)
|
||||
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/yield-harvest/harvest-prediction/",
|
||||
{"farm_uuid": str(self.farm.farm_uuid), "fertilization_plan_id": fertilization_plan.id},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
sent_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||
self.assertEqual(sent_payload["fertilization_plan"]["id"], fertilization_plan.id)
|
||||
|
||||
@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(
|
||||
@@ -470,6 +518,72 @@ class CropSimulationViewTests(TestCase):
|
||||
payload={"farm_uuid": str(farm_without_products.farm_uuid), "plant_name": "گوجهفرنگی"},
|
||||
)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_yield_prediction_includes_selected_irrigation_and_fertilization_plans(self, mock_external_api_request):
|
||||
irrigation_plan = IrrigationPlan.objects.create(
|
||||
farm=self.farm,
|
||||
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||
title="برنامه آبیاری",
|
||||
crop_id="گوجهفرنگی",
|
||||
growth_stage="flowering",
|
||||
plan_payload={"plan": {"durationMinutes": 30}},
|
||||
request_payload={"source": "manual"},
|
||||
response_payload={"ok": True},
|
||||
)
|
||||
fertilization_plan = FertilizationPlan.objects.create(
|
||||
farm=self.farm,
|
||||
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||
title="برنامه کودی",
|
||||
crop_id="گوجهفرنگی",
|
||||
growth_stage="flowering",
|
||||
plan_payload={"primary_recommendation": {"fertilizer_code": "npk-202020"}},
|
||||
request_payload={"source": "manual"},
|
||||
response_payload={"ok": True},
|
||||
)
|
||||
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/yield-harvest/yield-prediction/",
|
||||
{
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"irrigation_plan_id": irrigation_plan.id,
|
||||
"fertilization_plan_id": fertilization_plan.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
sent_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||
self.assertEqual(sent_payload["irrigation_plan"]["id"], irrigation_plan.id)
|
||||
self.assertEqual(sent_payload["irrigation_plan"]["plan_payload"]["plan"]["durationMinutes"], 30)
|
||||
self.assertEqual(sent_payload["fertilization_plan"]["id"], fertilization_plan.id)
|
||||
self.assertEqual(
|
||||
sent_payload["fertilization_plan"]["plan_payload"]["primary_recommendation"]["fertilizer_code"],
|
||||
"npk-202020",
|
||||
)
|
||||
|
||||
def test_yield_prediction_rejects_foreign_plan_ids(self):
|
||||
other_irrigation_plan = IrrigationPlan.objects.create(
|
||||
farm=self.other_farm,
|
||||
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||
title="other irrigation",
|
||||
)
|
||||
|
||||
response = self.api_client.post(
|
||||
"/api/yield-harvest/yield-prediction/",
|
||||
{
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"irrigation_plan_id": other_irrigation_plan.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["data"]["irrigation_plan_id"][0], "Irrigation plan not found.")
|
||||
|
||||
@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(
|
||||
@@ -520,6 +634,34 @@ class CropSimulationViewTests(TestCase):
|
||||
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_yield_harvest_summary_includes_selected_plans_in_query(self, mock_external_api_request):
|
||||
irrigation_plan = IrrigationPlan.objects.create(
|
||||
farm=self.farm,
|
||||
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||
title="برنامه آبیاری",
|
||||
plan_payload={"plan": {"durationMinutes": 18}},
|
||||
)
|
||||
fertilization_plan = FertilizationPlan.objects.create(
|
||||
farm=self.farm,
|
||||
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||
title="برنامه کودی",
|
||||
plan_payload={"primary_recommendation": {"fertilizer_code": "npk-111111"}},
|
||||
)
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "yield_prediction_chart": {"series": []}}}},
|
||||
)
|
||||
|
||||
response = self.api_client.get(
|
||||
f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}&irrigation_plan_id={irrigation_plan.id}&fertilization_plan_id={fertilization_plan.id}"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
sent_query = mock_external_api_request.call_args.kwargs["query"]
|
||||
self.assertEqual(sent_query["irrigation_plan"]["id"], irrigation_plan.id)
|
||||
self.assertEqual(sent_query["fertilization_plan"]["id"], fertilization_plan.id)
|
||||
|
||||
def test_crop_simulation_rejects_foreign_farm_uuid(self):
|
||||
request = self.factory.post(
|
||||
"/api/yield-harvest/crop-simulation/yield-prediction/",
|
||||
|
||||
+138
-12
@@ -9,6 +9,8 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
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 fertilization.models import FertilizationPlan
|
||||
from irrigation.models import IrrigationPlan
|
||||
from .models import YieldHarvestPredictionLog
|
||||
from .serializers import (
|
||||
CropSimulationRequestSerializer,
|
||||
@@ -77,6 +79,20 @@ class YieldHarvestSummaryView(APIView):
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
irrigation_plan_id, irrigation_plan_error = CropSimulationBaseView._parse_optional_plan_id(
|
||||
request.query_params.get("irrigation_plan_id"),
|
||||
"irrigation_plan_id",
|
||||
)
|
||||
if irrigation_plan_error is not None:
|
||||
return irrigation_plan_error
|
||||
|
||||
fertilization_plan_id, fertilization_plan_error = CropSimulationBaseView._parse_optional_plan_id(
|
||||
request.query_params.get("fertilization_plan_id"),
|
||||
"fertilization_plan_id",
|
||||
)
|
||||
if fertilization_plan_error is not None:
|
||||
return fertilization_plan_error
|
||||
|
||||
query = {"farm_uuid": str(farm.farm_uuid)}
|
||||
if request.query_params.get("season_year"):
|
||||
query["season_year"] = request.query_params.get("season_year")
|
||||
@@ -85,6 +101,15 @@ class YieldHarvestSummaryView(APIView):
|
||||
if request.query_params.get("include_narrative") is not None:
|
||||
query["include_narrative"] = request.query_params.get("include_narrative")
|
||||
|
||||
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
|
||||
farm,
|
||||
irrigation_plan_id=irrigation_plan_id,
|
||||
fertilization_plan_id=fertilization_plan_id,
|
||||
)
|
||||
if plan_error is not None:
|
||||
return plan_error
|
||||
query.update(ai_payload)
|
||||
|
||||
adapter_response = external_api_request(
|
||||
"ai",
|
||||
"/api/crop-simulation/yield-harvest-summary/",
|
||||
@@ -191,6 +216,98 @@ class CropSimulationBaseView(APIView):
|
||||
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _get_irrigation_plan_or_error(farm, plan_id):
|
||||
if not plan_id:
|
||||
return None, None
|
||||
|
||||
plan = IrrigationPlan.objects.filter(
|
||||
id=plan_id,
|
||||
farm=farm,
|
||||
is_deleted=False,
|
||||
).first()
|
||||
if plan is None:
|
||||
return None, Response(
|
||||
{"code": 404, "msg": "error", "data": {"irrigation_plan_id": ["Irrigation plan not found."]}},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return plan, None
|
||||
|
||||
@staticmethod
|
||||
def _get_fertilization_plan_or_error(farm, plan_id):
|
||||
if not plan_id:
|
||||
return None, None
|
||||
|
||||
plan = FertilizationPlan.objects.filter(
|
||||
id=plan_id,
|
||||
farm=farm,
|
||||
is_deleted=False,
|
||||
).first()
|
||||
if plan is None:
|
||||
return None, Response(
|
||||
{"code": 404, "msg": "error", "data": {"fertilization_plan_id": ["Fertilization plan not found."]}},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return plan, None
|
||||
|
||||
@staticmethod
|
||||
def _build_plan_payload(plan):
|
||||
if plan is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": plan.id,
|
||||
"uuid": str(plan.uuid),
|
||||
"source": plan.source,
|
||||
"title": plan.title,
|
||||
"crop_id": plan.crop_id,
|
||||
"growth_stage": plan.growth_stage,
|
||||
"is_active": plan.is_active,
|
||||
"plan_payload": plan.plan_payload if isinstance(plan.plan_payload, dict) else {},
|
||||
"request_payload": plan.request_payload if isinstance(plan.request_payload, dict) else {},
|
||||
"response_payload": plan.response_payload if isinstance(plan.response_payload, dict) else {},
|
||||
}
|
||||
|
||||
def _build_ai_payload_with_selected_plans(self, farm, irrigation_plan_id=None, fertilization_plan_id=None):
|
||||
irrigation_plan, irrigation_error = self._get_irrigation_plan_or_error(farm, irrigation_plan_id)
|
||||
if irrigation_error is not None:
|
||||
return None, irrigation_error
|
||||
|
||||
fertilization_plan, fertilization_error = self._get_fertilization_plan_or_error(
|
||||
farm, fertilization_plan_id
|
||||
)
|
||||
if fertilization_error is not None:
|
||||
return None, fertilization_error
|
||||
|
||||
ai_payload = {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"plant_name": self._get_first_farm_product_name(farm),
|
||||
}
|
||||
if irrigation_plan is not None:
|
||||
ai_payload["irrigation_plan"] = self._build_plan_payload(irrigation_plan)
|
||||
if fertilization_plan is not None:
|
||||
ai_payload["fertilization_plan"] = self._build_plan_payload(fertilization_plan)
|
||||
|
||||
return ai_payload, None
|
||||
|
||||
@staticmethod
|
||||
def _parse_optional_plan_id(raw_value, field_name):
|
||||
if raw_value in (None, ""):
|
||||
return None, None
|
||||
try:
|
||||
parsed_value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return None, Response(
|
||||
{"code": 400, "msg": "error", "data": {field_name: ["A valid integer is required."]}},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if parsed_value < 1:
|
||||
return None, Response(
|
||||
{"code": 400, "msg": "error", "data": {field_name: ["Ensure this value is greater than or equal to 1."]}},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return parsed_value, None
|
||||
|
||||
|
||||
class CurrentFarmChartView(CropSimulationBaseView):
|
||||
ai_path = "/api/crop-simulation/current-farm-chart/"
|
||||
@@ -209,10 +326,13 @@ class CurrentFarmChartView(CropSimulationBaseView):
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
ai_payload = {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"plant_name": self._get_first_farm_product_name(farm),
|
||||
}
|
||||
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
|
||||
farm,
|
||||
irrigation_plan_id=payload.get("irrigation_plan_id"),
|
||||
fertilization_plan_id=payload.get("fertilization_plan_id"),
|
||||
)
|
||||
if plan_error is not None:
|
||||
return plan_error
|
||||
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
|
||||
|
||||
if adapter_response.status_code >= 400:
|
||||
@@ -241,10 +361,13 @@ class HarvestPredictionView(CropSimulationBaseView):
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
ai_payload = {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"plant_name": self._get_first_farm_product_name(farm),
|
||||
}
|
||||
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
|
||||
farm,
|
||||
irrigation_plan_id=payload.get("irrigation_plan_id"),
|
||||
fertilization_plan_id=payload.get("fertilization_plan_id"),
|
||||
)
|
||||
if plan_error is not None:
|
||||
return plan_error
|
||||
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
|
||||
|
||||
if adapter_response.status_code >= 400:
|
||||
@@ -273,10 +396,13 @@ class YieldPredictionView(CropSimulationBaseView):
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
ai_payload = {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"plant_name": self._get_first_farm_product_name(farm),
|
||||
}
|
||||
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
|
||||
farm,
|
||||
irrigation_plan_id=payload.get("irrigation_plan_id"),
|
||||
fertilization_plan_id=payload.get("fertilization_plan_id"),
|
||||
)
|
||||
if plan_error is not None:
|
||||
return plan_error
|
||||
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
|
||||
|
||||
if adapter_response.status_code >= 400:
|
||||
|
||||
Reference in New Issue
Block a user