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, )