This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class YieldHarvestConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "yield_harvest"
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"),
]
+31
View File
@@ -0,0 +1,31 @@
EMPTY_YIELD_HARVEST_SUMMARY = {
"yield_prediction_card": {
"id": "yield_prediction",
"title": "پیش‌بینی عملکرد",
"subtitle": "این فصل",
"stats": None,
"avatarColor": "secondary",
"avatarIcon": "tabler-chart-bar",
"chipText": "بدون داده",
"chipColor": "secondary",
"status": "empty",
"source": "db",
},
"yield_prediction_chart": {
"categories": [],
"series": [],
"summary": [],
"status": "empty",
"source": "db",
},
"harvest_prediction_card": {
"date": None,
"dateFormatted": None,
"daysUntil": None,
"description": "داده پیش‌بینی برداشت هنوز ثبت نشده است.",
"optimalWindowStart": None,
"optimalWindowEnd": None,
"status": "empty",
"source": "db",
},
}
@@ -0,0 +1,43 @@
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("farm_hub", "0007_farmhub_subscription_plan"),
]
operations = [
migrations.CreateModel(
name="YieldHarvestPredictionLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("yield_stats", models.CharField(blank=True, default="", max_length=64)),
("yield_chip_text", models.CharField(blank=True, default="", max_length=32)),
("harvest_date", models.DateField(blank=True, null=True)),
("days_until_harvest", models.IntegerField(blank=True, null=True)),
("optimal_window_start", models.DateField(blank=True, null=True)),
("optimal_window_end", models.DateField(blank=True, null=True)),
("chart_data", models.JSONField(blank=True, default=dict)),
("fetched_at", models.DateTimeField(auto_now_add=True)),
(
"farm",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="yield_harvest_predictions",
to="farm_hub.farmhub",
),
),
],
options={
"db_table": "yield_harvest_prediction_logs",
"ordering": ["-fetched_at"],
},
),
]
+209
View File
@@ -0,0 +1,209 @@
"""
Static mock data for Yield & Harvest Prediction API.
Mirrors the yieldPredictionChart and harvestPredictionCard dashboard card shapes.
"""
CONFIG_SLIDERS_ONLY = {
"sliders": [
{
"key": "light",
"label": "نور",
"min": 0,
"max": 100,
"step": 5,
"unit_type": "percent",
"default_value": 75,
"icon": "☀️",
},
{
"key": "water",
"label": "آب",
"min": 0,
"max": 100,
"step": 5,
"unit_type": "percent",
"default_value": 65,
"icon": "💧",
},
{
"key": "soil_ph",
"label": "pH خاک",
"min": 4,
"max": 9,
"step": 0.5,
"unit_type": "number",
"unit": "",
"default_value": 6.5,
},
{
"key": "growth_speed",
"label": "سرعت رشد",
"min": 0.5,
"max": 5,
"step": 0.5,
"unit_type": "number",
"unit": "x",
"default_value": 1.5,
},
],
}
CONSTANTS = {
"max_height": 280,
"max_leaves": 14,
"max_branches": 6,
"max_yield": 500,
"yield_unit": "g",
"yield_rate_unit": "g/s",
"height_unit": "px",
}
CHART_CONFIG = {
"title": "پیشرفت رشد",
"x_axis_label": "زمان (ثانیه)",
"series": [
{
"key": "height",
"label": "ارتفاع (px)",
"y_axis_id": "yHeight",
"min": 0,
"max": 280,
"unit": "px",
},
{
"key": "leaves",
"label": "تعداد برگ",
"y_axis_id": "yLeaf",
"min": 0,
"max": 14,
},
{
"key": "yield",
"label": "محصول (g)",
"y_axis_id": "yYield",
"min": 0,
"max": 500,
"unit": "g",
},
{
"key": "yield_rate",
"label": "نرخ محصول (g/s)",
"y_axis_id": "yYieldRate",
"min": 0,
"unit": "g/s",
},
],
}
_labels = [f"{i * 0.2:.1f}s" for i in range(51)]
_height = [round(142 * (i / 50) ** 0.9) for i in range(51)]
_leaf = [min(5, int(i / 10)) for i in range(51)]
_yield = [round(12.4 * (i / 50) ** 1.2, 1) for i in range(51)]
_yield_rate = [round(0.087 * max(0, (i - 15) / 35), 3) for i in range(51)]
START_RESPONSE_DATA = {
"constants": CONSTANTS,
"chart": CHART_CONFIG,
"plant": {
"height": 142,
"leaves_count": 5,
"branches_count": 2,
"fruits_count": 0,
"yield": 12.4,
"yield_rate": 0.087,
"tick": 520,
"is_healthy": True,
"can_continue": True,
},
"progress": {
"growth_progress": 50,
"light_status": 75,
"water_status": 65,
"yield_progress": 2.5,
"yield_current": 12.4,
"yield_rate_current": 0.087,
},
"chart_history": {
"labels": _labels,
"height_history": _height,
"leaf_history": _leaf,
"yield_history": _yield,
"yield_rate_history": _yield_rate,
},
}
STATE_RESPONSE_DATA = {
"plant": {
"height": 142,
"leaves_count": 5,
"branches_count": 2,
"fruits_count": 0,
"yield": 12.4,
"yield_rate": 0.087,
"tick": 520,
"is_healthy": True,
"can_continue": True,
},
"progress": {
"growth_progress": 50,
"light_status": 75,
"water_status": 65,
"yield_progress": 2.5,
"yield_current": 12.4,
"yield_rate_current": 0.087,
},
"chart": {
"labels": _labels,
"height_history": _height,
"leaf_history": _leaf,
"yield_history": _yield,
"yield_rate_history": _yield_rate,
},
}
YIELD_PREDICTION_CARD = {
"id": "yield_prediction",
"title": "پیش‌بینی عملکرد",
"subtitle": "این فصل",
"stats": "42 تن",
"avatarColor": "secondary",
"avatarIcon": "tabler-chart-bar",
"chipText": "+8%",
"chipColor": "success",
}
YIELD_PREDICTION_CHART = {
"categories": [
"ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن",
"ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر",
],
"series": [
{"name": "امسال", "data": [35, 38, 40, 42, 45, 48, 50, 48, 46, 44, 42, 42]},
{"name": "سال گذشته", "data": [32, 34, 36, 38, 40, 42, 44, 42, 40, 38, 36, 38]},
],
"summary": [
{
"title": "عملکرد پیش‌بینی‌شده",
"subtitle": "این فصل",
"amount": "42 تن",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-bar",
},
{
"title": "تاریخ برداشت",
"subtitle": "حدود ۱۵ اکتبر",
"amount": "+8%",
"avatarColor": "success",
"avatarIcon": "tabler-calendar",
},
],
}
HARVEST_PREDICTION_CARD = {
"date": "2025-10-15",
"dateFormatted": "۱۵ اکتبر ۲۰۲۵",
"daysUntil": 58,
"description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.",
"optimalWindowStart": "2025-10-12",
"optimalWindowEnd": "2025-10-18",
}
+32
View File
@@ -0,0 +1,32 @@
import uuid as uuid_lib
from django.db import models
from farm_hub.models import FarmHub
class YieldHarvestPredictionLog(models.Model):
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
farm = models.ForeignKey(
FarmHub,
on_delete=models.CASCADE,
related_name="yield_harvest_predictions",
null=True,
blank=True,
)
yield_stats = models.CharField(max_length=64, blank=True, default="")
yield_chip_text = models.CharField(max_length=32, blank=True, default="")
harvest_date = models.DateField(null=True, blank=True)
days_until_harvest = models.IntegerField(null=True, blank=True)
optimal_window_start = models.DateField(null=True, blank=True)
optimal_window_end = models.DateField(null=True, blank=True)
chart_data = models.JSONField(default=dict, blank=True)
fetched_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "yield_harvest_prediction_logs"
ordering = ["-fetched_at"]
def __str__(self):
farm_label = str(self.farm_id) if self.farm_id else "no-farm"
return f"{farm_label}{self.yield_stats} harvest:{self.harvest_date}"
@@ -0,0 +1,192 @@
from rest_framework import serializers
def success_response():
return {"status": "success"}
def success_with_data(data):
return {"status": "success", "data": data}
class YieldPredictionCardSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True)
title = serializers.CharField(required=False, allow_blank=True)
subtitle = serializers.CharField(required=False, allow_blank=True)
stats = serializers.CharField(required=False, allow_blank=True)
avatarColor = serializers.CharField(required=False, allow_blank=True)
avatarIcon = serializers.CharField(required=False, allow_blank=True)
chipText = serializers.CharField(required=False, allow_blank=True)
chipColor = serializers.CharField(required=False, allow_blank=True)
class ChartSeriesSerializer(serializers.Serializer):
name = serializers.CharField(required=False, allow_blank=True)
data = serializers.ListField(child=serializers.FloatField(), required=False)
class ChartSummaryItemSerializer(serializers.Serializer):
title = serializers.CharField(required=False, allow_blank=True)
subtitle = serializers.CharField(required=False, allow_blank=True)
amount = serializers.CharField(required=False, allow_blank=True)
avatarColor = serializers.CharField(required=False, allow_blank=True)
avatarIcon = serializers.CharField(required=False, allow_blank=True)
class YieldPredictionChartSerializer(serializers.Serializer):
categories = serializers.ListField(child=serializers.CharField(), required=False)
series = ChartSeriesSerializer(many=True, required=False)
summary = ChartSummaryItemSerializer(many=True, required=False)
class HarvestPredictionCardSerializer(serializers.Serializer):
date = serializers.CharField(required=False, allow_blank=True)
dateFormatted = serializers.CharField(required=False, allow_blank=True)
daysUntil = serializers.IntegerField(required=False)
description = serializers.CharField(required=False, allow_blank=True)
optimalWindowStart = serializers.CharField(required=False, allow_blank=True)
optimalWindowEnd = serializers.CharField(required=False, allow_blank=True)
class YieldHarvestSummarySerializer(serializers.Serializer):
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):
farm_uuid = serializers.UUIDField(
required=True,
initial="11111111-1111-1111-1111-111111111111",
help_text="UUID مزرعه برای اجرای شبیه‌سازی.",
)
irrigation_plan_uuid = serializers.UUIDField(
required=False,
help_text="UUID برنامه آبیاری برای ارسال context به AI.",
)
fertilization_plan_uuid = serializers.UUIDField(
required=False,
help_text="UUID برنامه کودی برای ارسال context به AI.",
)
class GrowthSimulationRequestSerializer(serializers.Serializer):
plant_name = serializers.CharField(
required=False,
allow_blank=True,
default="",
help_text="نام گیاه؛ اگر farm_uuid ارسال شود از محصول مزرعه استفاده می‌شود.",
)
dynamic_parameters = serializers.ListField(
child=serializers.CharField(),
required=True,
allow_empty=False,
help_text="لیست پارامترهای دینامیک موردنیاز مانند DVS یا LAI.",
)
farm_uuid = serializers.UUIDField(
required=False,
allow_null=True,
initial="11111111-1111-1111-1111-111111111111",
help_text="UUID مزرعه؛ در صورت نبود باید weather ارسال شود.",
)
weather = serializers.JSONField(required=False, help_text="آب‌وهوا به‌صورت object یا array.")
soil_parameters = serializers.DictField(required=False, help_text="پارامترهای خاک.")
site_parameters = serializers.DictField(required=False, help_text="پارامترهای سایت.")
crop_parameters = serializers.DictField(required=False, help_text="پارامترهای محصول.")
agromanagement = serializers.DictField(required=False, help_text="تنظیمات مدیریت زراعی.")
page_size = serializers.IntegerField(required=False, min_value=1, max_value=50, help_text="اندازه صفحه بین 1 تا 50.")
def validate(self, attrs):
if not attrs.get("farm_uuid") and attrs.get("weather") in (None, "", [], {}):
raise serializers.ValidationError("At least one of 'farm_uuid' or 'weather' must be provided.")
if not attrs.get("farm_uuid") and not (attrs.get("plant_name") or "").strip():
raise serializers.ValidationError({"plant_name": ["This field is required when farm_uuid is not provided."]})
return attrs
class GrowthSimulationQueuedDataSerializer(serializers.Serializer):
task_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه تسک شبیه‌سازی رشد.")
status_url = serializers.CharField(required=False, allow_blank=True, help_text="آدرس بررسی وضعیت تسک.")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه شبیه‌سازی‌شده.")
class GrowthSimulationProgressSerializer(serializers.Serializer):
current = serializers.IntegerField(required=False, help_text="مرحله فعلی پیشرفت.")
total = serializers.IntegerField(required=False, help_text="تعداد کل مراحل.")
percent = serializers.FloatField(required=False, help_text="درصد پیشرفت.")
class GrowthSimulationPaginationSerializer(serializers.Serializer):
page = serializers.IntegerField(required=False, help_text="شماره صفحه فعلی.")
page_size = serializers.IntegerField(required=False, help_text="اندازه صفحه.")
total_items = serializers.IntegerField(required=False, help_text="تعداد کل آیتم‌ها.")
total_pages = serializers.IntegerField(required=False, help_text="تعداد کل صفحات.")
has_next = serializers.BooleanField(required=False, help_text="آیا صفحه بعدی وجود دارد.")
has_previous = serializers.BooleanField(required=False, help_text="آیا صفحه قبلی وجود دارد.")
class GrowthSimulationResultSerializer(serializers.Serializer):
plant_name = serializers.CharField(required=False, allow_blank=True)
dynamic_parameters = serializers.ListField(child=serializers.CharField(), required=False)
engine = serializers.CharField(required=False, allow_blank=True, allow_null=True)
model_name = serializers.CharField(required=False, allow_blank=True, allow_null=True)
scenario_id = serializers.IntegerField(required=False)
simulation_warning = serializers.CharField(required=False, allow_blank=True)
summary_metrics = serializers.DictField(required=False)
stage_timeline = serializers.ListField(child=serializers.DictField(), required=False)
stages_page = serializers.ListField(child=serializers.DictField(), required=False)
pagination = GrowthSimulationPaginationSerializer(required=False)
daily_records_count = serializers.IntegerField(required=False)
default_page_size = serializers.IntegerField(required=False)
class GrowthSimulationStatusDataSerializer(serializers.Serializer):
task_id = serializers.CharField(required=False, allow_blank=True)
status = serializers.CharField(required=False, allow_blank=True)
message = serializers.CharField(required=False, allow_blank=True)
progress = GrowthSimulationProgressSerializer(required=False)
result = GrowthSimulationResultSerializer(required=False)
error = serializers.CharField(required=False, allow_blank=True)
class CurrentFarmChartSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, allow_blank=True, allow_null=True)
plant_name = serializers.CharField(required=False, allow_blank=True)
engine = serializers.CharField(required=False, allow_blank=True, allow_null=True)
model_name = serializers.CharField(required=False, allow_blank=True, allow_null=True)
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.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.ListField(child=serializers.DictField(), required=False)
class HarvestPredictionSerializer(serializers.Serializer):
date = serializers.CharField(required=False, allow_blank=True)
dateFormatted = serializers.CharField(required=False, allow_blank=True)
daysUntil = serializers.IntegerField(required=False)
description = serializers.CharField(required=False, allow_blank=True)
optimalWindowStart = serializers.CharField(required=False, allow_blank=True)
optimalWindowEnd = serializers.CharField(required=False, allow_blank=True)
gddDetails = serializers.DictField(required=False)
class YieldPredictionSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, allow_blank=True)
plant_name = serializers.CharField(required=False, allow_blank=True, allow_null=True)
predictedYieldTons = serializers.FloatField(required=False)
predictedYieldRaw = serializers.FloatField(required=False)
unit = serializers.CharField(required=False, allow_blank=True)
sourceUnit = serializers.CharField(required=False, allow_blank=True)
simulationEngine = serializers.CharField(required=False, allow_blank=True, allow_null=True)
simulationModel = serializers.CharField(required=False, allow_blank=True, allow_null=True)
scenarioId = serializers.IntegerField(required=False)
simulationWarning = serializers.CharField(required=False, allow_blank=True)
supportingMetrics = serializers.DictField(required=False)
+40
View File
@@ -0,0 +1,40 @@
from copy import deepcopy
from .defaults import EMPTY_YIELD_HARVEST_SUMMARY
from .models import YieldHarvestPredictionLog
def get_yield_harvest_summary_data(farm=None):
data = deepcopy(EMPTY_YIELD_HARVEST_SUMMARY)
if farm is None:
return data
log = YieldHarvestPredictionLog.objects.filter(farm=farm).first()
if log is None:
return data
data["yield_prediction_card"]["status"] = "success"
data["yield_prediction_card"]["source"] = "db"
data["yield_prediction_chart"]["status"] = "success"
data["yield_prediction_chart"]["source"] = "db"
data["harvest_prediction_card"]["status"] = "success"
data["harvest_prediction_card"]["source"] = "db"
if log.yield_stats:
data["yield_prediction_card"]["stats"] = log.yield_stats
if log.yield_chip_text:
data["yield_prediction_card"]["chipText"] = log.yield_chip_text
if log.chart_data:
data["yield_prediction_chart"] = deepcopy(log.chart_data)
if log.harvest_date:
data["harvest_prediction_card"]["date"] = log.harvest_date.isoformat()
if log.days_until_harvest is not None:
data["harvest_prediction_card"]["daysUntil"] = log.days_until_harvest
if log.optimal_window_start:
data["harvest_prediction_card"]["optimalWindowStart"] = log.optimal_window_start.isoformat()
if log.optimal_window_end:
data["harvest_prediction_card"]["optimalWindowEnd"] = log.optimal_window_end.isoformat()
return data
+737
View File
@@ -0,0 +1,737 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
from config.observability import METRICS
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,
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",
password="secret123",
email="farmer@example.com",
phone_number="09120000000",
)
self.other_user = get_user_model().objects.create_user(
username="other-farmer",
password="secret123",
email="other@example.com",
phone_number="09120000001",
)
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.product = Product.objects.create(farm_type=self.farm_type, name="گوجه‌فرنگی")
self.farm.products.add(self.product)
self.api_client.force_authenticate(user=self.user)
def tearDown(self):
METRICS.clear()
@patch("yield_harvest.views.external_api_request")
def test_growth_queues_simulation_task(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=202,
data={
"data": {
"task_id": "growth-task-123",
"status_url": "/api/crop-simulation/growth/growth-task-123/status/",
"plant_name": "گوجه‌فرنگی",
}
},
)
request = self.factory.post(
"/api/yield-harvest/crop-simulation/growth/",
{"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)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.data["code"], 202)
self.assertEqual(response.data["data"]["task_id"], "growth-task-123")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/growth/",
method="POST",
payload={
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI"],
"farm_uuid": str(self.farm.farm_uuid),
},
)
@patch("yield_harvest.views.external_api_request")
def test_growth_top_level_route_queues_simulation_task(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=202,
data={
"data": {
"task_id": "growth-task-123",
"status_url": "/api/crop-simulation/growth/growth-task-123/status/",
"plant_name": "گوجه‌فرنگی",
}
},
)
response = self.api_client.post(
"/api/crop-simulation/growth/",
{
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI"],
"farm_uuid": str(self.farm.farm_uuid),
},
format="json",
)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.json()["data"]["task_id"], "growth-task-123")
@patch("yield_harvest.views.external_api_request")
def test_growth_yield_harvest_route_queues_simulation_task(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=202,
data={
"data": {
"task_id": "growth-task-123",
"status_url": "/api/crop-simulation/growth/growth-task-123/status/",
"plant_name": "گوجه‌فرنگی",
}
},
)
response = self.api_client.post(
"/api/yield-harvest/growth/",
{
"plant_name": "wheat",
"dynamic_parameters": ["DVS", "LAI"],
"farm_uuid": str(self.farm.farm_uuid),
},
format="json",
)
self.assertEqual(response.status_code, 202)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/growth/",
method="POST",
payload={
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI"],
"farm_uuid": str(self.farm.farm_uuid),
},
)
def test_growth_requires_farm_uuid_or_weather(self):
request = self.factory.post(
"/api/yield-harvest/crop-simulation/growth/",
{"plant_name": "گوجه‌فرنگی", "dynamic_parameters": ["DVS"]},
format="json",
)
force_authenticate(request, user=self.user)
response = GrowthSimulationView.as_view()(request)
self.assertEqual(response.status_code, 400)
@patch("yield_harvest.views.external_api_request")
def test_growth_status_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"task_id": "growth-task-123",
"status": "SUCCESS",
"message": "done",
"progress": {},
"result": {
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI"],
"scenario_id": 1,
},
"error": "",
}
},
)
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)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["status"], "SUCCESS")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/growth/growth-task-123/status/",
method="GET",
query={"page": "1", "page_size": "10"},
)
@patch("yield_harvest.views.external_api_request")
def test_growth_status_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"task_id": "growth-task-123",
"status": "SUCCESS",
"message": "done",
"progress": {},
"result": {
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI"],
"scenario_id": 1,
},
"error": "",
}
},
)
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")
@patch("yield_harvest.views.external_api_request")
def test_growth_status_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"task_id": "growth-task-123", "status": "SUCCESS"}},
)
response = self.api_client.get("/api/yield-harvest/growth/growth-task-123/status/?page=1&page_size=10")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["status"], "SUCCESS")
def test_legacy_plant_simulator_routes_are_unavailable(self):
legacy_paths = [
"/api/yield-harvest/plant-simulator/config/",
"/api/yield-harvest/plant-simulator/environment/",
"/api/yield-harvest/plant-simulator/reset/",
"/api/yield-harvest/plant-simulator/start/",
"/api/yield-harvest/plant-simulator/state/",
"/api/yield-harvest/plant-simulator/stop/",
]
for path in legacy_paths:
response = self.client.get(path)
self.assertEqual(response.status_code, 404, path)
@patch("yield_harvest.views.external_api_request")
def test_current_farm_chart_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": "گوجه‌فرنگی",
"scenario_id": 1,
"categories": ["day1"],
"series": {"biomass": [1.2]},
}
}
},
)
request = self.factory.post(
"/api/yield-harvest/crop-simulation/current-farm-chart/",
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"},
format="json",
)
force_authenticate(request, user=self.user)
response = CurrentFarmChartView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["scenario_id"], 1)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/current-farm-chart/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@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_current_farm_chart_yield_harvest_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": "گوجه‌فرنگی"}}},
)
response = self.api_client.post(
"/api/yield-harvest/current-farm-chart/",
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"},
format="json",
)
self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/current-farm-chart/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@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(
status_code=200,
data={
"data": {
"result": {
"date": "2026-07-15",
"dateFormatted": "15 Jul 2026",
"daysUntil": 96,
"gddDetails": {"current": 800},
}
}
},
)
request = self.factory.post(
"/api/yield-harvest/crop-simulation/harvest-prediction/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = HarvestPredictionView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["daysUntil"], 96)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/harvest-prediction/",
method="POST",
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_harvest_prediction_yield_harvest_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/yield-harvest/harvest-prediction/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/harvest-prediction/",
method="POST",
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_uuid": str(irrigation_plan.uuid)},
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"]["uuid"], str(irrigation_plan.uuid))
@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_uuid": str(fertilization_plan.uuid)},
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)
self.assertEqual(sent_payload["fertilization_plan"]["uuid"], str(fertilization_plan.uuid))
@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(
status_code=200,
data={
"data": {
"result": {
"farm_uuid": str(self.farm.farm_uuid),
"predictedYieldTons": 8.4,
"scenarioId": 1,
}
}
},
)
request = self.factory.post(
"/api/yield-harvest/crop-simulation/yield-prediction/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = YieldPredictionView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["predictedYieldTons"], 8.4)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/yield-prediction/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@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_prediction_yield_harvest_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/yield-harvest/yield-prediction/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/yield-prediction/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@patch("yield_harvest.views.external_api_request")
def test_yield_prediction_falls_back_to_farm_type_product_when_farm_products_are_empty(self, mock_external_api_request):
farm_without_products = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm fallback")
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"farm_uuid": str(farm_without_products.farm_uuid), "predictedYieldTons": 8.4}}},
)
response = self.api_client.post(
"/api/yield-harvest/yield-prediction/",
{"farm_uuid": str(farm_without_products.farm_uuid)},
format="json",
)
self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/yield-prediction/",
method="POST",
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_uuid": str(irrigation_plan.uuid),
"fertilization_plan_uuid": str(fertilization_plan.uuid),
},
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_uuids(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_uuid": str(other_irrigation_plan.uuid),
},
format="json",
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["data"]["irrigation_plan_uuid"][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(
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",
},
)
@patch("yield_harvest.views.external_api_request")
def test_yield_harvest_summary_yield_harvest_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), "yield_prediction_chart": {"series": []}}}},
)
response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}")
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_uuid={irrigation_plan.uuid}&fertilization_plan_uuid={fertilization_plan.uuid}"
)
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)
@patch("yield_harvest.views.external_api_request")
def test_yield_harvest_summary_records_empty_result_metric(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {"result": {}}})
response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}")
self.assertEqual(response.status_code, 200)
self.assertEqual(METRICS["yield_harvest.ai.empty_result|operation=yield_harvest_summary"], 1)
@patch("yield_harvest.views.external_api_request")
def test_yield_harvest_summary_persists_seeded_log_from_realistic_ai_contract(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),
"yield_prediction": {"predicted_yield_tons": 5.1, "unit": "tons"},
"harvest_prediction_card": {
"harvest_date": "2026-09-28",
"days_until": 152,
"optimalWindowStart": "2026-09-25",
"optimalWindowEnd": "2026-10-01",
},
"yield_prediction_chart": {"series": [{"name": "yield", "data": []}]},
}
}
},
)
response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}")
self.assertEqual(response.status_code, 200)
self.assertTrue(self.farm.yield_harvest_prediction_logs.exists())
log = self.farm.yield_harvest_prediction_logs.latest("id")
self.assertEqual(log.yield_stats, "5.1")
self.assertEqual(str(log.harvest_date), "2026-09-28")
@patch("yield_harvest.views.external_api_request")
def test_yield_prediction_provider_unavailable_returns_explicit_failure(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=503,
data={"message": "provider unavailable"},
)
response = self.api_client.post(
"/api/yield-harvest/yield-prediction/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.json()["data"]["message"], "provider unavailable")
def test_crop_simulation_rejects_foreign_farm_uuid(self):
request = self.factory.post(
"/api/yield-harvest/crop-simulation/yield-prediction/",
{"farm_uuid": str(self.other_farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = YieldPredictionView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["code"], 404)
self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.")
+20
View File
@@ -0,0 +1,20 @@
from django.urls import path
from .views import (
CurrentFarmChartView,
GrowthSimulationStatusView,
GrowthSimulationView,
HarvestPredictionView,
YieldHarvestSummaryView,
YieldPredictionView,
)
urlpatterns = [
path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
path("current-farm-chart/", CurrentFarmChartView.as_view(), name="yield-harvest-current-farm-chart"),
path("growth/", GrowthSimulationView.as_view(), name="yield-harvest-growth"),
path("growth/<str:task_id>/status/", GrowthSimulationStatusView.as_view(), name="yield-harvest-growth-status"),
path("harvest-prediction/", HarvestPredictionView.as_view(), name="yield-harvest-harvest-prediction"),
path("yield-prediction/", YieldPredictionView.as_view(), name="yield-harvest-yield-prediction"),
path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-crop-simulation-summary"),
]
+543
View File
@@ -0,0 +1,543 @@
"""Yield & Harvest Prediction and Crop Simulation API views."""
import logging
import time
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.observability import classify_exception, log_event, observe_operation, record_metric
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,
CurrentFarmChartSerializer,
GrowthSimulationQueuedDataSerializer,
GrowthSimulationRequestSerializer,
GrowthSimulationStatusDataSerializer,
HarvestPredictionSerializer,
YieldHarvestSummarySerializer,
YieldPredictionSerializer,
)
logger = logging.getLogger(__name__)
class YieldHarvestSummaryView(APIView):
"""
GET endpoint for combined yield prediction and harvest prediction data.
Purpose:
Returns three dashboard card payloads in one response:
- yield_prediction_card (kpi card shape)
- yield_prediction_chart (monthly chart + summary)
- harvest_prediction_card (harvest date + window)
Data is fetched from the AI external adapter. If farm_uuid is provided
and the farm exists, the result is persisted in YieldHarvestPredictionLog.
Input parameters:
- farm_uuid (query, optional): UUID of the farm.
Response structure:
- status: string, always "success".
- data: object with keys yield_prediction_card,
yield_prediction_chart, harvest_prediction_card.
"""
@extend_schema(
tags=["Yield & Harvest Prediction"],
parameters=[
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: code_response("YieldHarvestSummaryResponse", data=YieldHarvestSummarySerializer())},
)
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
farm, error_response = CropSimulationBaseView._get_farm(request, farm_uuid)
if error_response is not None:
return error_response
irrigation_plan_uuid, irrigation_plan_error = CropSimulationBaseView._parse_optional_plan_uuid(
request.query_params.get("irrigation_plan_uuid"),
"irrigation_plan_uuid",
)
if irrigation_plan_error is not None:
return irrigation_plan_error
fertilization_plan_uuid, fertilization_plan_error = CropSimulationBaseView._parse_optional_plan_uuid(
request.query_params.get("fertilization_plan_uuid"),
"fertilization_plan_uuid",
)
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")
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")
ai_payload, plan_error = CropSimulationBaseView()._build_ai_payload_with_selected_plans(
farm,
irrigation_plan_uuid=irrigation_plan_uuid,
fertilization_plan_uuid=fertilization_plan_uuid,
)
if plan_error is not None:
return plan_error
query.update(ai_payload)
with observe_operation(source="backend.yield_harvest", provider="ai", operation="yield_harvest_summary"):
started_at = time.monotonic()
adapter_response = external_api_request(
"ai",
"/api/crop-simulation/yield-harvest-summary/",
method="GET",
query=query,
)
if adapter_response.status_code >= 400:
record_metric("yield_harvest.ai.failure", status_code=adapter_response.status_code, operation="yield_harvest_summary")
return CropSimulationBaseView._error_response(adapter_response)
summary = CropSimulationBaseView._extract_result(adapter_response.data)
if not summary:
record_metric("yield_harvest.ai.empty_result", operation="yield_harvest_summary")
log_event(
level=logging.WARNING,
message="yield harvest summary returned empty result",
source="backend.yield_harvest",
provider="ai",
operation="yield_harvest_summary",
result_status="empty",
duration_ms=(time.monotonic() - started_at) * 1000,
farm_uuid=str(farm.farm_uuid),
)
self._persist_log(farm.farm_uuid, summary)
return Response(
{"code": 200, "msg": "success", "data": summary},
status=status.HTTP_200_OK,
)
@staticmethod
def _persist_log(farm_uuid, summary):
farm = None
if farm_uuid:
try:
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
except FarmHub.DoesNotExist:
logger.warning("yield_harvest log persistence skipped because farm was not found farm_uuid=%s", farm_uuid)
except Exception as exc:
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="yield_harvest log persistence failed",
source="backend.yield_harvest",
provider="db",
operation="persist_log",
result_status="error",
error_code=failure.error_code,
farm_uuid=str(farm_uuid),
)
return
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=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,
)
class CropSimulationBaseView(APIView):
@staticmethod
def _get_farm(request, farm_uuid):
if not farm_uuid:
return None, Response(
{"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}},
status=status.HTTP_400_BAD_REQUEST,
)
try:
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None
except FarmHub.DoesNotExist:
return None, Response(
{"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}},
status=status.HTTP_404_NOT_FOUND,
)
@staticmethod
def _extract_result(adapter_data):
if not isinstance(adapter_data, dict):
record_metric("yield_harvest.ai.invalid_payload", operation="extract_result")
return {}
data = adapter_data.get("data")
if isinstance(data, dict) and isinstance(data.get("result"), dict):
return data["result"]
if isinstance(data, dict):
return data
result = adapter_data.get("result")
if isinstance(result, dict):
return result
return adapter_data
@staticmethod
def _error_response(adapter_response):
response_data = (
adapter_response.data
if isinstance(adapter_response.data, dict)
else {"message": str(adapter_response.data)}
)
log_event(
level=logging.ERROR,
message="yield_harvest upstream request failed",
source="backend.yield_harvest",
provider="ai",
operation="external_api",
result_status="error",
error_code="provider_error",
status_code=adapter_response.status_code,
)
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
@staticmethod
def _get_first_farm_product_name(farm):
first_product = farm.products.order_by("id").first()
if first_product is not None:
return (first_product.name or "").strip()
fallback_product = farm.farm_type.products.order_by("id").first()
if fallback_product is not None:
return (fallback_product.name or "").strip()
return ""
@staticmethod
def _get_irrigation_plan_or_error(farm, plan_uuid):
if not plan_uuid:
return None, None
plan = IrrigationPlan.objects.filter(
uuid=plan_uuid,
farm=farm,
is_deleted=False,
).first()
if plan is None:
return None, Response(
{"code": 404, "msg": "error", "data": {"irrigation_plan_uuid": ["Irrigation plan not found."]}},
status=status.HTTP_404_NOT_FOUND,
)
return plan, None
@staticmethod
def _get_fertilization_plan_or_error(farm, plan_uuid):
if not plan_uuid:
return None, None
plan = FertilizationPlan.objects.filter(
uuid=plan_uuid,
farm=farm,
is_deleted=False,
).first()
if plan is None:
return None, Response(
{"code": 404, "msg": "error", "data": {"fertilization_plan_uuid": ["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_uuid=None, fertilization_plan_uuid=None):
irrigation_plan, irrigation_error = self._get_irrigation_plan_or_error(farm, irrigation_plan_uuid)
if irrigation_error is not None:
return None, irrigation_error
fertilization_plan, fertilization_error = self._get_fertilization_plan_or_error(
farm, fertilization_plan_uuid
)
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_uuid(raw_value, field_name):
if raw_value in (None, ""):
return None, None
try:
parsed_value = str(serializers.UUIDField().to_internal_value(raw_value))
except serializers.ValidationError:
return None, Response(
{"code": 400, "msg": "error", "data": {field_name: ["Must be a valid UUID."]}},
status=status.HTTP_400_BAD_REQUEST,
)
return parsed_value, None
class CurrentFarmChartView(CropSimulationBaseView):
ai_path = "/api/crop-simulation/current-farm-chart/"
@extend_schema(
tags=["Crop Simulation"],
request=CropSimulationRequestSerializer,
responses={200: code_response("CurrentFarmChartResponse", data=CurrentFarmChartSerializer())},
)
def post(self, request):
serializer = CropSimulationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data.copy()
farm, error_response = self._get_farm(request, payload.get("farm_uuid"))
if error_response is not None:
return error_response
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
farm,
irrigation_plan_uuid=payload.get("irrigation_plan_uuid"),
fertilization_plan_uuid=payload.get("fertilization_plan_uuid"),
)
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:
return self._error_response(adapter_response)
return Response(
{"code": 200, "msg": "success", "data": self._extract_result(adapter_response.data)},
status=status.HTTP_200_OK,
)
class HarvestPredictionView(CropSimulationBaseView):
ai_path = "/api/crop-simulation/harvest-prediction/"
@extend_schema(
tags=["Crop Simulation"],
request=CropSimulationRequestSerializer,
responses={200: code_response("CropSimulationHarvestPredictionResponse", data=HarvestPredictionSerializer())},
)
def post(self, request):
serializer = CropSimulationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data.copy()
farm, error_response = self._get_farm(request, payload.get("farm_uuid"))
if error_response is not None:
return error_response
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
farm,
irrigation_plan_uuid=payload.get("irrigation_plan_uuid"),
fertilization_plan_uuid=payload.get("fertilization_plan_uuid"),
)
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:
return self._error_response(adapter_response)
return Response(
{"code": 200, "msg": "success", "data": self._extract_result(adapter_response.data)},
status=status.HTTP_200_OK,
)
class YieldPredictionView(CropSimulationBaseView):
ai_path = "/api/crop-simulation/yield-prediction/"
@extend_schema(
tags=["Crop Simulation"],
request=CropSimulationRequestSerializer,
responses={200: code_response("CropSimulationYieldPredictionResponse", data=YieldPredictionSerializer())},
)
def post(self, request):
serializer = CropSimulationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data.copy()
farm, error_response = self._get_farm(request, payload.get("farm_uuid"))
if error_response is not None:
return error_response
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
farm,
irrigation_plan_uuid=payload.get("irrigation_plan_uuid"),
fertilization_plan_uuid=payload.get("fertilization_plan_uuid"),
)
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:
return self._error_response(adapter_response)
return Response(
{"code": 200, "msg": "success", "data": self._extract_result(adapter_response.data)},
status=status.HTTP_200_OK,
)
class GrowthSimulationView(APIView):
@extend_schema(
tags=["Crop Simulation"],
request=GrowthSimulationRequestSerializer,
responses={202: code_response("GrowthSimulationQueuedResponse", data=GrowthSimulationQueuedDataSerializer())},
)
def post(self, request):
serializer = GrowthSimulationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data.copy()
farm_uuid = payload.get("farm_uuid")
if farm_uuid is not None:
farm, error_response = CropSimulationBaseView._get_farm(request, farm_uuid)
if error_response is not None:
return error_response
payload["farm_uuid"] = str(farm.farm_uuid)
payload["plant_name"] = CropSimulationBaseView._get_first_farm_product_name(farm)
adapter_response = external_api_request(
"ai",
"/api/crop-simulation/growth/",
method="POST",
payload=payload,
)
if adapter_response.status_code >= 400:
response_data = (
adapter_response.data
if isinstance(adapter_response.data, dict)
else {"message": str(adapter_response.data)}
)
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
return Response(
{"code": 202, "msg": "تسک شبیه سازی رشد در صف قرار گرفت.", "data": CropSimulationBaseView._extract_result(adapter_response.data)},
status=status.HTTP_202_ACCEPTED,
)
class GrowthSimulationStatusView(APIView):
@extend_schema(
tags=["Crop Simulation"],
parameters=[
OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="شماره صفحه."),
OpenApiParameter(
name="page_size",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="اندازه صفحه بین 1 تا 50.",
),
],
responses={200: code_response("GrowthSimulationStatusResponse", data=GrowthSimulationStatusDataSerializer())},
)
def get(self, request, task_id):
query = {}
if request.query_params.get("page"):
query["page"] = request.query_params.get("page")
if request.query_params.get("page_size"):
query["page_size"] = request.query_params.get("page_size")
adapter_response = external_api_request(
"ai",
f"/api/crop-simulation/growth/{task_id}/status/",
method="GET",
query=query or None,
)
if adapter_response.status_code >= 400:
response_data = (
adapter_response.data
if isinstance(adapter_response.data, dict)
else {"message": str(adapter_response.data)}
)
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
return Response(
{"code": 200, "msg": "success", "data": CropSimulationBaseView._extract_result(adapter_response.data)},
status=status.HTTP_200_OK,
)