569 lines
23 KiB
Python
569 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
import uuid
|
|
|
|
from django.apps import apps
|
|
from django.test import override_settings
|
|
|
|
from crop_simulation.models import SimulationRun, SimulationScenario
|
|
from farm_alerts.models import FarmAlertNotification
|
|
from farm_data.models import SensorData
|
|
from farm_data.services import assign_farm_plants_from_backend_ids
|
|
from integration_tests.base import IntegrationAPITestCase, square_boundary
|
|
|
|
|
|
class FakeAsyncResult:
|
|
def __init__(self, *, state: str, result=None, info=None):
|
|
self.state = state
|
|
self.result = result
|
|
self.info = info
|
|
|
|
|
|
@override_settings(ROOT_URLCONF="config.urls")
|
|
class ReportingAndAiJourneyTests(IntegrationAPITestCase):
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
self.irrigation_method = self.create_irrigation_method_via_api("Analytics Drip")
|
|
self.primary_plant = self.create_plant_via_api("Tomato")
|
|
self.secondary_plant = self.create_plant_via_api("Pepper")
|
|
self.farm_uuid = uuid.uuid4()
|
|
self.upsert_farm_via_api(
|
|
farm_uuid=self.farm_uuid,
|
|
plant_ids=[self.primary_plant["id"], self.secondary_plant["id"]],
|
|
irrigation_method_id=self.irrigation_method["id"],
|
|
sensor_payload={
|
|
"sensor-7-1": {
|
|
"soil_moisture": 46.0,
|
|
"soil_temperature": 24.2,
|
|
"soil_ph": 6.5,
|
|
"electrical_conductivity": 1.3,
|
|
"nitrogen": 20.0,
|
|
"phosphorus": 11.0,
|
|
"potassium": 18.0,
|
|
"timestamp": "2026-04-10T06:30:00Z",
|
|
}
|
|
},
|
|
)
|
|
self.seed_neighbor_farm()
|
|
|
|
def seed_neighbor_farm(self) -> None:
|
|
neighbor_location = self.create_complete_location(
|
|
lat=35.706000,
|
|
lon=51.406000,
|
|
boundary=square_boundary(35.706000, 51.406000, delta=0.008),
|
|
clay_values=(19.0, 16.5, 13.0),
|
|
nitrogen_values=(12.0, 10.0, 7.0),
|
|
)
|
|
self.seed_weather_forecasts(
|
|
neighbor_location,
|
|
start=self.forecast_start,
|
|
days=7,
|
|
temperature_base=24.0,
|
|
et0_base=3.8,
|
|
)
|
|
neighbor_sensor = SensorData.objects.create(
|
|
farm_uuid=uuid.uuid4(),
|
|
center_location=neighbor_location,
|
|
weather_forecast=neighbor_location.weather_forecasts.order_by("forecast_date").first(),
|
|
irrigation_method_id=self.irrigation_method["id"],
|
|
sensor_payload={
|
|
"sensor-7-1": {
|
|
"soil_moisture": 38.5,
|
|
"soil_temperature": 25.0,
|
|
"soil_ph": 6.7,
|
|
"electrical_conductivity": 1.0,
|
|
"nitrogen": 16.0,
|
|
"timestamp": "2026-04-10T06:35:00Z",
|
|
}
|
|
},
|
|
)
|
|
assign_farm_plants_from_backend_ids(neighbor_sensor, [self.primary_plant["id"]])
|
|
|
|
def test_reporting_endpoints_read_from_persisted_farm_context(self) -> None:
|
|
soil_response = self.client.get(
|
|
"/api/location-data/",
|
|
data={"lat": f"{self.primary_lat:.6f}", "lon": f"{self.primary_lon:.6f}"},
|
|
)
|
|
self.assertEqual(soil_response.status_code, 200)
|
|
self.assertEqual(soil_response.json()["data"]["source"], "database")
|
|
self.assertIn("satellite_snapshots", soil_response.json()["data"])
|
|
|
|
weather_response = self.client.post(
|
|
"/api/weather/farm-card/",
|
|
data={"farm_uuid": str(self.farm_uuid)},
|
|
format="json",
|
|
)
|
|
self.assertEqual(weather_response.status_code, 200)
|
|
self.assertEqual(weather_response.json()["data"]["condition"], "صاف")
|
|
|
|
with patch(
|
|
"weather.water_need_prediction.get_water_need_prediction_insight",
|
|
return_value={
|
|
"summary": "Water demand is moderate for the next week.",
|
|
"irrigation_outlook": "Increase slowly.",
|
|
"recommended_action": "Keep early morning irrigation.",
|
|
"risk_note": "Watch evapotranspiration after day 4.",
|
|
"confidence": 0.88,
|
|
"knowledge_base": "water_need_prediction",
|
|
"tone_file": "config/tones/water_need_prediction_tone.txt",
|
|
"raw_response": "{\"summary\": \"ok\"}",
|
|
},
|
|
):
|
|
water_need_response = self.client.post(
|
|
"/api/weather/water-need-prediction/",
|
|
data={"farm_uuid": str(self.farm_uuid)},
|
|
format="json",
|
|
)
|
|
self.assertEqual(water_need_response.status_code, 200)
|
|
self.assertGreater(water_need_response.json()["data"]["totalNext7Days"], 0)
|
|
self.assertEqual(
|
|
water_need_response.json()["data"]["knowledge_base"],
|
|
"water_need_prediction",
|
|
)
|
|
|
|
economy_response = self.client.post(
|
|
"/api/economy/overview/",
|
|
data={"farm_uuid": str(self.farm_uuid)},
|
|
format="json",
|
|
)
|
|
self.assertEqual(economy_response.status_code, 200)
|
|
self.assertEqual(economy_response.json()["data"]["farm_uuid"], str(self.farm_uuid))
|
|
|
|
heatmap_response = self.client.post(
|
|
"/api/soile/moisture-heatmap/",
|
|
data={"farm_uuid": str(self.farm_uuid)},
|
|
format="json",
|
|
)
|
|
self.assertEqual(heatmap_response.status_code, 200)
|
|
self.assertGreater(len(heatmap_response.json()["data"]["grid_cells"]), 0)
|
|
self.assertGreaterEqual(len(heatmap_response.json()["data"]["sensor_points"]), 1)
|
|
|
|
soil_health_response = self.client.post(
|
|
"/api/soile/health-summary/",
|
|
data={"farm_uuid": str(self.farm_uuid)},
|
|
format="json",
|
|
)
|
|
self.assertEqual(soil_health_response.status_code, 200)
|
|
self.assertIn("healthScore", soil_health_response.json()["data"])
|
|
|
|
with patch(
|
|
"soile.services.get_soil_anomaly_insight",
|
|
return_value={
|
|
"interpretation": {
|
|
"summary": "No critical anomaly detected.",
|
|
"recommended_action": "Continue monitoring.",
|
|
},
|
|
"knowledge_base": "soil_anomaly",
|
|
"raw_response": "{\"status\": \"ok\"}",
|
|
},
|
|
):
|
|
anomaly_response = self.client.post(
|
|
"/api/soile/anomaly-detection/",
|
|
data={"farm_uuid": str(self.farm_uuid)},
|
|
format="json",
|
|
)
|
|
self.assertEqual(anomaly_response.status_code, 200)
|
|
self.assertEqual(anomaly_response.json()["data"]["knowledge_base"], "soil_anomaly")
|
|
|
|
ndvi_response = self.client.post(
|
|
"/api/location-data/ndvi-health/",
|
|
data={"farm_uuid": str(self.farm_uuid)},
|
|
format="json",
|
|
)
|
|
self.assertEqual(ndvi_response.status_code, 200)
|
|
self.assertEqual(ndvi_response.json()["data"]["vegetation_health_class"], "Healthy")
|
|
|
|
broken_simulation_service = SimpleNamespace(
|
|
get_water_stress=lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("simulation offline"))
|
|
)
|
|
crop_simulation_app = apps.get_app_config("crop_simulation")
|
|
with patch.object(
|
|
crop_simulation_app,
|
|
"get_water_stress_service",
|
|
return_value=broken_simulation_service,
|
|
):
|
|
water_stress_response = self.client.post(
|
|
"/api/irrigation/water-stress/",
|
|
data={"farm_uuid": str(self.farm_uuid)},
|
|
format="json",
|
|
)
|
|
self.assertEqual(water_stress_response.status_code, 500)
|
|
|
|
def test_ai_assistant_and_recommendation_endpoints_use_farm_context(self) -> None:
|
|
with ExitStack() as stack:
|
|
stack.enter_context(
|
|
patch("rag.config.load_rag_config", return_value=SimpleNamespace())
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"rag.views.chat_rag_stream",
|
|
return_value=iter(["Farm looks stable. ", "Moisture is acceptable."]),
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"rag.services.irrigation.get_irrigation_recommendation",
|
|
return_value={
|
|
"sections": [
|
|
{
|
|
"type": "recommendation",
|
|
"title": "برنامه آبیاری بهینه",
|
|
"content": "هفته ای 3 نوبت آبیاری انجام شود.",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"rag.services.fertilization.get_fertilization_recommendation",
|
|
return_value={
|
|
"sections": [
|
|
{
|
|
"type": "recommendation",
|
|
"title": "برنامه کودهی بهینه",
|
|
"fertilizerType": "15-5-30",
|
|
"amount": "60 kg",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"pest_disease.views.get_pest_disease_detection",
|
|
return_value={
|
|
"diagnosis": "low risk leaf stress",
|
|
"confidence": 0.81,
|
|
},
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"pest_disease.views.get_pest_disease_risk",
|
|
return_value={
|
|
"riskLevel": "medium",
|
|
"topRisk": "powdery mildew",
|
|
},
|
|
)
|
|
)
|
|
|
|
chat_response = self.client.post(
|
|
"/api/rag/chat/",
|
|
data={
|
|
"farm_uuid": str(self.farm_uuid),
|
|
"query": "Give me a short farm update",
|
|
"history": [],
|
|
},
|
|
format="json",
|
|
)
|
|
self.assertEqual(chat_response.status_code, 200)
|
|
streamed_text = b"".join(chat_response.streaming_content).decode("utf-8")
|
|
self.assertIn("Moisture is acceptable", streamed_text)
|
|
|
|
irrigation_recommend_response = self.client.post(
|
|
"/api/irrigation/recommend/",
|
|
data={
|
|
"farm_uuid": str(self.farm_uuid),
|
|
"plant_name": "Tomato",
|
|
"growth_stage": "flowering",
|
|
"irrigation_method_name": "Analytics Drip",
|
|
},
|
|
format="json",
|
|
)
|
|
self.assertEqual(irrigation_recommend_response.status_code, 200)
|
|
self.assertEqual(
|
|
irrigation_recommend_response.json()["data"]["sections"][0]["type"],
|
|
"recommendation",
|
|
)
|
|
|
|
fertilization_recommend_response = self.client.post(
|
|
"/api/fertilization/recommend/",
|
|
data={
|
|
"farm_uuid": str(self.farm_uuid),
|
|
"plant_name": "Tomato",
|
|
"growth_stage": "flowering",
|
|
},
|
|
format="json",
|
|
)
|
|
self.assertEqual(fertilization_recommend_response.status_code, 200)
|
|
self.assertEqual(
|
|
fertilization_recommend_response.json()["data"]["sections"][0]["fertilizerType"],
|
|
"15-5-30",
|
|
)
|
|
|
|
pest_detect_response = self.client.post(
|
|
"/api/pest-disease/detect/",
|
|
data={
|
|
"farm_uuid": str(self.farm_uuid),
|
|
"plant_name": "Tomato",
|
|
"query": "Check leaf condition",
|
|
"image_urls": ["https://example.com/leaf.jpg"],
|
|
},
|
|
format="json",
|
|
)
|
|
self.assertEqual(pest_detect_response.status_code, 200)
|
|
self.assertEqual(pest_detect_response.json()["data"]["confidence"], 0.81)
|
|
|
|
pest_risk_response = self.client.post(
|
|
"/api/pest-disease/risk/",
|
|
data={
|
|
"farm_uuid": str(self.farm_uuid),
|
|
"plant_name": "Tomato",
|
|
"growth_stage": "flowering",
|
|
},
|
|
format="json",
|
|
)
|
|
self.assertEqual(pest_risk_response.status_code, 200)
|
|
|
|
def test_alert_and_crop_simulation_endpoints_persist_records(self) -> None:
|
|
def tracker_stub(*, farm_uuid: str, query: str | None = None):
|
|
from farm_alerts.services import _save_notifications, _serialize_notification
|
|
|
|
saved = _save_notifications(
|
|
farm_uuid=str(farm_uuid),
|
|
endpoint=FarmAlertNotification.ENDPOINT_TRACKER,
|
|
notifications=[
|
|
{
|
|
"level": FarmAlertNotification.LEVEL_WARNING,
|
|
"title": "Moisture drift",
|
|
"message": "Moisture dropped below the target band.",
|
|
"suggested_action": "Review the next irrigation cycle.",
|
|
"source_alert_id": "soil-moisture:1",
|
|
"source_metric_type": "soil_moisture",
|
|
}
|
|
],
|
|
)
|
|
return {
|
|
"headline": "Tracker result",
|
|
"overview": "One warning was recorded.",
|
|
"status_level": "warning",
|
|
"query": query,
|
|
"notifications": [_serialize_notification(item) for item in saved],
|
|
}
|
|
|
|
def timeline_stub(*, farm_uuid: str, query: str | None = None):
|
|
from farm_alerts.services import _save_notifications, _serialize_notification
|
|
|
|
saved = _save_notifications(
|
|
farm_uuid=str(farm_uuid),
|
|
endpoint=FarmAlertNotification.ENDPOINT_TIMELINE,
|
|
notifications=[
|
|
{
|
|
"level": FarmAlertNotification.LEVEL_INFO,
|
|
"title": "Irrigation completed",
|
|
"message": "The latest drip cycle finished successfully.",
|
|
"suggested_action": "Recheck moisture after sunrise.",
|
|
"source_alert_id": "irrigation:1",
|
|
"source_metric_type": "irrigation",
|
|
}
|
|
],
|
|
)
|
|
return {
|
|
"headline": "Timeline result",
|
|
"overview": "One event was stored.",
|
|
"query": query,
|
|
"timeline": [
|
|
{
|
|
"timestamp": "2026-04-10T05:30:00Z",
|
|
"level": "info",
|
|
"title": "Irrigation completed",
|
|
"description": "Cycle finished.",
|
|
"source_alert_id": "irrigation:1",
|
|
"source_metric_type": "irrigation",
|
|
}
|
|
],
|
|
"notifications": [_serialize_notification(item) for item in saved],
|
|
}
|
|
|
|
with patch("farm_alerts.views.get_farm_alerts_tracker", side_effect=tracker_stub):
|
|
tracker_response = self.client.post(
|
|
"/api/farm-alerts/tracker/",
|
|
data={"farm_uuid": str(self.farm_uuid), "query": "status"},
|
|
format="json",
|
|
)
|
|
self.assertEqual(tracker_response.status_code, 200)
|
|
|
|
with patch("farm_alerts.views.get_farm_alerts_timeline", side_effect=timeline_stub):
|
|
timeline_response = self.client.post(
|
|
"/api/farm-alerts/timeline/",
|
|
data={"farm_uuid": str(self.farm_uuid)},
|
|
format="json",
|
|
)
|
|
self.assertEqual(timeline_response.status_code, 200)
|
|
self.assertEqual(
|
|
FarmAlertNotification.objects.filter(farm_uuid=self.farm_uuid).count(),
|
|
2,
|
|
)
|
|
|
|
current_chart_service = SimpleNamespace(
|
|
simulate=lambda **_kwargs: {
|
|
"farm_uuid": str(self.farm_uuid),
|
|
"plant_name": "Tomato",
|
|
"engine": "stub",
|
|
"model_name": "integration-model",
|
|
"scenario_id": None,
|
|
"simulation_warning": "",
|
|
"categories": ["day1", "day2"],
|
|
"series": [{"name": "LAI", "data": [0.8, 1.1]}],
|
|
"summary": {"expectedTrend": "up"},
|
|
"current_state": {"soilMoisture": 46.0},
|
|
"metrics": {"yieldEstimate": 8.2},
|
|
"daily_output": [{"day": "2026-04-10", "lai": 0.8}],
|
|
}
|
|
)
|
|
harvest_service = SimpleNamespace(
|
|
get_harvest_prediction=lambda **_kwargs: {
|
|
"date": "2026-07-15",
|
|
"dateFormatted": "15 Jul 2026",
|
|
"daysUntil": 96,
|
|
"description": "Expected harvest window",
|
|
"optimalWindowStart": "2026-07-10",
|
|
"optimalWindowEnd": "2026-07-20",
|
|
"gddDetails": {"current": 420, "target": 1220},
|
|
}
|
|
)
|
|
yield_service = SimpleNamespace(
|
|
get_yield_prediction=lambda **_kwargs: {
|
|
"farm_uuid": str(self.farm_uuid),
|
|
"plant_name": "Tomato",
|
|
"predictedYieldTons": 8.4,
|
|
"predictedYieldRaw": 8400.0,
|
|
"unit": "t/ha",
|
|
"sourceUnit": "kg/ha",
|
|
"simulationEngine": "stub",
|
|
"simulationModel": "integration-model",
|
|
"scenarioId": None,
|
|
"simulationWarning": "",
|
|
"supportingMetrics": {"biomass": 12.1},
|
|
}
|
|
)
|
|
crop_simulation_app = apps.get_app_config("crop_simulation")
|
|
with (
|
|
patch.object(
|
|
crop_simulation_app,
|
|
"get_current_farm_chart_simulator",
|
|
return_value=current_chart_service,
|
|
),
|
|
patch.object(
|
|
crop_simulation_app,
|
|
"get_harvest_prediction_service",
|
|
return_value=harvest_service,
|
|
),
|
|
patch.object(
|
|
crop_simulation_app,
|
|
"get_yield_prediction_service",
|
|
return_value=yield_service,
|
|
),
|
|
):
|
|
current_chart_response = self.client.post(
|
|
"/api/crop-simulation/current-farm-chart/",
|
|
data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"},
|
|
format="json",
|
|
)
|
|
harvest_response = self.client.post(
|
|
"/api/crop-simulation/harvest-prediction/",
|
|
data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"},
|
|
format="json",
|
|
)
|
|
yield_response = self.client.post(
|
|
"/api/crop-simulation/yield-prediction/",
|
|
data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"},
|
|
format="json",
|
|
)
|
|
self.assertEqual(current_chart_response.status_code, 200)
|
|
self.assertEqual(harvest_response.status_code, 200)
|
|
self.assertEqual(yield_response.status_code, 200)
|
|
|
|
task_state: dict[str, object] = {}
|
|
|
|
def growth_delay_stub(payload):
|
|
serializable_payload = {
|
|
**payload,
|
|
"farm_uuid": str(payload["farm_uuid"]) if payload.get("farm_uuid") else None,
|
|
}
|
|
scenario = SimulationScenario.objects.create(
|
|
name=f"integration-{payload['plant_name']}",
|
|
scenario_type=SimulationScenario.ScenarioType.SINGLE,
|
|
status=SimulationScenario.Status.SUCCESS,
|
|
input_payload=serializable_payload,
|
|
result_payload={"engine": "stub"},
|
|
)
|
|
SimulationRun.objects.create(
|
|
scenario=scenario,
|
|
run_key="primary",
|
|
label=payload["plant_name"],
|
|
status=SimulationScenario.Status.SUCCESS,
|
|
weather_payload=payload.get("weather", []),
|
|
soil_payload=payload.get("soil_parameters", {}),
|
|
result_payload={"summary": "ok"},
|
|
)
|
|
task_id = f"growth-task-{scenario.id}"
|
|
task_state["task_id"] = task_id
|
|
task_state["result"] = {
|
|
"plant_name": payload["plant_name"],
|
|
"dynamic_parameters": payload["dynamic_parameters"],
|
|
"engine": "stub",
|
|
"model_name": "integration-model",
|
|
"scenario_id": scenario.id,
|
|
"simulation_warning": "",
|
|
"summary_metrics": {"yield_estimate": 8.4},
|
|
"stage_timeline": [
|
|
{
|
|
"order": 1,
|
|
"stage_code": "VEG",
|
|
"stage_name": "Vegetative",
|
|
"start_date": "2026-04-10",
|
|
"end_date": "2026-05-05",
|
|
"days_count": 25,
|
|
"metrics": {"LAI": {"start": 0.8, "end": 1.5, "min": 0.8, "max": 1.5, "avg": 1.15}},
|
|
},
|
|
{
|
|
"order": 2,
|
|
"stage_code": "FLOW",
|
|
"stage_name": "Flowering",
|
|
"start_date": "2026-05-06",
|
|
"end_date": "2026-06-01",
|
|
"days_count": 26,
|
|
"metrics": {"LAI": {"start": 1.6, "end": 2.2, "min": 1.6, "max": 2.2, "avg": 1.9}},
|
|
},
|
|
],
|
|
"daily_records_count": 51,
|
|
"default_page_size": payload.get("page_size", 2),
|
|
}
|
|
return SimpleNamespace(id=task_id)
|
|
|
|
with patch("crop_simulation.views.run_growth_simulation_task.delay", side_effect=growth_delay_stub):
|
|
growth_response = self.client.post(
|
|
"/api/crop-simulation/growth/",
|
|
data={
|
|
"plant_name": "Tomato",
|
|
"dynamic_parameters": ["DVS", "LAI"],
|
|
"farm_uuid": str(self.farm_uuid),
|
|
"page_size": 1,
|
|
},
|
|
format="json",
|
|
)
|
|
self.assertEqual(growth_response.status_code, 202)
|
|
|
|
with patch(
|
|
"crop_simulation.views._get_async_result",
|
|
return_value=FakeAsyncResult(state="SUCCESS", result=task_state["result"]),
|
|
):
|
|
growth_status_response = self.client.get(
|
|
f"/api/crop-simulation/growth/{task_state['task_id']}/status/?page=1&page_size=1"
|
|
)
|
|
self.assertEqual(growth_status_response.status_code, 200)
|
|
self.assertEqual(
|
|
SimulationScenario.objects.filter(name="integration-Tomato").count(),
|
|
1,
|
|
)
|
|
self.assertEqual(SimulationRun.objects.count(), 1)
|
|
self.assertEqual(
|
|
growth_status_response.json()["data"]["result"]["pagination"]["page_size"],
|
|
1,
|
|
)
|