from datetime import date from unittest.mock import patch import uuid from django.test import TestCase from rest_framework.test import APIClient from location_data.models import SoilDepthData, SoilLocation from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter from farm_data.services import ( assign_farm_plants_from_backend_ids, get_canonical_farm_record, get_runtime_plant_for_farm, list_runtime_plants_for_farm, ) from irrigation.models import IrrigationMethod from weather.models import WeatherForecast from farm_data.services import resolve_center_location_from_boundary def square_boundary_for_center(lat: float, lon: float, delta: float = 0.01) -> dict: return { "type": "Polygon", "coordinates": [ [ [lon - delta, lat - delta], [lon + delta, lat - delta], [lon + delta, lat + delta], [lon - delta, lat + delta], [lon - delta, lat - delta], ] ], } class FarmDetailApiTests(TestCase): def setUp(self): self.client = APIClient() self.location = SoilLocation.objects.create( latitude="35.700000", longitude="51.400000", farm_boundary={"type": "Polygon", "coordinates": []}, ) SoilDepthData.objects.create( soil_location=self.location, depth_label="0-5cm", clay=22.0, nitrogen=10.0, sand=40.0, ) SoilDepthData.objects.create( soil_location=self.location, depth_label="5-15cm", clay=18.0, nitrogen=8.0, ) self.weather = WeatherForecast.objects.create( location=self.location, forecast_date=date(2026, 4, 10), temperature_min=12.0, temperature_max=23.0, temperature_mean=18.0, precipitation=1.2, humidity_mean=52.0, ) self.plant1 = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجه‌فرنگی") self.plant2 = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="خیار") self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") self.farm_uuid = uuid.uuid4() self.farm = SensorData.objects.create( farm_uuid=self.farm_uuid, center_location=self.location, weather_forecast=self.weather, irrigation_method=self.irrigation_method, sensor_payload={ "sensor-7-1": { "soil_moisture": 33.5, "nitrogen": 99.0, } }, ) assign_farm_plants_from_backend_ids(self.farm, [self.plant2.backend_plant_id, self.plant1.backend_plant_id]) def test_canonical_plant_runtime_path_uses_assignments_not_legacy_relation(self): farm = get_canonical_farm_record(str(self.farm_uuid)) self.assertIsNotNone(farm) self.assertEqual([plant.name for plant in list_runtime_plants_for_farm(farm)], ["خیار", "گوجه‌فرنگی"]) self.assertEqual(get_runtime_plant_for_farm(farm).name, "خیار") def test_assignment_sync_reconciles_legacy_relation_for_transition(self): self.assertEqual(list(self.farm.plants.values_list("name", flat=True)), ["خیار", "گوجه‌فرنگی"]) def test_runtime_plant_lookup_resolves_by_name_from_canonical_assignments(self): farm = get_canonical_farm_record(str(self.farm_uuid)) resolved = get_runtime_plant_for_farm(farm, plant_name="گوجه‌فرنگی") self.assertIsNotNone(resolved) self.assertEqual(resolved.name, "گوجه‌فرنگی") self.assertEqual(resolved.id, self.plant1.backend_plant_id) def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self): response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertNotIn("farm_uuid", payload) self.assertEqual(payload["center_location"]["id"], self.location.id) self.assertEqual(payload["weather"]["id"], self.weather.id) self.assertEqual( payload["sensor_payload"]["sensor-7-1"]["soil_moisture"], 33.5, ) self.assertIn("sensor_schema", payload) self.assertEqual(payload["sensor_schema"]["sensor-7-1"][0]["code"], "nitrogen") resolved_metrics = payload["soil"]["resolved_metrics"] metric_sources = payload["soil"]["metric_sources"] self.assertEqual(resolved_metrics["nitrogen"], 99.0) self.assertEqual(metric_sources["nitrogen"]["type"], "sensor") self.assertEqual(metric_sources["nitrogen"]["strategy"], "single_value") self.assertEqual(resolved_metrics["clay"], 22.0) self.assertEqual(metric_sources["clay"], "soil") self.assertEqual(len(payload["soil"]["depths"]), 2) self.assertCountEqual(payload["plant_ids"], [self.plant1.backend_plant_id, self.plant2.backend_plant_id]) self.assertEqual(len(payload["plants"]), 2) returned_plants = {item["id"]: item for item in payload["plants"]} self.assertEqual(returned_plants[self.plant1.backend_plant_id]["name"], self.plant1.name) self.assertEqual(returned_plants[self.plant2.backend_plant_id]["name"], self.plant2.name) self.assertIn("light", returned_plants[self.plant1.backend_plant_id]) self.assertEqual(len(payload["plant_assignments"]), 2) self.assertEqual(payload["irrigation_method_id"], self.irrigation_method.id) self.assertEqual(payload["irrigation_method"]["name"], self.irrigation_method.name) def test_returns_404_when_farm_is_missing(self): response = self.client.get(f"/api/farm-data/{uuid.uuid4()}/detail/") self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["msg"], "farm یافت نشد.") def test_aggregates_conflicting_metrics_from_multiple_sensors_without_overwrite(self): self.farm.sensor_payload = { "sensor-a": { "soil_moisture": 20.0, "nitrogen": 90.0, "status": "ok", }, "sensor-b": { "soil_moisture": 40.0, "nitrogen": 110.0, "status": "needs-check", }, } self.farm.save(update_fields=["sensor_payload"]) response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] resolved_metrics = payload["soil"]["resolved_metrics"] metric_sources = payload["soil"]["metric_sources"] self.assertEqual(resolved_metrics["soil_moisture"], 30.0) self.assertEqual(metric_sources["soil_moisture"]["strategy"], "average") self.assertCountEqual( metric_sources["soil_moisture"]["sensor_keys"], ["sensor-a", "sensor-b"], ) self.assertEqual(metric_sources["soil_moisture"]["distinct_values"], [20.0, 40.0]) self.assertEqual(resolved_metrics["status"], ["ok", "needs-check"]) self.assertEqual(metric_sources["status"]["strategy"], "distinct_values") def test_detail_auto_registers_unknown_sensor_parameters(self): self.farm.sensor_payload = { "leaf-sensor": { "leaf_wetness": 11.0, "leaf_temperature": 19.8, } } self.farm.save(update_fields=["sensor_payload"]) response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] leaf_schema = payload["sensor_schema"]["leaf-sensor"] self.assertCountEqual( [item["code"] for item in leaf_schema], ["leaf_temperature", "leaf_wetness"], ) self.assertTrue( SensorParameter.objects.filter(sensor_key="leaf-sensor", code="leaf_wetness").exists() ) class FarmDataUpsertApiTests(TestCase): def setUp(self): self.client = APIClient() self.location = SoilLocation.objects.create( latitude="35.710000", longitude="51.410000", ) SoilDepthData.objects.create( soil_location=self.location, depth_label="0-5cm", clay=20.0, ) SoilDepthData.objects.create( soil_location=self.location, depth_label="5-15cm", clay=18.0, ) SoilDepthData.objects.create( soil_location=self.location, depth_label="15-30cm", clay=16.0, ) self.boundary = square_boundary_for_center(35.71, 51.41) self.weather = WeatherForecast.objects.create( location=self.location, forecast_date=date(2026, 4, 11), temperature_min=11.0, temperature_max=24.0, temperature_mean=17.5, ) self.irrigation_method = IrrigationMethod.objects.create(name="بارانی") def test_post_creates_farm_data_with_explicit_farm_uuid(self): farm_uuid = uuid.uuid4() response = self.client.post( "/api/farm-data/", data={ "farm_uuid": str(farm_uuid), "farm_boundary": self.boundary, "sensor_payload": { "sensor-7-1": { "soil_moisture": 31.2, "nitrogen": 18.0, } }, "irrigation_method_id": self.irrigation_method.id, }, format="json", ) self.assertEqual(response.status_code, 201) self.assertEqual(response.json()["data"]["farm_uuid"], str(farm_uuid)) self.assertEqual(response.json()["data"]["center_location_id"], self.location.id) self.assertEqual(response.json()["data"]["weather_forecast_id"], self.weather.id) farm = SensorData.objects.get(farm_uuid=farm_uuid) self.assertEqual(farm.center_location_id, self.location.id) self.assertEqual(farm.weather_forecast_id, self.weather.id) self.assertEqual(farm.irrigation_method_id, self.irrigation_method.id) self.assertEqual( farm.sensor_payload["sensor-7-1"]["soil_moisture"], 31.2, ) self.assertTrue( SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists() ) def test_post_auto_registers_new_sensor_without_manual_parameter_creation(self): farm_uuid = uuid.uuid4() response = self.client.post( "/api/farm-data/", data={ "farm_uuid": str(farm_uuid), "farm_boundary": self.boundary, "sensor_payload": { "canopy-sensor-v2": { "leaf_wetness": 12.4, "leaf_temperature": 21.6, "disease_pressure_index": 0.41, } }, }, format="json", ) self.assertEqual(response.status_code, 201) self.assertTrue( SensorParameter.objects.filter( sensor_key="canopy-sensor-v2", code="leaf_wetness", ).exists() ) detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/") self.assertEqual(detail_response.status_code, 200) schema = detail_response.json()["data"]["sensor_schema"]["canopy-sensor-v2"] self.assertCountEqual( [item["code"] for item in schema], ["disease_pressure_index", "leaf_temperature", "leaf_wetness"], ) def test_post_requires_farm_uuid_in_request_body(self): response = self.client.post( "/api/farm-data/", data={ "farm_boundary": self.boundary, "sensor_payload": {"sensor-7-1": {"soil_moisture": 31.2}}, }, format="json", ) self.assertEqual(response.status_code, 400) self.assertIn("farm_uuid", response.json()["data"]) @patch("farm_data.services.update_weather_for_location", return_value={"status": "no_data"}) @patch( "farm_data.services.fetch_soil_data_for_coordinates", return_value={"status": "completed", "depths": []}, ) def test_post_creates_center_location_from_boundary_when_missing( self, _mock_fetch_soil_data_for_coordinates, _mock_update_weather_for_location, ): farm_uuid = uuid.uuid4() response = self.client.post( "/api/farm-data/", data={ "farm_uuid": str(farm_uuid), "farm_boundary": { "corners": [ {"lat": 50.0, "lon": 50.0}, {"lat": 50.0, "lon": 50.02}, {"lat": 50.02, "lon": 50.02}, {"lat": 50.02, "lon": 50.0}, ] }, "sensor_payload": {"sensor-7-1": {"soil_moisture": 40.0}}, }, format="json", ) self.assertEqual(response.status_code, 201) farm = SensorData.objects.get(farm_uuid=farm_uuid) self.assertIsNotNone(farm.center_location_id) self.assertEqual(str(farm.center_location.latitude), "50.010000") self.assertEqual(str(farm.center_location.longitude), "50.010000") self.assertIsNone(farm.weather_forecast_id) def test_resolve_center_location_uses_geometric_centroid_for_concave_polygon(self): location = resolve_center_location_from_boundary( { "corners": [ {"lat": 0.0, "lon": 0.0}, {"lat": 0.0, "lon": 4.0}, {"lat": 4.0, "lon": 4.0}, {"lat": 4.0, "lon": 0.0}, {"lat": 1.0, "lon": 0.0}, {"lat": 1.0, "lon": 3.0}, {"lat": 3.0, "lon": 3.0}, {"lat": 3.0, "lon": 1.0}, {"lat": 0.0, "lon": 1.0}, ] } ) self.assertEqual(str(location.latitude), "2.078947") self.assertEqual(str(location.longitude), "2.078947") @patch("farm_data.services.update_weather_for_location") @patch("farm_data.services.fetch_soil_data_for_coordinates") def test_post_fetches_missing_location_and_weather_data( self, mock_fetch_soil_data_for_coordinates, mock_update_weather_for_location, ): missing_boundary = square_boundary_for_center(36.0, 52.0) farm_uuid = uuid.uuid4() def soil_side_effect(latitude, longitude, task_id="", progress_callback=None): location = SoilLocation.objects.get( latitude="36.000000", longitude="52.000000", ) SoilDepthData.objects.update_or_create( soil_location=location, depth_label="0-5cm", defaults={"clay": 20.0}, ) SoilDepthData.objects.update_or_create( soil_location=location, depth_label="5-15cm", defaults={"clay": 18.0}, ) SoilDepthData.objects.update_or_create( soil_location=location, depth_label="15-30cm", defaults={"clay": 16.0}, ) return {"status": "completed", "location_id": location.id, "depths": ["0-5cm", "5-15cm", "15-30cm"]} def weather_side_effect(location): WeatherForecast.objects.update_or_create( location=location, forecast_date=date(2026, 4, 12), defaults={ "temperature_min": 10.0, "temperature_max": 20.0, "temperature_mean": 15.0, }, ) return {"status": "success", "location_id": location.id, "days_updated": 1} mock_fetch_soil_data_for_coordinates.side_effect = soil_side_effect mock_update_weather_for_location.side_effect = weather_side_effect response = self.client.post( "/api/farm-data/", data={ "farm_uuid": str(farm_uuid), "farm_boundary": missing_boundary, "sensor_payload": {"sensor-7-1": {"soil_moisture": 44.0}}, }, format="json", ) self.assertEqual(response.status_code, 201) mock_fetch_soil_data_for_coordinates.assert_called_once() mock_update_weather_for_location.assert_called_once() farm = SensorData.objects.get(farm_uuid=farm_uuid) self.assertEqual(farm.center_location.depths.count(), 3) self.assertIsNotNone(farm.weather_forecast_id)