UPDATE
This commit is contained in:
@@ -7,6 +7,7 @@ from math import exp
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.paginator import EmptyPage, Paginator
|
||||
|
||||
from farm_data.models import SensorData
|
||||
@@ -275,7 +276,7 @@ def _resolve_plant_simulation_defaults(plant: Any) -> tuple[dict[str, Any] | Non
|
||||
|
||||
|
||||
def build_growth_context(payload: dict[str, Any]) -> GrowthSimulationContext:
|
||||
plant_name = payload["plant_name"]
|
||||
plant_name = apps.get_app_config("plant").resolve_plant_name(payload["plant_name"]) or payload["plant_name"]
|
||||
from plant.models import Plant
|
||||
|
||||
plant = Plant.objects.filter(name=plant_name).first()
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
@@ -18,8 +20,38 @@ class QueryJSONField(serializers.JSONField):
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
class GrowthSimulationRequestSerializer(serializers.Serializer):
|
||||
plant_name = serializers.CharField(help_text="نام گیاه")
|
||||
class PlantNameAliasMixin:
|
||||
plant_name_field = "plant_name"
|
||||
plant_alias_fields = ("crop", "crop_name")
|
||||
|
||||
def _get_raw_plant_name(self, attrs):
|
||||
value = attrs.get(self.plant_name_field)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
|
||||
for alias in self.plant_alias_fields:
|
||||
alias_value = self.initial_data.get(alias) if hasattr(self, "initial_data") else None
|
||||
if alias_value not in (None, ""):
|
||||
return alias_value
|
||||
return value
|
||||
|
||||
def _resolve_plant_name(self, attrs, *, required: bool) -> dict:
|
||||
raw_value = self._get_raw_plant_name(attrs)
|
||||
if raw_value in (None, ""):
|
||||
if required:
|
||||
raise serializers.ValidationError(
|
||||
{self.plant_name_field: "یکی از plant_name، crop یا crop_name باید ارسال شود."}
|
||||
)
|
||||
attrs[self.plant_name_field] = ""
|
||||
return attrs
|
||||
|
||||
resolved_value = apps.get_app_config("plant").resolve_plant_name(str(raw_value))
|
||||
attrs[self.plant_name_field] = resolved_value or str(raw_value).strip()
|
||||
return attrs
|
||||
|
||||
|
||||
class GrowthSimulationRequestSerializer(PlantNameAliasMixin, serializers.Serializer):
|
||||
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
|
||||
dynamic_parameters = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
allow_empty=False,
|
||||
@@ -36,6 +68,7 @@ class GrowthSimulationRequestSerializer(serializers.Serializer):
|
||||
page_size = serializers.IntegerField(required=False, min_value=1, max_value=50)
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = self._resolve_plant_name(attrs, required=True)
|
||||
if not attrs.get("farm_uuid") and not attrs.get("weather"):
|
||||
raise serializers.ValidationError(
|
||||
"یکی از farm_uuid یا weather باید ارسال شود."
|
||||
@@ -44,7 +77,7 @@ class GrowthSimulationRequestSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class GrowthSimulationQueuedSerializer(serializers.Serializer):
|
||||
task_id = serializers.CharField()
|
||||
task_id = serializers.UUIDField()
|
||||
status_url = serializers.CharField()
|
||||
plant_name = serializers.CharField()
|
||||
|
||||
@@ -92,12 +125,15 @@ class GrowthSimulationResultSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
|
||||
class CurrentFarmChartRequestSerializer(serializers.Serializer):
|
||||
class CurrentFarmChartRequestSerializer(PlantNameAliasMixin, serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
|
||||
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
|
||||
irrigation_recommendation = serializers.JSONField(required=False)
|
||||
fertilization_recommendation = serializers.JSONField(required=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
return self._resolve_plant_name(attrs, required=False)
|
||||
|
||||
|
||||
class CurrentFarmChartResponseSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.CharField(allow_null=True)
|
||||
@@ -114,12 +150,15 @@ class CurrentFarmChartResponseSerializer(serializers.Serializer):
|
||||
daily_output = serializers.JSONField()
|
||||
|
||||
|
||||
class HarvestPredictionRequestSerializer(serializers.Serializer):
|
||||
class HarvestPredictionRequestSerializer(PlantNameAliasMixin, serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
|
||||
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
|
||||
irrigation_recommendation = serializers.JSONField(required=False)
|
||||
fertilization_recommendation = serializers.JSONField(required=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
return self._resolve_plant_name(attrs, required=False)
|
||||
|
||||
|
||||
class HarvestPredictionResponseSerializer(serializers.Serializer):
|
||||
date = serializers.CharField()
|
||||
@@ -131,12 +170,15 @@ class HarvestPredictionResponseSerializer(serializers.Serializer):
|
||||
gddDetails = serializers.JSONField()
|
||||
|
||||
|
||||
class YieldPredictionRequestSerializer(serializers.Serializer):
|
||||
class YieldPredictionRequestSerializer(PlantNameAliasMixin, serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
|
||||
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
|
||||
irrigation_recommendation = serializers.JSONField(required=False)
|
||||
fertilization_recommendation = serializers.JSONField(required=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
return self._resolve_plant_name(attrs, required=False)
|
||||
|
||||
|
||||
class YieldPredictionResponseSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.CharField()
|
||||
@@ -172,6 +214,15 @@ class YieldHarvestSummaryQuerySerializer(serializers.Serializer):
|
||||
help_text="برنامه کودهی به صورت JSON برای تزریق به PCSE.",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
raw_crop_name = attrs.get("crop_name")
|
||||
if raw_crop_name in (None, "") and hasattr(self, "initial_data"):
|
||||
raw_crop_name = self.initial_data.get("plant_name") or self.initial_data.get("crop")
|
||||
if raw_crop_name not in (None, ""):
|
||||
resolved_crop_name = apps.get_app_config("plant").resolve_plant_name(str(raw_crop_name))
|
||||
attrs["crop_name"] = resolved_crop_name or str(raw_crop_name).strip()
|
||||
return attrs
|
||||
|
||||
|
||||
class YieldHarvestSummaryResponseSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.CharField()
|
||||
|
||||
@@ -111,6 +111,24 @@ class PlantGrowthSimulationApiTests(TestCase):
|
||||
self.assertEqual(response.json()["data"]["task_id"], "growth-task-1")
|
||||
self.assertEqual(mock_delay.call_args.args[0]["irrigation_recommendation"]["events"][0]["amount"], 2.5)
|
||||
|
||||
@patch("crop_simulation.views.run_growth_simulation_task.delay")
|
||||
def test_queue_api_accepts_crop_alias(self, mock_delay):
|
||||
mock_delay.return_value = SimpleNamespace(id="growth-task-2")
|
||||
|
||||
response = self.client.post(
|
||||
"/growth/",
|
||||
data={
|
||||
"crop": "tomato",
|
||||
"dynamic_parameters": ["DVS", "LAI"],
|
||||
"weather": self.weather,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(mock_delay.call_args.args[0]["plant_name"], self.plant.name)
|
||||
self.assertEqual(response.json()["data"]["plant_name"], self.plant.name)
|
||||
|
||||
def test_queue_api_returns_400_for_missing_weather_and_farm_uuid(self):
|
||||
response = self.client.post(
|
||||
"/growth/",
|
||||
@@ -275,6 +293,44 @@ class PlantGrowthSimulationApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["code"], 400)
|
||||
|
||||
@patch("crop_simulation.views.apps.get_app_config")
|
||||
def test_current_farm_chart_api_accepts_crop_name_alias(self, mock_get_app_config):
|
||||
captured = {}
|
||||
|
||||
def simulate(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"farm_uuid": kwargs["farm_uuid"],
|
||||
"plant_name": kwargs["plant_name"],
|
||||
"engine": "growth_projection",
|
||||
"model_name": "growth_projection_v1",
|
||||
"scenario_id": 12,
|
||||
"simulation_warning": None,
|
||||
"categories": [],
|
||||
"series": [],
|
||||
"summary": [],
|
||||
"current_state": {},
|
||||
"metrics": {},
|
||||
"daily_output": [],
|
||||
}
|
||||
|
||||
mock_get_app_config.return_value = SimpleNamespace(
|
||||
get_current_farm_chart_simulator=lambda: SimpleNamespace(simulate=simulate)
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/current-farm-chart/",
|
||||
data={
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"crop_name": "tomato",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(captured["plant_name"], self.plant.name)
|
||||
self.assertEqual(response.json()["data"]["plant_name"], self.plant.name)
|
||||
|
||||
@patch("crop_simulation.views.apps.get_app_config")
|
||||
def test_current_farm_chart_api_returns_500_when_simulator_fails(self, mock_get_app_config):
|
||||
mock_simulator = SimpleNamespace(
|
||||
@@ -342,6 +398,40 @@ class PlantGrowthSimulationApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["code"], 400)
|
||||
|
||||
@patch("crop_simulation.views.apps.get_app_config")
|
||||
def test_harvest_prediction_api_accepts_crop_alias(self, mock_get_app_config):
|
||||
captured = {}
|
||||
|
||||
def get_harvest_prediction(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"date": "2026-05-14",
|
||||
"dateFormatted": "14 May 2026",
|
||||
"daysUntil": 43,
|
||||
"description": "ok",
|
||||
"optimalWindowStart": "2026-05-11",
|
||||
"optimalWindowEnd": "2026-05-17",
|
||||
"gddDetails": {},
|
||||
}
|
||||
|
||||
mock_get_app_config.return_value = SimpleNamespace(
|
||||
get_harvest_prediction_service=lambda: SimpleNamespace(
|
||||
get_harvest_prediction=get_harvest_prediction
|
||||
)
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/harvest-prediction/",
|
||||
data={
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"crop": "tomato",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(captured["plant_name"], self.plant.name)
|
||||
|
||||
@patch("crop_simulation.views.apps.get_app_config")
|
||||
def test_harvest_prediction_api_returns_500_when_service_fails(self, mock_get_app_config):
|
||||
class BrokenService:
|
||||
@@ -409,6 +499,44 @@ class PlantGrowthSimulationApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["code"], 400)
|
||||
|
||||
@patch("crop_simulation.views.apps.get_app_config")
|
||||
def test_yield_prediction_api_accepts_crop_alias(self, mock_get_app_config):
|
||||
captured = {}
|
||||
|
||||
def get_yield_prediction(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"farm_uuid": kwargs["farm_uuid"],
|
||||
"plant_name": kwargs["plant_name"],
|
||||
"predictedYieldTons": 5.4,
|
||||
"predictedYieldRaw": 5400.0,
|
||||
"unit": "تن",
|
||||
"sourceUnit": "kg/ha",
|
||||
"simulationEngine": "growth_projection",
|
||||
"simulationModel": "growth_projection_v1",
|
||||
"scenarioId": 12,
|
||||
"simulationWarning": None,
|
||||
"supportingMetrics": {},
|
||||
}
|
||||
|
||||
mock_get_app_config.return_value = SimpleNamespace(
|
||||
get_yield_prediction_service=lambda: SimpleNamespace(
|
||||
get_yield_prediction=get_yield_prediction
|
||||
)
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/yield-prediction/",
|
||||
data={
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"crop": "tomato",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(captured["plant_name"], self.plant.name)
|
||||
|
||||
@patch("crop_simulation.views.apps.get_app_config")
|
||||
def test_yield_prediction_api_returns_500_when_service_fails(self, mock_get_app_config):
|
||||
class BrokenService:
|
||||
@@ -493,3 +621,26 @@ class PlantGrowthSimulationApiTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["code"], 400)
|
||||
|
||||
@patch("crop_simulation.views.YieldHarvestSummaryService")
|
||||
def test_yield_harvest_summary_api_accepts_plant_name_alias(self, mock_service_cls):
|
||||
mock_service_cls.return_value.get_summary.return_value = {
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"season_highlights_card": {},
|
||||
"yield_prediction": {},
|
||||
"harvest_prediction_card": {},
|
||||
"harvest_readiness_zones": {},
|
||||
"yield_quality_bands": {},
|
||||
"harvest_operations_card": {},
|
||||
"yield_prediction_chart": {},
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
"/yield-harvest-summary/?farm_uuid=550e8400-e29b-41d4-a716-446655440000&plant_name=tomato"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
mock_service_cls.return_value.get_summary.call_args.kwargs["crop_name"],
|
||||
self.plant.name,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user