This commit is contained in:
2026-04-30 01:01:04 +03:30
parent 8139a49756
commit 5d8ad57b2d
21 changed files with 1841 additions and 76 deletions
+1 -1
View File
@@ -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"
+19
View File
@@ -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"),
]
+11 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = {}