UPDATE
This commit is contained in:
@@ -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"),
|
||||
]
|
||||
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user