from datetime import date, timedelta from types import SimpleNamespace from unittest.mock import patch from django.test import TestCase, override_settings from django.utils import timezone from rest_framework.test import APIClient from farm_data.models import SensorData from location_data.data_driven_subdivision import DEFAULT_CLUSTER_FEATURES from location_data.models import ( AnalysisGridCell, AnalysisGridObservation, RemoteSensingClusterBlock, BlockSubdivision, RemoteSensingClusterAssignment, RemoteSensingRun, RemoteSensingSubdivisionResult, SoilLocation, ) @override_settings(ROOT_URLCONF="location_data.urls") class RemoteSensingApiTests(TestCase): def setUp(self): self.client = APIClient() self.boundary = { "type": "Polygon", "coordinates": [ [ [51.3890, 35.6890], [51.3900, 35.6890], [51.3900, 35.6900], [51.3890, 35.6900], [51.3890, 35.6890], ] ], } self.location = SoilLocation.objects.create( latitude="35.689200", longitude="51.389000", farm_boundary=self.boundary, ) self.location.set_input_block_count(1) self.location.save(update_fields=["input_block_count", "block_layout", "updated_at"]) self.farm = SensorData.objects.create( farm_uuid="11111111-1111-1111-1111-111111111111", center_location=self.location, sensor_payload={}, ) self.temporal_end = timezone.localdate() - timedelta(days=1) self.temporal_start = self.temporal_end - timedelta(days=30) self.subdivision = BlockSubdivision.objects.create( soil_location=self.location, block_code="block-1", source_boundary=self.boundary, chunk_size_sqm=900, status="created", ) def test_post_remote_sensing_returns_404_when_location_missing(self): response = self.client.post( "/remote-sensing/", data={ "farm_uuid": "22222222-2222-2222-2222-222222222222", }, format="json", ) self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["msg"], "location پیدا نشد.") @patch("location_data.views.run_remote_sensing_analysis_task.delay") def test_post_remote_sensing_enqueues_task_and_returns_processing(self, mock_delay): mock_delay.return_value = SimpleNamespace(id="e723ba3e-c53c-401b-b3a0-5f7013c7b401") response = self.client.post( "/remote-sensing/", data={ "farm_uuid": str(self.farm.farm_uuid), "force_refresh": False, }, format="json", ) self.assertEqual(response.status_code, 202) payload = response.json()["data"] self.assertEqual(payload["status"], "processing") self.assertEqual(payload["source"], "processing") self.assertEqual(payload["task_id"], "e723ba3e-c53c-401b-b3a0-5f7013c7b401") self.assertEqual(payload["block_code"], "") self.assertEqual(payload["summary"]["cell_count"], 0) run = RemoteSensingRun.objects.get(id=payload["run"]["id"]) self.assertEqual(run.block_code, "") self.assertEqual(run.temporal_start, self.temporal_start) self.assertEqual(run.temporal_end, self.temporal_end) self.assertEqual(run.status, RemoteSensingRun.STATUS_PENDING) self.assertEqual(run.metadata["stage"], "queued") self.assertEqual(run.metadata["selected_features"], DEFAULT_CLUSTER_FEATURES) mock_delay.assert_called_once() def test_get_remote_sensing_returns_processing_when_run_exists_without_results(self): RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_RUNNING, metadata={"task_id": "e723ba3e-c53c-401b-b3a0-5f7013c7b401"}, ) response = self.client.get( "/remote-sensing/", data={ "farm_uuid": str(self.farm.farm_uuid), }, ) self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "processing") self.assertEqual(payload["source"], "processing") self.assertEqual(payload["cells"], []) self.assertEqual(payload["run"]["status"], RemoteSensingRun.STATUS_RUNNING) def test_get_remote_sensing_returns_cached_results(self): run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_SUCCESS, ) cell = AnalysisGridCell.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", cell_code="cell-1", chunk_size_sqm=900, geometry=self.boundary, centroid_lat="35.689500", centroid_lon="51.389500", ) AnalysisGridObservation.objects.create( cell=cell, run=run, temporal_start=self.temporal_start, temporal_end=self.temporal_end, ndvi=0.61, ndwi=0.22, soil_vv=0.13, soil_vv_db=-8.860566, dem_m=1550.0, slope_deg=4.2, metadata={"backend_name": "openeo"}, ) response = self.client.get( "/remote-sensing/", data={ "farm_uuid": str(self.farm.farm_uuid), }, ) self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "success") self.assertEqual(payload["source"], "database") self.assertEqual(payload["summary"]["cell_count"], 1) self.assertEqual(payload["summary"]["ndvi_mean"], 0.61) self.assertEqual(payload["summary"]["soil_vv_db_mean"], -8.860566) self.assertEqual(len(payload["cells"]), 1) self.assertEqual(payload["cells"][0]["cell_code"], "cell-1") @patch("location_data.views.run_remote_sensing_analysis_task.delay") def test_post_remote_sensing_reuses_latest_completed_farm_cache_when_window_differs(self, mock_delay): fallback_start = self.temporal_start - timedelta(days=1) fallback_end = self.temporal_end - timedelta(days=1) run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=fallback_start, temporal_end=fallback_end, status=RemoteSensingRun.STATUS_SUCCESS, metadata={"farm_uuid": str(self.farm.farm_uuid), "stage": "completed"}, ) cell = AnalysisGridCell.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", cell_code="cell-seeded-1", chunk_size_sqm=900, geometry=self.boundary, centroid_lat="35.689500", centroid_lon="51.389500", ) AnalysisGridObservation.objects.create( cell=cell, run=run, temporal_start=fallback_start, temporal_end=fallback_end, ndvi=0.49, ndwi=0.17, soil_vv=0.10, soil_vv_db=-9.8, metadata={"backend_name": "openeo"}, ) response = self.client.post( "/remote-sensing/", data={"farm_uuid": str(self.farm.farm_uuid), "force_refresh": False}, format="json", ) self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "success") self.assertEqual(payload["source"], "database") self.assertEqual(payload["temporal_extent"]["start_date"], fallback_start.isoformat()) self.assertEqual(payload["temporal_extent"]["end_date"], fallback_end.isoformat()) self.assertEqual(payload["metadata"]["cache_match"], "latest_completed_for_farm") self.assertEqual(payload["cells"][0]["cell_code"], "cell-seeded-1") self.assertEqual(payload["run"]["id"], run.id) self.assertNotIn("task_id", payload) mock_delay.assert_not_called() @patch("location_data.views.run_remote_sensing_analysis_task.delay") def test_post_remote_sensing_returns_cached_results_without_enqueuing(self, mock_delay): run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_SUCCESS, metadata={"farm_uuid": str(self.farm.farm_uuid), "stage": "completed"}, ) cell = AnalysisGridCell.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", cell_code="cell-cache-1", chunk_size_sqm=900, geometry=self.boundary, centroid_lat="35.689500", centroid_lon="51.389500", ) AnalysisGridObservation.objects.create( cell=cell, run=run, temporal_start=self.temporal_start, temporal_end=self.temporal_end, ndvi=0.52, ndwi=0.18, soil_vv=0.11, soil_vv_db=-9.2, metadata={"backend_name": "openeo"}, ) response = self.client.post( "/remote-sensing/", data={"farm_uuid": str(self.farm.farm_uuid), "force_refresh": False}, format="json", ) self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "success") self.assertEqual(payload["source"], "database") self.assertTrue(payload["metadata"]["cache_hit"]) self.assertEqual(payload["cells"][0]["cell_code"], "cell-cache-1") self.assertEqual(payload["run"]["id"], run.id) self.assertEqual(payload["run"]["status"], RemoteSensingRun.STATUS_SUCCESS) self.assertNotIn("task_id", payload) self.assertEqual(RemoteSensingRun.objects.count(), 1) mock_delay.assert_not_called() @patch("location_data.views.run_remote_sensing_analysis_task.delay") def test_post_remote_sensing_cached_results_do_not_create_status_run(self, mock_delay): source_run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_SUCCESS, metadata={"farm_uuid": str(self.farm.farm_uuid), "stage": "completed"}, ) cell = AnalysisGridCell.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", cell_code="cell-status-cache-1", chunk_size_sqm=900, geometry=self.boundary, centroid_lat="35.689500", centroid_lon="51.389500", ) AnalysisGridObservation.objects.create( cell=cell, run=source_run, temporal_start=self.temporal_start, temporal_end=self.temporal_end, ndvi=0.57, ndwi=0.19, soil_vv=0.12, soil_vv_db=-8.7, metadata={"backend_name": "openeo"}, ) post_response = self.client.post( "/remote-sensing/", data={"farm_uuid": str(self.farm.farm_uuid), "force_refresh": False}, format="json", ) self.assertEqual(post_response.status_code, 200) payload = post_response.json()["data"] self.assertEqual(payload["status"], "success") self.assertEqual(payload["run"]["id"], source_run.id) self.assertEqual(payload["summary"]["cell_count"], 1) self.assertEqual(payload["cells"][0]["cell_code"], "cell-status-cache-1") self.assertNotIn("task_id", payload) self.assertEqual(RemoteSensingRun.objects.count(), 1) mock_delay.assert_not_called() @patch("location_data.views.run_remote_sensing_analysis_task.delay") def test_post_remote_sensing_returns_existing_processing_run_without_enqueuing(self, mock_delay): run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_PENDING, metadata={ "farm_uuid": str(self.farm.farm_uuid), "task_id": "e723ba3e-c53c-401b-b3a0-5f7013c7b401", "stage": "queued", }, ) response = self.client.post( "/remote-sensing/", data={"farm_uuid": str(self.farm.farm_uuid), "force_refresh": False}, format="json", ) self.assertEqual(response.status_code, 202) payload = response.json()["data"] self.assertEqual(payload["status"], "processing") self.assertEqual(payload["source"], "processing") self.assertEqual(payload["run"]["id"], run.id) mock_delay.assert_not_called() @patch("location_data.views.run_remote_sensing_analysis_task.delay") def test_post_remote_sensing_ignores_other_farm_cache_on_same_location(self, mock_delay): other_farm_uuid = "33333333-3333-3333-3333-333333333333" mock_delay.return_value = SimpleNamespace(id="f723ba3e-c53c-401b-b3a0-5f7013c7b402") other_run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_SUCCESS, metadata={"farm_uuid": other_farm_uuid, "stage": "completed"}, ) other_cell = AnalysisGridCell.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", cell_code="cell-other-farm", chunk_size_sqm=900, geometry=self.boundary, centroid_lat="35.689510", centroid_lon="51.389510", ) AnalysisGridObservation.objects.create( cell=other_cell, run=other_run, temporal_start=self.temporal_start, temporal_end=self.temporal_end, ndvi=0.66, ndwi=0.31, soil_vv=0.15, soil_vv_db=-8.1, metadata={"backend_name": "openeo"}, ) response = self.client.post( "/remote-sensing/", data={"farm_uuid": str(self.farm.farm_uuid), "force_refresh": False}, format="json", ) self.assertEqual(response.status_code, 202) payload = response.json()["data"] self.assertEqual(payload["status"], "processing") self.assertEqual(RemoteSensingRun.objects.count(), 2) self.assertNotEqual(payload["run"]["id"], other_run.id) mock_delay.assert_called_once() def test_run_status_endpoint_returns_normalized_status(self): run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_SUCCESS, metadata={"stage": "completed", "selected_features": ["ndvi"]}, ) task_id = "e723ba3e-c53c-401b-b3a0-5f7013c7b401" run.metadata = {**run.metadata, "task_id": task_id} run.save(update_fields=["metadata", "updated_at"]) response = self.client.get(f"/remote-sensing/runs/{task_id}/status/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "completed") self.assertEqual(payload["run"]["pipeline_status"], "completed") self.assertEqual(payload["run"]["stage"], "completed") self.assertEqual(payload["run"]["selected_features"], ["ndvi"]) @patch("location_data.views._get_remote_sensing_async_result") def test_run_status_endpoint_returns_detailed_task_progress(self, mock_async_result): mock_async_result.return_value = SimpleNamespace( state="STARTED", result=None, info={"message": "fetching_remote_metrics"}, ready=lambda: False, successful=lambda: False, failed=lambda: False, ) task_id = "e723ba3e-c53c-401b-b3a0-5f7013c7b401" run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_RUNNING, metadata={ "task_id": task_id, "stage": "fetching_remote_metrics", "selected_features": ["ndvi", "ndwi"], "timestamps": { "queued_at": "2026-05-10T08:00:00Z", "started_at": "2026-05-10T08:00:03Z", "fetching_remote_metrics_at": "2026-05-10T08:00:12Z", }, "stage_details": { "fetching_remote_metrics": { "requested_cell_count": 2, "metric_progress": { "total_metrics": 2, "completed_metric_count": 1, "active_metric": "ndwi", "completed_metrics": ["ndvi"], "failed_metrics": [], "states": [ {"metric": "ndvi", "status": "completed"}, {"metric": "ndwi", "status": "running"}, ], }, } }, }, ) response = self.client.get(f"/remote-sensing/runs/{task_id}/status/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "running") self.assertEqual(payload["task"]["current_stage"], "fetching_remote_metrics") self.assertEqual(payload["task"]["metric_progress"]["active_metric"], "ndwi") self.assertEqual(payload["task"]["stages"][-1]["status"], "running") self.assertEqual(payload["task"]["celery"]["state"], "STARTED") self.assertEqual(payload["task"]["celery"]["info"]["message"], "fetching_remote_metrics") self.assertEqual(payload["run"]["id"], run.id) @patch("location_data.views._get_remote_sensing_async_result") def test_run_status_endpoint_returns_retrying_status_when_celery_is_retrying(self, mock_async_result): mock_async_result.return_value = SimpleNamespace( state="RETRY", result="temporary openEO timeout", info="temporary openEO timeout", ready=lambda: False, successful=lambda: False, failed=lambda: False, ) task_id = "e723ba3e-c53c-401b-b3a0-5f7013c7b401" run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_RUNNING, metadata={ "task_id": task_id, "stage": "retrying", "status_label": "retrying", "failed_stage": "observations_persisted", "timestamps": { "failed_at": "2026-05-10T08:10:00Z", "retrying_at": "2026-05-10T08:11:00Z", }, "stage_details": { "retrying": { "retry_count": 2, "retry_delay_seconds": 120, "last_error": "temporary openEO timeout", "failed_stage": "observations_persisted", "failed_stage_details": {"created_count": 12, "updated_count": 0}, } }, }, ) response = self.client.get(f"/remote-sensing/runs/{task_id}/status/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "retrying") self.assertEqual(payload["run"]["pipeline_status"], "retrying") self.assertEqual(payload["task"]["current_stage"], "retrying") self.assertEqual(payload["task"]["retry"]["retry_count"], 2) self.assertEqual(payload["task"]["last_error"], "temporary openEO timeout") self.assertNotIn("failure_reason", payload["task"]) self.assertEqual(payload["task"]["celery"]["state"], "RETRY") @patch("location_data.views._get_remote_sensing_async_result") def test_run_status_endpoint_overrides_stale_failed_db_state_when_celery_is_retrying(self, mock_async_result): mock_async_result.return_value = SimpleNamespace( state="RETRY", result="temporary openEO timeout", info="temporary openEO timeout", ready=lambda: False, successful=lambda: False, failed=lambda: False, ) task_id = "e723ba3e-c53c-401b-b3a0-5f7013c7b401" run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_FAILURE, error_message="temporary openEO timeout", metadata={ "task_id": task_id, "stage": "failed", "status_label": "failed", "failed_stage": "observations_persisted", "failure_reason": "temporary openEO timeout", "timestamps": {"failed_at": "2026-05-10T08:10:00Z"}, "stage_details": { "failed": { "failed_stage": "observations_persisted", "error_message": "temporary openEO timeout", "failed_stage_details": {"created_count": 12, "updated_count": 0}, } }, }, ) response = self.client.get(f"/remote-sensing/runs/{task_id}/status/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "retrying") self.assertEqual(payload["run"]["status"], RemoteSensingRun.STATUS_FAILURE) self.assertEqual(payload["run"]["status_label"], "retrying") self.assertEqual(payload["run"]["pipeline_status"], "retrying") self.assertEqual(payload["run"]["stage"], "retrying") self.assertEqual(payload["task"]["current_stage"], "retrying") self.assertEqual(payload["task"]["retry"]["failed_stage"], "observations_persisted") self.assertEqual(payload["task"]["stages"][-1]["name"], "retrying") self.assertEqual(payload["task"]["stages"][-1]["status"], "running") self.assertNotIn("failure_reason", payload["task"]) self.assertEqual(payload["task"]["celery"]["state"], "RETRY") self.assertEqual(payload["run"]["id"], run.id) def test_run_status_endpoint_returns_failed_task_details(self): task_id = "e723ba3e-c53c-401b-b3a0-5f7013c7b401" run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_FAILURE, error_message="openEO timeout", metadata={ "task_id": task_id, "stage": "failed", "failed_stage": "observations_persisted", "failure_reason": "openEO timeout", "timestamps": {"failed_at": "2026-05-10T08:10:00Z"}, "stage_details": { "failed": { "failed_stage": "observations_persisted", "error_message": "openEO timeout", "failed_stage_details": {"created_count": 12, "updated_count": 0}, } }, }, ) response = self.client.get(f"/remote-sensing/runs/{task_id}/status/") self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "failed") self.assertEqual(payload["task"]["current_stage"], "failed") self.assertEqual(payload["task"]["failed_stage"], "observations_persisted") self.assertEqual(payload["task"]["failure_reason"], "openEO timeout") self.assertEqual(payload["task"]["current_stage_details"]["failed_stage"], "observations_persisted") self.assertEqual(payload["task"]["stages"][-1]["status"], "failed") self.assertEqual(payload["run"]["id"], run.id) def test_run_result_endpoint_returns_paginated_assignments(self): run = RemoteSensingRun.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, status=RemoteSensingRun.STATUS_SUCCESS, metadata={"stage": "completed"}, ) cell = AnalysisGridCell.objects.create( soil_location=self.location, block_subdivision=self.subdivision, block_code="", cell_code="cell-1", chunk_size_sqm=900, geometry=self.boundary, centroid_lat="35.689500", centroid_lon="51.389500", ) AnalysisGridObservation.objects.create( cell=cell, run=run, temporal_start=self.temporal_start, temporal_end=self.temporal_end, ndvi=0.61, ndwi=0.22, soil_vv=0.13, soil_vv_db=-8.860566, dem_m=1550.0, slope_deg=4.2, metadata={"backend_name": "openeo"}, ) result = RemoteSensingSubdivisionResult.objects.create( soil_location=self.location, run=run, block_subdivision=self.subdivision, block_code="", chunk_size_sqm=900, temporal_start=self.temporal_start, temporal_end=self.temporal_end, cluster_count=1, selected_features=["ndvi"], metadata={"used_cell_count": 1, "skipped_cell_count": 0}, ) RemoteSensingClusterAssignment.objects.create( result=result, cell=cell, cluster_label=0, raw_feature_values={"ndvi": 0.61}, scaled_feature_values={"ndvi": 0.0}, ) cluster_block = RemoteSensingClusterBlock.objects.create( result=result, soil_location=self.location, block_subdivision=self.subdivision, block_code="", sub_block_code="cluster-0", cluster_label=0, chunk_size_sqm=900, centroid_lat="35.689500", centroid_lon="51.389500", cell_count=1, cell_codes=["cell-1"], geometry=self.boundary, metadata={"source": "analysis_grid_cells"}, ) task_id = "e723ba3e-c53c-401b-b3a0-5f7013c7b401" run.metadata = {**run.metadata, "task_id": task_id} run.save(update_fields=["metadata", "updated_at"]) response = self.client.get(f"/remote-sensing/runs/{task_id}/status/", data={"page": 1, "page_size": 10}) self.assertEqual(response.status_code, 200) payload = response.json()["data"] self.assertEqual(payload["status"], "completed") self.assertEqual(payload["subdivision_result"]["cluster_count"], 1) self.assertEqual(payload["subdivision_result"]["cluster_blocks"][0]["uuid"], str(cluster_block.uuid)) self.assertEqual(len(payload["subdivision_result"]["assignments"]), 1) self.assertEqual(payload["pagination"]["assignments"]["total_items"], 1)