This commit is contained in:
2026-05-10 02:02:48 +03:30
parent cead7dafe2
commit 2d1f7da89e
30 changed files with 1195 additions and 320 deletions
+2 -1
View File
@@ -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()
+57 -6
View File
@@ -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,
)