Files
Ai/integration_tests/test_reporting_and_ai_api_flow.py
T
2026-04-26 01:15:38 +03:30

619 lines
25 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 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",
}
},
)
neighbor_sensor.plants.set([self.primary_plant["id"]])
def test_reporting_endpoints_read_from_persisted_farm_context(self) -> None:
soil_response = self.client.get(
"/api/soil-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.assertEqual(len(soil_response.json()["data"]["depths"]), 3)
queued_location = {}
def soil_delay_stub(lat: float, lon: float):
location = self.create_complete_location(lat=lat, lon=lon)
queued_location["id"] = location.id
return SimpleNamespace(id="soil-task-1")
with patch("location_data.views.fetch_soil_data_task.delay", side_effect=soil_delay_stub):
queued_response = self.client.post(
"/api/soil-data/",
data={"lat": "36.100000", "lon": "52.200000"},
format="json",
)
self.assertEqual(queued_response.status_code, 202)
with patch(
"celery.result.AsyncResult",
return_value=FakeAsyncResult(
state="SUCCESS",
result={"status": "completed", "location_id": queued_location["id"]},
),
):
soil_status_response = self.client.get("/api/soil-data/tasks/soil-task-1/status/")
self.assertEqual(soil_status_response.status_code, 200)
self.assertEqual(
soil_status_response.json()["data"]["result"]["id"],
queued_location["id"],
)
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/soil-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, 200)
self.assertEqual(
water_stress_response.json()["data"]["sourceMetric"]["engine"],
"sensor_fallback",
)
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",
},
)
)
stack.enter_context(
patch(
"pest_disease.services.build_pest_disease_risk_summary",
return_value={
"riskLevel": "medium",
"headline": "Watch humidity trend",
"items": [{"title": "Powdery mildew", "severity": "medium"}],
},
)
)
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)
pest_summary_response = self.client.post(
"/api/pest-disease/risk-summary/",
data={"farm_uuid": str(self.farm_uuid)},
format="json",
)
self.assertEqual(pest_summary_response.status_code, 200)
self.assertEqual(pest_summary_response.json()["data"]["riskLevel"], "medium")
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,
)