From 45fee1dfd38d51f4f306974286f8a30fe2a990d6 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Wed, 13 May 2026 22:28:56 +0330 Subject: [PATCH] UPDATE --- config/urls.py | 1 - crop_simulation/growth_simulation.py | 12 +- crop_simulation/test_growth_simulation_api.py | 5 +- crop_simulation/tests.py | 3 +- docker-compose-prod.yaml | 2 + docker-compose.yaml | 2 + docs/location_data_api_responses_fa.md | 1031 +++++++++++++++++ docs/plant_service_relation.md | 291 +++++ entrypoint.sh | 53 +- farm_data/services.py | 14 +- farm_data/tests/test_farm_detail_api.py | 7 +- integration_tests/base.py | 33 +- integration_tests/test_management_api_flow.py | 110 +- .../test_reporting_and_ai_api_flow.py | 3 +- location_data/cluster_recommendation.py | 103 +- location_data/test_cluster_block_live_api.py | 59 + .../test_cluster_recommendation_api.py | 34 + location_data/test_remote_sensing_api.py | 237 +++- location_data/views.py | 536 +++++++-- plant/PLANT_NAMES_API.md | 74 -- plant/apps.py | 11 +- plant/management/commands/seed_plants.py | 109 -- plant/serializers.py | 64 - plant/services.py | 34 - plant/urls.py | 15 - plant/views.py | 364 ------ 26 files changed, 2329 insertions(+), 878 deletions(-) create mode 100644 docs/location_data_api_responses_fa.md create mode 100644 docs/plant_service_relation.md delete mode 100644 plant/PLANT_NAMES_API.md delete mode 100644 plant/management/commands/seed_plants.py delete mode 100644 plant/serializers.py delete mode 100644 plant/services.py delete mode 100644 plant/urls.py delete mode 100644 plant/views.py diff --git a/config/urls.py b/config/urls.py index c9ee772..7806c36 100644 --- a/config/urls.py +++ b/config/urls.py @@ -15,7 +15,6 @@ urlpatterns = [ path("api/farm-data/", include("farm_data.urls")), path("api/weather/", include("weather.urls")), path("api/economy/", include("economy.urls")), - path("api/plants/", include("plant.urls")), path("api/pest-disease/", include("pest_disease.urls")), path("api/irrigation/", include("irrigation.urls")), path("api/fertilization/", include("fertilization.urls")), diff --git a/crop_simulation/growth_simulation.py b/crop_simulation/growth_simulation.py index aab19ce..25177f1 100644 --- a/crop_simulation/growth_simulation.py +++ b/crop_simulation/growth_simulation.py @@ -10,8 +10,8 @@ import logging from django.apps import apps from django.core.paginator import EmptyPage, Paginator -from farm_data.models import SensorData -from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm +from farm_data.models import PlantCatalogSnapshot, SensorData +from farm_data.services import clone_snapshot_as_runtime_plant, get_canonical_farm_record, get_runtime_plant_for_farm from location_data.satellite_snapshot import build_location_satellite_snapshot from plant.gdd import calculate_daily_gdd, resolve_growth_profile from weather.models import WeatherForecast @@ -277,9 +277,11 @@ def _resolve_plant_simulation_defaults(plant: Any) -> tuple[dict[str, Any] | Non def build_growth_context(payload: dict[str, Any]) -> GrowthSimulationContext: plant_name = apps.get_app_config("plant").resolve_plant_name(payload["plant_name"]) or payload["plant_name"] - from plant.models import Plant - - plant = Plant.objects.filter(name=plant_name).first() + snapshot = ( + PlantCatalogSnapshot.objects.filter(name=plant_name).first() + or PlantCatalogSnapshot.objects.filter(name__iexact=plant_name).first() + ) + plant = clone_snapshot_as_runtime_plant(snapshot) if plant is None: raise GrowthSimulationError("Plant not found.") diff --git a/crop_simulation/test_growth_simulation_api.py b/crop_simulation/test_growth_simulation_api.py index 5068057..559a62a 100644 --- a/crop_simulation/test_growth_simulation_api.py +++ b/crop_simulation/test_growth_simulation_api.py @@ -6,7 +6,7 @@ from unittest.mock import patch from django.test import TestCase, override_settings from rest_framework.test import APIClient -from plant.models import Plant +from farm_data.models import PlantCatalogSnapshot from .growth_simulation import paginate_growth_stages, run_growth_simulation @@ -15,7 +15,8 @@ from .growth_simulation import paginate_growth_stages, run_growth_simulation class PlantGrowthSimulationApiTests(TestCase): def setUp(self): self.client = APIClient() - self.plant = Plant.objects.create( + self.plant = PlantCatalogSnapshot.objects.create( + backend_plant_id=301, name="گوجه‌فرنگی", growth_profile={ "base_temperature": 10, diff --git a/crop_simulation/tests.py b/crop_simulation/tests.py index cad6d02..ef946b7 100644 --- a/crop_simulation/tests.py +++ b/crop_simulation/tests.py @@ -12,6 +12,7 @@ from rest_framework.test import APIRequestFactory from .models import SimulationRun, SimulationScenario from farm_data.models import PlantCatalogSnapshot, SensorData +from farm_data.services import assign_farm_plants_from_backend_ids from irrigation.models import IrrigationMethod from location_data.models import SoilLocation from weather.models import WeatherForecast @@ -393,7 +394,7 @@ class CropSimulationCanonicalSnapshotTests(TestCase): weather_forecast=self.weather, irrigation_method=self.irrigation_method, ) - self.farm.plants.add(self.plant) + assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id]) @patch("crop_simulation.services.build_ai_farm_snapshot") def test_build_simulation_payload_from_farm_uses_aggregated_metrics(self, mock_snapshot): diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 00b665e..09bdc75 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -61,6 +61,7 @@ services: PROXYCHAINS_PROXY_HOST: ${PROXYCHAINS_PROXY_HOST:-host.docker.internal} PROXYCHAINS_PROXY_PORT: ${PROXYCHAINS_PROXY_PORT:-10808} PROXYCHAINS_CHAIN_MODE: ${PROXYCHAINS_CHAIN_MODE:-strict_chain} + OPENEO_VERIFY_ON_STARTUP: ${OPENEO_VERIFY_ON_STARTUP:-0} depends_on: db: condition: service_healthy @@ -101,6 +102,7 @@ services: PROXYCHAINS_PROXY_HOST: ${PROXYCHAINS_PROXY_HOST:-host.docker.internal} PROXYCHAINS_PROXY_PORT: ${PROXYCHAINS_PROXY_PORT:-10808} PROXYCHAINS_CHAIN_MODE: ${PROXYCHAINS_CHAIN_MODE:-strict_chain} + OPENEO_VERIFY_ON_STARTUP: ${OPENEO_VERIFY_ON_STARTUP:-0} depends_on: db: condition: service_healthy diff --git a/docker-compose.yaml b/docker-compose.yaml index 575e1cf..bdc5515 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -81,6 +81,7 @@ services: PROXYCHAINS_PROXY_HOST: ${PROXYCHAINS_PROXY_HOST:-host.docker.internal} PROXYCHAINS_PROXY_PORT: ${PROXYCHAINS_PROXY_PORT:-10808} PROXYCHAINS_CHAIN_MODE: ${PROXYCHAINS_CHAIN_MODE:-strict_chain} + OPENEO_VERIFY_ON_STARTUP: ${OPENEO_VERIFY_ON_STARTUP:-0} depends_on: db: condition: service_healthy @@ -118,6 +119,7 @@ services: PROXYCHAINS_PROXY_HOST: ${PROXYCHAINS_PROXY_HOST:-host.docker.internal} PROXYCHAINS_PROXY_PORT: ${PROXYCHAINS_PROXY_PORT:-10808} PROXYCHAINS_CHAIN_MODE: ${PROXYCHAINS_CHAIN_MODE:-strict_chain} + OPENEO_VERIFY_ON_STARTUP: ${OPENEO_VERIFY_ON_STARTUP:-0} depends_on: db: condition: service_healthy diff --git a/docs/location_data_api_responses_fa.md b/docs/location_data_api_responses_fa.md new file mode 100644 index 0000000..14e290a --- /dev/null +++ b/docs/location_data_api_responses_fa.md @@ -0,0 +1,1031 @@ +# مستند کامل response های Location Data + +این فایل، response همه endpointهای اصلی `Location Data` را به زبان ساده و دقیق توضیح می‌دهد. + +مسیرهای این مستند: + +- `GET /api/location-data/` +- `POST /api/location-data/` +- `POST /api/location-data/ndvi-health/` +- `GET /api/location-data/remote-sensing/` +- `POST /api/location-data/remote-sensing/` +- `GET /api/location-data/remote-sensing/cluster-blocks/{cluster_uuid}/live/` +- `GET /api/location-data/remote-sensing/cluster-recommendations/` +- `GET /api/location-data/remote-sensing/results/{result_id}/k-options/` +- `POST /api/location-data/remote-sensing/results/{result_id}/k-options/activate/` +- `GET /api/location-data/remote-sensing/runs/{run_id}/status/` + +## 1) ساختار عمومی همه response ها + +تقریبا همه endpointها این envelope را دارند: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +توضیح فیلدها: + +- `code`: کد منطقی response در body +- `msg`: پیام کوتاه +- `data`: payload اصلی + +در خطاها معمولا یکی از این دو حالت برمی‌گردد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "field_name": ["error message"] + } +} +``` + +یا: + +```json +{ + "code": 404, + "msg": "location پیدا نشد.", + "data": null +} +``` + +## 2) `GET /api/location-data/` + +کاربرد: + +- خواندن ساختار ذخیره‌شده مزرعه +- خواندن بلوک‌ها +- خواندن subdivisionها +- خواندن snapshotهای ماهواره‌ای ذخیره/تجمیع‌شده + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "source": "database", + "id": 12, + "lon": "51.389000", + "lat": "35.689200", + "input_block_count": 2, + "farm_boundary": {}, + "block_layout": {}, + "block_subdivisions": [], + "satellite_snapshots": [] + } +} +``` + +### توضیح فیلدهای `data` + +- `source`: در این endpoint همیشه از دیتابیس است و معمولا مقدار آن `database` است +- `id`: شناسه داخلی `SoilLocation` +- `lon`: طول جغرافیایی location +- `lat`: عرض جغرافیایی location +- `input_block_count`: تعداد بلوک‌های تعریف‌شده برای این مزرعه +- `farm_boundary`: مرز کل مزرعه به صورت GeoJSON +- `block_layout`: ساختار کلی بلوک‌ها، وضعیت الگوریتم، sub-blockها و metadata سطح مزرعه +- `block_subdivisions`: لیست subdivisionهای سطح بلوک +- `satellite_snapshots`: خلاصه‌های سنجش‌ازدور هر بلوک و هر sub-block + +### ساختار هر آیتم `block_subdivisions` + +```json +{ + "block_code": "block-1", + "chunk_size_sqm": 900, + "grid_points": [], + "centroid_points": [], + "grid_point_count": 0, + "centroid_count": 0, + "elbow_plot": null, + "status": "defined", + "metadata": {}, + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +توضیح: + +- `block_code`: کد بلوک +- `chunk_size_sqm`: اندازه هر سلول تحلیل +- `grid_points`: نقاط grid تولیدشده +- `centroid_points`: centroidهای grid +- `grid_point_count`: تعداد نقاط grid +- `centroid_count`: تعداد centroidها +- `elbow_plot`: تصویر elbow plot اگر ساخته شده باشد +- `status`: وضعیت subdivision مثل `defined`، `created`، `subdivided` +- `metadata`: داده‌های تکمیلی + +### `400` + +وقتی `lat` یا `lon` نامعتبر باشد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "lat": ["..."], + "lon": ["..."] + } +} +``` + +### `404` + +وقتی location پیدا نشود: + +```json +{ + "code": 404, + "msg": "location پیدا نشد.", + "data": null +} +``` + +## 3) `POST /api/location-data/` + +کاربرد: + +- ثبت یا به‌روزرسانی مزرعه +- ثبت مرز مزرعه +- ثبت بلوک‌های کشاورز + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "source": "created", + "id": 12, + "lon": "51.389000", + "lat": "35.689200", + "input_block_count": 2, + "farm_boundary": {}, + "block_layout": {}, + "block_subdivisions": [], + "satellite_snapshots": [] + } +} +``` + +### توضیح `source` + +- `created`: این location تازه ساخته شده +- `database`: location از قبل وجود داشته و فقط update شده یا همان داده قبلی برگشته + +### `400` + +حالت اول: body نامعتبر باشد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_boundary": ["مختصات گوشه‌های کل زمین باید ارسال شود."] + } +} +``` + +حالت دوم: مرز کل مزرعه نه در request آمده و نه قبلا ذخیره شده: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_boundary": [ + "برای ثبت location باید گوشه‌های کل زمین ارسال یا قبلاً ذخیره شده باشد." + ] + } +} +``` + +## 4) `POST /api/location-data/ndvi-health/` + +کاربرد: + +- برگرداندن وضعیت سلامت پوشش گیاهی مزرعه بر اساس NDVI + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "ndviIndex": 0.63, + "mean_ndvi": 0.63, + "ndvi_map": {}, + "vegetation_health_class": "healthy", + "observation_date": "2026-05-12", + "satellite_source": "sentinel-2", + "healthData": [ + { + "title": "میانگین NDVI", + "value": 0.63, + "color": "green", + "icon": "leaf" + } + ] + } +} +``` + +### توضیح فیلدها + +- `ndviIndex`: شاخص اصلی NDVI برای UI +- `mean_ndvi`: میانگین NDVI محاسبه‌شده +- `ndvi_map`: داده نقشه یا لایه NDVI +- `vegetation_health_class`: کلاس سلامت پوشش گیاهی +- `observation_date`: تاریخ مشاهده +- `satellite_source`: منبع داده ماهواره‌ای +- `healthData`: کارت‌های خلاصه برای نمایش در فرانت + +### ساختار هر آیتم `healthData` + +- `title`: عنوان آیتم +- `value`: مقدار عددی یا ساختار JSON +- `color`: رنگ پیشنهادی UI +- `icon`: آیکون پیشنهادی UI + +### `400` + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["..."] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "مزرعه پیدا نشد.", + "data": null +} +``` + +## 5) `GET /api/location-data/remote-sensing/` + +کاربرد: + +- فقط نتایج cache شده remote sensing و subdivision را می‌خواند +- هیچ پردازش جدیدی اجرا نمی‌کند + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "status": "success", + "source": "database", + "location": {}, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_extent": { + "start_date": "2026-04-12", + "end_date": "2026-05-12" + }, + "summary": { + "cell_count": 12, + "ndvi_mean": 0.54, + "ndwi_mean": 0.21, + "soil_vv_db_mean": -8.92 + }, + "cells": [], + "run": {}, + "subdivision_result": {}, + "pagination": {}, + "metadata": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "cache_hit": true + } + } +} +``` + +### حالت‌های مهم `status` + +- `success`: داده کامل در DB موجود است +- `processing`: run در حال انجام است و هنوز observation نهایی کامل نشده +- `not_found`: runی وجود داشته ولی observation قابل استفاده برنگشته + +### توضیح فیلدها + +- `status`: وضعیت نتیجه +- `source`: معمولا `database` یا `processing` +- `location`: همان ساختار `SoilLocationResponse` +- `block_code`: برای full farm معمولا رشته خالی `""` +- `chunk_size_sqm`: اندازه سلول تحلیل +- `temporal_extent.start_date`: شروع بازه تحلیل +- `temporal_extent.end_date`: پایان بازه تحلیل +- `summary`: خلاصه آماری observationها +- `cells`: observationهای صفحه فعلی +- `run`: اطلاعات run مرتبط +- `subdivision_result`: نتیجه clustering و KMeans +- `pagination`: اطلاعات صفحه‌بندی `cells` و گاهی `assignments` +- `metadata.cache_hit`: نشان می‌دهد پاسخ از cache/DB آمده + +### ساختار `summary` + +- `cell_count`: تعداد سلول‌ها +- `ndvi_mean`: میانگین NDVI +- `ndwi_mean`: میانگین NDWI +- `soil_vv_db_mean`: میانگین `soil_vv_db` + +### ساختار هر آیتم `cells` + +```json +{ + "cell_code": "cell-1", + "block_code": "", + "chunk_size_sqm": 900, + "centroid_lat": "35.689500", + "centroid_lon": "51.389500", + "geometry": {}, + "temporal_start": "2026-04-12", + "temporal_end": "2026-05-12", + "ndvi": 0.61, + "ndwi": 0.22, + "soil_vv": 0.13, + "soil_vv_db": -8.860566, + "metadata": {} +} +``` + +### ساختار `run` + +```json +{ + "id": 10, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_start": "2026-04-12", + "temporal_end": "2026-05-12", + "status": "success", + "status_label": "completed", + "pipeline_status": "completed", + "stage": "completed", + "selected_features": ["ndvi", "ndwi", "soil_vv_db"], + "requested_cluster_count": null, + "metadata": {}, + "error_message": "", + "started_at": null, + "finished_at": null, + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +### ساختار `subdivision_result` + +```json +{ + "id": 5, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_start": "2026-04-12", + "temporal_end": "2026-05-12", + "cluster_count": 3, + "selected_features": ["ndvi", "ndwi", "soil_vv_db"], + "skipped_cell_codes": [], + "metadata": {}, + "available_k_options": [], + "cluster_blocks": [], + "assignments": [], + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +### ساختار هر `assignment` + +```json +{ + "cell_code": "cell-1", + "cluster_label": 0, + "centroid_lat": "35.689500", + "centroid_lon": "51.389500", + "raw_feature_values": { + "ndvi": 0.61 + }, + "scaled_feature_values": { + "ndvi": 0.21 + } +} +``` + +### ساختار هر `cluster_block` + +```json +{ + "uuid": "11111111-1111-1111-1111-111111111111", + "sub_block_code": "cluster-0", + "cluster_label": 0, + "chunk_size_sqm": 900, + "centroid_lat": "35.689500", + "centroid_lon": "51.389500", + "center_cell_code": "cell-1", + "center_cell_lat": "35.689500", + "center_cell_lon": "51.389500", + "cell_count": 4, + "cell_codes": ["cell-1", "cell-2"], + "geometry": {}, + "metadata": {}, + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +### `400` + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["..."] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "location پیدا نشد.", + "data": null +} +``` + +## 6) `POST /api/location-data/remote-sensing/` + +کاربرد: + +- اجرای async تحلیل سنجش‌ازدور +- ساخت run و task قابل polling +- اگر داده قبلا در DB موجود باشد هم یک `task_id` tracking برمی‌گرداند تا status بلافاصله نتیجه را بدهد + +### response موفق `202` + +```json +{ + "code": 202, + "msg": "تحلیل سنجش‌ازدور در صف قرار گرفت.", + "data": { + "status": "processing", + "source": "processing", + "location": {}, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_extent": { + "start_date": "2026-04-12", + "end_date": "2026-05-12" + }, + "summary": { + "cell_count": 0, + "ndvi_mean": null, + "ndwi_mean": null, + "soil_vv_db_mean": null + }, + "cells": [], + "run": {}, + "task_id": "11111111-1111-1111-1111-111111111111" + } +} +``` + +### دو حالت مهم + +#### حالت اول: واقعا task جدید ساخته شده + +- `status = processing` +- `source = processing` +- `task_id` مربوط به Celery run جدید است + +#### حالت دوم: data از قبل در DB وجود دارد + +- باز هم `202` برمی‌گردد +- `status` ممکن است `success` باشد +- `source` معمولا `database` است +- `task_id` برای polling ساخته می‌شود +- `GET /runs/{run_id}/status/` بلافاصله نتیجه کامل را می‌دهد + +### `400` + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["..."] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "location پیدا نشد.", + "data": null +} +``` + +## 7) `GET /api/location-data/remote-sensing/cluster-blocks/{cluster_uuid}/live/` + +کاربرد: + +- دریافت metricهای زنده یا cache شده برای یک cluster block + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "status": "success", + "source": "database", + "cluster_block": {}, + "temporal_extent": { + "start_date": "2026-04-12", + "end_date": "2026-05-12" + }, + "selected_features": ["ndvi", "ndwi", "soil_vv_db"], + "summary": { + "cell_count": 2, + "ndvi_mean": 0.54, + "ndwi_mean": 0.17, + "soil_vv_db_mean": -9.0 + }, + "metrics": { + "ndvi": 0.54, + "ndwi": 0.17, + "soil_vv": 0.14, + "soil_vv_db": -9.0 + }, + "metadata": { + "requested_cluster_uuid": "11111111-1111-1111-1111-111111111111", + "cache_hit": true + } + } +} +``` + +### توضیح فیلدها + +- `source`: اگر از observationهای DB آمده باشد `database` و اگر مستقیم از openEO آمده باشد `openeo` +- `cluster_block`: ساختار کامل sub-block +- `selected_features`: metricهایی که برای تحلیل استفاده می‌شوند +- `summary`: خلاصه آماری cluster +- `metrics`: metric تجمیع‌شده همان cluster +- `metadata`: اطلاعات تکمیلی مثل backend، source_result_id، source_run_id + +### `400` + +- پارامترهای تاریخ نامعتبر باشند +- یا هندسه cluster معتبر نباشد + +نمونه: + +```json +{ + "code": 400, + "msg": "هندسه زیر‌بلاک KMeans نامعتبر است.", + "data": { + "cluster_uuid": ["11111111-1111-1111-1111-111111111111"] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "زیر‌بلاک KMeans پیدا نشد.", + "data": null +} +``` + +### `502` + +وقتی openEO پاسخ ندهد: + +```json +{ + "code": 502, + "msg": "خواندن داده از openEO ناموفق بود.", + "data": { + "detail": "..." + } +} +``` + +## 8) `GET /api/location-data/remote-sensing/cluster-recommendations/` + +کاربرد: + +- مقایسه گیاه‌های ثبت‌شده در `farm_data` +- استفاده از داده کلاسترها +- استفاده از `crop_simulation` +- پیشنهاد بهترین گیاه برای هر cluster + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "location_id": 12, + "evaluated_plant_count": 2, + "cluster_count": 2, + "registered_plants": [], + "clusters": [], + "source_metadata": {} + } +} +``` + +### توضیح فیلدهای سطح بالا + +- `farm_uuid`: شناسه مزرعه +- `location_id`: شناسه داخلی location +- `evaluated_plant_count`: تعداد گیاه‌هایی که وارد simulation شده‌اند +- `cluster_count`: تعداد clusterهای بررسی‌شده +- `registered_plants`: گیاه‌های ثبت‌شده روی مزرعه +- `clusters`: خروجی نهایی هر cluster +- `source_metadata`: metadata کلی پاسخ + +### ساختار هر آیتم `registered_plants` + +```json +{ + "plant_id": 101, + "plant_name": "Tomato", + "position": 0, + "stage": "vegetative" +} +``` + +### ساختار هر آیتم `clusters` + +```json +{ + "block_code": "block-1", + "cluster_uuid": "11111111-1111-1111-1111-111111111111", + "sub_block_code": "cluster-0", + "cluster_label": 0, + "temporal_extent": { + "start_date": "2026-04-12", + "end_date": "2026-05-12" + }, + "cluster_block": {}, + "satellite_metrics": { + "ndvi": 0.51, + "ndwi": 0.24, + "soil_vv": 0.13 + }, + "sensor_metrics": {}, + "resolved_metrics": { + "ndvi": 0.51, + "ndwi": 0.24, + "soil_vv": 0.13 + }, + "candidate_plants": [], + "suggested_plant": {}, + "source_metadata": {} +} +``` + +### ساختار هر آیتم `candidate_plants` + +```json +{ + "plant_id": 101, + "plant_name": "Tomato", + "position": 0, + "stage": "vegetative", + "score": 150.0, + "predicted_yield": 150.0, + "predicted_yield_tons": 0.15, + "biomass": 300.0, + "max_lai": 4.2, + "simulation_engine": "pcse", + "simulation_model_name": "Wofost81_NWLP_CWB_CNB", + "simulation_warning": null, + "supporting_metrics": {} +} +``` + +### `400` + +وقتی مزرعه گیاه ثبت‌شده نداشته باشد یا پیش‌نیاز simulation کامل نباشد: + +```json +{ + "code": 400, + "msg": "برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.", + "data": null +} +``` + +### `404` + +وقتی مزرعه یا خروجی KMeans پیدا نشود: + +```json +{ + "code": 404, + "msg": "برای این مزرعه هنوز خروجی KMeans در location_data ثبت نشده است.", + "data": null +} +``` + +## 9) `GET /api/location-data/remote-sensing/results/{result_id}/k-options/` + +کاربرد: + +- لیست همه Kهای ذخیره‌شده برای یک subdivision result + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "result_id": 5, + "active_requested_k": 3, + "recommended_requested_k": 4, + "options": [] + } +} +``` + +### توضیح فیلدها + +- `result_id`: شناسه subdivision result +- `active_requested_k`: K فعال فعلی +- `recommended_requested_k`: K پیشنهادی سیستم +- `options`: لیست کامل گزینه‌ها + +### ساختار هر آیتم `options` + +```json +{ + "id": 11, + "requested_k": 3, + "effective_cluster_count": 3, + "is_active": true, + "is_recommended": false, + "selection_source": "user", + "metadata": {}, + "cluster_blocks": [], + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +### ساختار هر `cluster_blocks` داخل option + +```json +{ + "cluster_label": 0, + "sub_block_code": "cluster-0", + "chunk_size_sqm": 900, + "centroid_lat": "35.689500", + "centroid_lon": "51.389500", + "center_cell_code": "cell-1", + "center_cell_lat": "35.689500", + "center_cell_lon": "51.389500", + "cell_count": 4, + "cell_codes": ["cell-1", "cell-2"], + "geometry": {}, + "metadata": {} +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "subdivision result پیدا نشد.", + "data": null +} +``` + +## 10) `POST /api/location-data/remote-sensing/results/{result_id}/k-options/activate/` + +کاربرد: + +- فعال‌سازی یکی از Kهای ذخیره‌شده + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "result_id": 5, + "activated_requested_k": 4, + "subdivision_result": {} + } +} +``` + +### توضیح فیلدها + +- `result_id`: شناسه result +- `activated_requested_k`: K که الان active شده +- `subdivision_result`: خروجی کامل subdivision بعد از sync شدن روی K جدید + +### `400` + +حالت اول: body نامعتبر + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "requested_k": ["..."] + } +} +``` + +حالت دوم: K داخل optionها وجود ندارد + +```json +{ + "code": 400, + "msg": "K انتخابی برای این subdivision result موجود نیست.", + "data": { + "requested_k": [7] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "subdivision result پیدا نشد.", + "data": null +} +``` + +## 11) `GET /api/location-data/remote-sensing/runs/{run_id}/status/` + +کاربرد: + +- polling وضعیت run +- دیدن stageهای pipeline +- اگر run کامل شده باشد، دیدن نتیجه نهایی +- اگر run از نوع cache-hit باشد، دیدن نتیجه کامل DB بلافاصله + +### response موفق `200` در حالت pending/running + +```json +{ + "code": 200, + "msg": "success", + "data": { + "status": "running", + "source": "database", + "run": {}, + "task_id": "11111111-1111-1111-1111-111111111111", + "task": { + "current_stage": "fetching_remote_metrics", + "current_stage_details": {}, + "timestamps": {}, + "stages": [], + "metric_progress": {}, + "celery": { + "state": "STARTED", + "ready": false, + "successful": false, + "failed": false, + "info": {} + } + } + } +} +``` + +### response موفق `200` در حالت completed + +```json +{ + "code": 200, + "msg": "success", + "data": { + "status": "completed", + "source": "database", + "run": {}, + "task_id": "11111111-1111-1111-1111-111111111111", + "task": { + "current_stage": "completed", + "current_stage_details": {}, + "timestamps": {}, + "stages": [] + }, + "location": {}, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_extent": { + "start_date": "2026-04-12", + "end_date": "2026-05-12" + }, + "summary": { + "cell_count": 12, + "ndvi_mean": 0.54, + "ndwi_mean": 0.21, + "soil_vv_db_mean": -8.92 + }, + "cells": [], + "subdivision_result": {}, + "pagination": {} + } +} +``` + +### توضیح فیلدهای `task` + +- `current_stage`: stage فعلی pipeline +- `current_stage_details`: جزئیات همان stage +- `timestamps`: زمان ورود به stageها +- `stages`: تاریخچه stageها +- `metric_progress`: پیشرفت metricها هنگام fetch داده +- `retry`: اطلاعات retry اگر task در حال retry باشد +- `last_error`: آخرین خطا +- `failure_reason`: علت fail شدن task +- `celery.state`: وضعیت Celery مثل `PENDING`، `STARTED`، `RETRY` +- `celery.ready`: آیا task تمام شده +- `celery.successful`: آیا task موفق بوده +- `celery.failed`: آیا task fail شده +- `celery.info`: اطلاعات خام Celery + +### مقادیر متداول `status` + +- `pending` +- `running` +- `retrying` +- `completed` +- `failed` + +### `404` + +```json +{ + "code": 404, + "msg": "run با این task_id پیدا نشد.", + "data": null +} +``` + +## 12) نکات مهم برای فرانت + +- در همه endpointها اول `code` و بعد `data` را چک کنید. +- در `POST /remote-sensing/` همیشه انتظار `task_id` داشته باشید. +- در `POST /remote-sensing/` اگر داده قبلا موجود باشد هم ممکن است `202` بگیرید، چون سیستم برای polling یک run قابل پیگیری می‌سازد. +- در `GET /remote-sensing/runs/{run_id}/status/` اگر `status = completed` شد، همان response نهایی را استفاده کنید و دیگر لازم نیست `GET /remote-sensing/` را دوباره صدا بزنید. +- در `GET /remote-sensing/cluster-blocks/{cluster_uuid}/live/` مقدار `source` مهم است: + - `database`: از cache + - `openeo`: از backend زنده +- در responseهای subdivision، pagination ممکن است هم برای `cells` باشد و هم برای `assignments`. + +## 13) محل فایل + +این مستند در این مسیر ذخیره شده است: + +- `docs/location_data_api_responses_fa.md` diff --git a/docs/plant_service_relation.md b/docs/plant_service_relation.md new file mode 100644 index 0000000..f2a3f2f --- /dev/null +++ b/docs/plant_service_relation.md @@ -0,0 +1,291 @@ +# ارتباط سرویس‌ها با Plant و گیاهان + +این سند توضیح می‌دهد که در پروژه، داده‌ی گیاه از کجا می‌آید، چطور در `farm_data` نگه‌داری می‌شود، چگونه به سرویس‌های `crop_simulation` می‌رسد و در نهایت چطور در `location_data` برای پیشنهاد گیاه هر کلاستر استفاده می‌شود. + +## نمای کلی + +در این پروژه سه لایه اصلی برای کار با گیاه وجود دارد: + +1. `plant` +2. `farm_data` +3. `crop_simulation` و `location_data` + +نقش هر لایه: + +- `plant`: مرجع canonical نام گیاه و aliasها است. +- `farm_data`: نسخه snapshot شده‌ی گیاهان Backend و assignment هر مزرعه به گیاه‌ها را نگه می‌دارد. +- `crop_simulation`: از گیاه انتخاب‌شده برای ساخت ورودی شبیه‌سازی استفاده می‌کند. +- `location_data`: داده‌ی کلاسترهای KMeans را با گیاه‌های مزرعه ترکیب می‌کند و پیشنهاد گیاه می‌سازد. + +## 1) لایه plant + +اپ `plant` مرجع اصلی برای resolve کردن نام گیاه است. + +فایل مهم: + +- `plant/apps.py` + +تابع مهم: + +- `resolve_plant_name` + +رفتار این تابع: + +- نام ورودی را می‌گیرد. +- اگر همان نام در جدول `plant.Plant` وجود داشته باشد، همان را برمی‌گرداند. +- اگر alias برای آن تعریف شده باشد، alias را به نام canonical تبدیل می‌کند. +- اگر از نظر نرمال‌سازی متنی با یک گیاه match شود، همان نام canonical را برمی‌گرداند. + +در نتیجه: + +- ورودی‌هایی مثل `plant_name`، `crop` یا `crop_name` قبل از ورود به شبیه‌سازی، به نام استاندارد تبدیل می‌شوند. + +## 2) لایه farm_data + +اپ `farm_data` مدل read-model مربوط به گیاهان هر مزرعه را نگه می‌دارد. + +مدل‌های اصلی: + +- `farm_data.models.PlantCatalogSnapshot` +- `farm_data.models.FarmPlantAssignment` +- `farm_data.models.SensorData` + +### PlantCatalogSnapshot + +این مدل کپی محلی و خواندنی از کاتالوگ گیاه Backend است. + +اطلاعاتی که در آن نگه‌داری می‌شود: + +- نام گیاه +- توضیحات +- `growth_profile` +- `irrigation_profile` +- `health_profile` +- فصل کاشت، زمان برداشت، فاصله کاشت، کود و ... + +این مدل منبع اصلی هوش مصنوعی برای خواندن پروفایل گیاه است، نه relation قدیمی `SensorData.plants`. + +### FarmPlantAssignment + +این مدل مشخص می‌کند هر مزرعه چه گیاه‌هایی دارد. + +فیلدهای مهم: + +- `farm` +- `plant` +- `position` +- `stage` +- `metadata` + +یعنی هر مزرعه می‌تواند چند گیاه داشته باشد و ترتیب و مرحله رشد هرکدام هم ثبت می‌شود. + +### توابع مهم در farm_data/services.py + +فایل مهم: + +- `farm_data/services.py` + +توابع کلیدی: + +- `sync_plant_catalog_from_backend` +- `assign_farm_plants_from_backend_ids` +- `get_farm_plant_assignments` +- `get_farm_plant_snapshots` +- `get_primary_plant_snapshot` +- `get_farm_plant_snapshot_by_name` +- `clone_snapshot_as_runtime_plant` +- `get_runtime_plant_for_farm` +- `list_runtime_plants_for_farm` + +### جریان داده در farm_data + +1. کاتالوگ گیاه از Backend خوانده می‌شود و داخل `PlantCatalogSnapshot` ذخیره می‌شود. +2. گیاه‌های انتخاب‌شده‌ی هر مزرعه با `FarmPlantAssignment` ثبت می‌شوند. +3. اگر سرویس شبیه‌سازی یک `plant_name` مشخص بگیرد، همان گیاه از assignmentها پیدا می‌شود. +4. اگر `plant_name` ارسال نشود، گیاه اول مزرعه به عنوان پیش‌فرض انتخاب می‌شود. + +### Runtime Plant + +تابع `get_runtime_plant_for_farm` یک snapshot را به یک object سبک runtime تبدیل می‌کند تا downstream serviceها بدون وابستگی مستقیم به مدل DB از آن استفاده کنند. + +این object شامل فیلدهایی مثل: + +- `name` +- `growth_profile` +- `irrigation_profile` +- `health_profile` +- `planting_season` +- `harvest_time` + +است. + +## 3) ورود گیاه به crop_simulation + +فایل‌های مهم: + +- `crop_simulation/services.py` +- `crop_simulation/growth_simulation.py` +- `crop_simulation/harvest_prediction.py` +- `crop_simulation/yield_prediction.py` + +### build_simulation_payload_from_farm + +مهم‌ترین نقطه اتصال بین `farm_data` و `crop_simulation` این تابع است: + +- `crop_simulation.services.build_simulation_payload_from_farm` + +این تابع: + +1. مزرعه را با `get_canonical_farm_record` پیدا می‌کند. +2. گیاه را با `get_runtime_plant_for_farm` resolve می‌کند. +3. snapshot هوش مصنوعی مزرعه را می‌خواند. +4. weather, soil, site_parameters را می‌سازد. +5. از پروفایل گیاه، `crop_parameters` و در صورت وجود `agromanagement` پیش‌فرض را استخراج می‌کند. + +خروجی این تابع شامل این بخش‌هاست: + +- `plant` +- `runtime_plants` +- `weather` +- `soil` +- `site_parameters` +- `crop_parameters` +- `agromanagement` + +یعنی تمام چیزی که موتور شبیه‌سازی لازم دارد. + +### استفاده در Growth Simulation + +در `crop_simulation/growth_simulation.py` اگر `farm_uuid` داده شود: + +- `build_growth_context` از `build_simulation_payload_from_farm` استفاده می‌کند. +- گیاه انتخاب‌شده وارد context می‌شود. +- سپس شبیه‌سازی PCSE یا fallback projection روی همان گیاه اجرا می‌شود. + +### استفاده در Harvest Prediction + +در `crop_simulation/harvest_prediction.py` اگر `plant_name` ارسال نشود: + +- سرویس با `get_runtime_plant_for_farm` گیاه پیش‌فرض مزرعه را پیدا می‌کند. +- سپس از همان گیاه برای محاسبه‌ی GDD و پیش‌بینی برداشت استفاده می‌شود. + +### استفاده در Yield Prediction + +در `crop_simulation/yield_prediction.py`: + +- سرویس chart فعلی مزرعه را صدا می‌زند. +- chart هم قبلاً گیاه را از مسیر canonical مزرعه resolve کرده است. +- بنابراین yield همیشه روی یک گیاه مشخص از assignmentهای مزرعه محاسبه می‌شود. + +## 4) نقش serializerها در resolve کردن نام گیاه + +فایل مهم: + +- `crop_simulation/serializers.py` + +کلاس مهم: + +- `PlantNameAliasMixin` + +این mixin: + +- `plant_name` +- `crop` +- `crop_name` + +را قبول می‌کند و با `apps.get_app_config("plant").resolve_plant_name(...)` آن را canonical می‌کند. + +پس حتی اگر کلاینت نام گیاه را با alias بفرستد، سرویس شبیه‌سازی با نام استاندارد کار می‌کند. + +## 5) ارتباط location_data با گیاه‌ها + +فایل مهم: + +- `location_data/cluster_recommendation.py` + +تابع اصلی: + +- `build_cluster_crop_recommendations` + +این تابع ارتباط بین کلاسترهای KMeans و گیاه‌های مزرعه را می‌سازد. + +### ورودی + +- `farm_uuid` + +### کارهایی که انجام می‌دهد + +1. مزرعه را از `farm_data` پیدا می‌کند. +2. لیست گیاه‌های ثبت‌شده را با `get_farm_plant_assignments` می‌خواند. +3. snapshot کلاسترهای `location_data` را می‌گیرد. +4. برای هر گیاه ثبت‌شده، با `build_simulation_payload_from_farm` یک payload پایه می‌سازد. +5. برای هر کلاستر: + - متریک‌های همان کلاستر مثل `ndvi`, `ndwi`, `soil_vv`, `soil_vv_db` را جمع می‌کند. + - پارامترهای soil/site را با داده همان کلاستر override می‌کند. + - برای تک‌تک گیاه‌های مزرعه شبیه‌سازی اجرا می‌کند. + - خروجی‌ها را بر اساس `yield_estimate` رتبه‌بندی می‌کند. +6. بهترین گیاه را به عنوان `suggested_plant` برمی‌گرداند. + +### نتیجه + +`location_data` خودش مرجع گیاه نیست؛ فقط: + +- گیاه‌ها را از `farm_data` +- نام canonical را از `plant` +- منطق شبیه‌سازی را از `crop_simulation` + +می‌گیرد و روی داده‌های کلاستر اعمال می‌کند. + +## 6) ترتیب مسئولیت‌ها + +برای جلوگیری از ابهام، مسئولیت هر بخش این است: + +- `plant`: + - canonical name + - alias resolving + +- `farm_data`: + - snapshot گیاه + - assignment گیاه به مزرعه + - تبدیل snapshot به runtime plant + +- `crop_simulation`: + - ساخت payload شبیه‌سازی از مزرعه و گیاه + - اجرای شبیه‌سازی رشد، عملکرد و برداشت + +- `location_data`: + - خواندن کلاسترهای KMeans + - مقایسه گیاه‌های مزرعه برای هر کلاستر + - پیشنهاد گیاه برای sub-block + +## 7) نکات مهم طراحی + +- relation قدیمی `SensorData.plants` مسیر legacy است و منبع canonical نیست. +- مسیر canonical برای گیاه‌های مزرعه، `FarmPlantAssignment` و `PlantCatalogSnapshot` است. +- سرویس‌های شبیه‌سازی نباید مستقیم از `plant.Plant` برای گیاه مزرعه استفاده کنند؛ مسیر درست، `farm_data.services.get_runtime_plant_for_farm` است. +- اگر `plant_name` صریح داده نشود، معمولاً گیاه اول assignmentهای مزرعه انتخاب می‌شود. +- اگر چند گیاه برای مزرعه ثبت شده باشد، endpoint پیشنهاد کلاستر همه‌ی آن‌ها را compare می‌کند. + +## 8) خلاصه جریان end-to-end + +جریان کامل به این صورت است: + +1. Backend plant catalog -> `PlantCatalogSnapshot` +2. farm selected plants -> `FarmPlantAssignment` +3. client request with `farm_uuid` +4. farm -> runtime plant resolution +5. runtime plant + farm metrics -> simulation payload +6. simulation payload -> PCSE/projection +7. cluster metrics + plant candidates -> recommended crop per cluster + +## 9) فایل‌های کلیدی برای مرور سریع + +- `plant/apps.py` +- `farm_data/models.py` +- `farm_data/services.py` +- `crop_simulation/services.py` +- `crop_simulation/growth_simulation.py` +- `crop_simulation/harvest_prediction.py` +- `crop_simulation/yield_prediction.py` +- `location_data/cluster_recommendation.py` + diff --git a/entrypoint.sh b/entrypoint.sh index 397c357..be8a0ae 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,6 +2,33 @@ set -e PROXYCHAINS_CONFIG_FILE="${PROXYCHAINS_CONFIG_FILE:-/etc/proxychains.conf}" +OPENEO_VERIFY_ON_STARTUP="${OPENEO_VERIFY_ON_STARTUP:-1}" + +disable_proxy_mode() { + reason="$1" + echo "Proxy support disabled: ${reason}" >&2 + ENABLE_PROXYCHAINS=0 + export ENABLE_PROXYCHAINS + export OPENEO_PROXY_URL="" + export OPENEO_VERIFY_ON_STARTUP=0 +} + +proxy_endpoint_reachable() { + proxy_host="$1" + proxy_port="$2" + python - "$proxy_host" "$proxy_port" <<'PY' +import socket +import sys + +host = sys.argv[1] +port = int(sys.argv[2]) +try: + with socket.create_connection((host, port), timeout=2): + sys.exit(0) +except OSError: + sys.exit(1) +PY +} setup_proxychains() { if [ "${ENABLE_PROXYCHAINS}" != "1" ]; then @@ -10,8 +37,8 @@ setup_proxychains() { fi if ! command -v proxychains4 >/dev/null 2>&1; then - echo "proxychains4 is not installed but ENABLE_PROXYCHAINS=1 was set." >&2 - exit 1 + disable_proxy_mode "proxychains4 is not installed but ENABLE_PROXYCHAINS=1 was set." + return 0 fi proxy_type="${PROXYCHAINS_PROXY_TYPE:-socks4}" @@ -21,8 +48,13 @@ setup_proxychains() { proxy_ip="$(getent hosts "${proxy_host}" | awk 'NR==1 {print $1}')" if [ -z "${proxy_ip}" ]; then - echo "Could not resolve proxy host: ${proxy_host}" >&2 - exit 1 + disable_proxy_mode "could not resolve proxy host ${proxy_host}" + return 0 + fi + + if ! proxy_endpoint_reachable "${proxy_host}" "${proxy_port}"; then + disable_proxy_mode "proxy ${proxy_host}:${proxy_port} is unreachable" + return 0 fi cat > "${PROXYCHAINS_CONFIG_FILE}" <&2 +if [ "${OPENEO_VERIFY_ON_STARTUP}" = "1" ]; then + echo "Checking openEO authentication..." + if ! run_cmd python manage.py verify_openeo_auth --skip-if-unconfigured; then + echo "openEO authentication failed; continuing startup with degraded openEO-dependent features." >&2 + fi +else + echo "Skipping openEO authentication during startup." fi echo "Collecting static files..." diff --git a/farm_data/services.py b/farm_data/services.py index 23c0323..9c69dd8 100644 --- a/farm_data/services.py +++ b/farm_data/services.py @@ -7,7 +7,6 @@ import uuid import warnings from django.conf import settings -from django.apps import apps from django.db import transaction from django.utils.dateparse import parse_datetime from django.utils import timezone @@ -217,16 +216,9 @@ def reconcile_legacy_farm_plants_relation( farm: SensorData, snapshots: list[PlantCatalogSnapshot] | None = None, ) -> None: - snapshots = list(snapshots if snapshots is not None else get_farm_plant_snapshots(farm)) - Plant = apps.get_model("plant", "Plant") - if Plant is None: - return - names = [snapshot.name for snapshot in snapshots if snapshot and snapshot.name] - if not names: - farm.plants.clear() - return - legacy_plants = list(Plant.objects.filter(name__in=names).order_by("name", "id")) - farm.plants.set(legacy_plants) + # AI no longer mirrors canonical plant rows locally; the legacy relation is cleared + # so downstream services cannot accidentally read stale plant data. + farm.plants.clear() def get_canonical_farm_record(farm_uuid: str) -> SensorData | None: diff --git a/farm_data/tests/test_farm_detail_api.py b/farm_data/tests/test_farm_detail_api.py index 6f4c09a..a127186 100644 --- a/farm_data/tests/test_farm_detail_api.py +++ b/farm_data/tests/test_farm_detail_api.py @@ -86,8 +86,11 @@ class FarmDetailApiTests(TestCase): 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_assignment_sync_uses_backend_snapshots_as_canonical_source(self): + self.assertEqual( + list(self.farm.plant_assignments.values_list("plant__name", flat=True)), + ["خیار", "گوجه‌فرنگی"], + ) def test_runtime_plant_lookup_resolves_by_name_from_canonical_assignments(self): farm = get_canonical_farm_record(str(self.farm_uuid)) diff --git a/integration_tests/base.py b/integration_tests/base.py index 2d82073..ddcdeb9 100644 --- a/integration_tests/base.py +++ b/integration_tests/base.py @@ -7,6 +7,7 @@ import uuid from django.test import TransactionTestCase from rest_framework.test import APIClient +from farm_data.models import PlantCatalogSnapshot from location_data.models import NdviObservation, SoilLocation from weather.models import WeatherForecast @@ -40,6 +41,7 @@ class IntegrationAPITestCase(TransactionTestCase): def setUp(self) -> None: super().setUp() self.client = APIClient() + self._next_backend_plant_id = 100 self.primary_boundary = square_boundary(self.primary_lat, self.primary_lon) self.primary_location = self.create_complete_location( lat=self.primary_lat, @@ -55,6 +57,7 @@ class IntegrationAPITestCase(TransactionTestCase): lat: float, lon: float, boundary: dict[str, Any] | None = None, + **_ignored: Any, ) -> SoilLocation: location = SoilLocation.objects.create( latitude=f"{lat:.6f}", @@ -126,22 +129,46 @@ class IntegrationAPITestCase(TransactionTestCase): return response.json()["data"] def create_plant_via_api(self, name: str, **overrides: Any) -> dict[str, Any]: + backend_plant_id = int(overrides.pop("id", self._next_backend_plant_id)) + self._next_backend_plant_id = max(self._next_backend_plant_id, backend_plant_id + 1) payload = { + "id": backend_plant_id, "name": name, + "icon": "leaf", "light": "full sun", "watering": "every 2 days", "soil": "loamy", "temperature": "20-28C", "growth_stage": "vegetative", + "growth_stages": ["vegetative"], "planting_season": "spring", "harvest_time": "90 days", "spacing": "50 cm", "fertilizer": "balanced NPK", } payload.update(overrides) - response = self.client.post("/api/plants/", data=payload, format="json") - self.assertEqual(response.status_code, 201, response.json()) - return response.json()["data"] + if "growth_stages" not in overrides: + payload["growth_stages"] = [payload["growth_stage"]] if payload.get("growth_stage") else [] + response = self.client.post("/api/farm-data/plants/sync/", data=[payload], format="json") + self.assertEqual(response.status_code, 200, response.json()) + + snapshot = PlantCatalogSnapshot.objects.get(backend_plant_id=backend_plant_id) + return { + "id": snapshot.backend_plant_id, + "backend_plant_id": snapshot.backend_plant_id, + "name": snapshot.name, + "icon": snapshot.icon, + "light": snapshot.light, + "watering": snapshot.watering, + "soil": snapshot.soil, + "temperature": snapshot.temperature, + "growth_stage": snapshot.growth_stage, + "growth_stages": list(snapshot.growth_stages or []), + "planting_season": snapshot.planting_season, + "harvest_time": snapshot.harvest_time, + "spacing": snapshot.spacing, + "fertilizer": snapshot.fertilizer, + } def create_sensor_parameter_via_api(self, **overrides: Any) -> dict[str, Any]: payload = { diff --git a/integration_tests/test_management_api_flow.py b/integration_tests/test_management_api_flow.py index 79c33fa..82b24f9 100644 --- a/integration_tests/test_management_api_flow.py +++ b/integration_tests/test_management_api_flow.py @@ -5,9 +5,8 @@ from unittest.mock import patch from django.test import override_settings -from farm_data.models import ParameterUpdateLog, SensorData, SensorParameter +from farm_data.models import ParameterUpdateLog, PlantCatalogSnapshot, SensorData, SensorParameter from integration_tests.base import IntegrationAPITestCase -from plant.models import Plant @override_settings(ROOT_URLCONF="config.urls") @@ -44,88 +43,44 @@ class FarmManagementJourneyTests(IntegrationAPITestCase): tomato = self.create_plant_via_api("Tomato") cucumber = self.create_plant_via_api("Cucumber", watering="daily") - removable_plant = self.create_plant_via_api("Remove Plant") - - plants_list_response = self.client.get("/api/plants/") - self.assertEqual(plants_list_response.status_code, 200) - returned_names = {item["name"] for item in plants_list_response.json()["data"]} - self.assertTrue({"Tomato", "Cucumber", "Remove Plant"}.issubset(returned_names)) plant_catalog = self.create_plant_via_api( "Pepper", growth_stage="", - icon="sprout", - ) - Plant.objects.filter(pk=plant_catalog["id"]).update(growth_stage="", icon="") - plant_names_response = self.client.get("/api/plants/names/") - self.assertEqual(plant_names_response.status_code, 200) - plant_names_payload = { - item["name"]: item for item in plant_names_response.json()["data"] - } - self.assertEqual(plant_names_payload["Pepper"]["icon"], "leaf") - self.assertEqual( - plant_names_payload["Pepper"]["growth_stages"], - ["initial", "vegetative", "flowering", "fruiting", "maturity"], - ) - pepper = Plant.objects.get(pk=plant_catalog["id"]) - self.assertEqual( - pepper.growth_stage, - "initial, vegetative, flowering, fruiting, maturity", + icon="", ) + pepper = PlantCatalogSnapshot.objects.get(backend_plant_id=plant_catalog["id"]) self.assertEqual(pepper.icon, "leaf") + self.assertEqual(pepper.growth_stages, []) - plant_patch_response = self.client.patch( - f"/api/plants/{tomato['id']}/", - data={"growth_stage": "flowering", "watering": "daily"}, - format="json", + updated_tomato = self.create_plant_via_api( + "Tomato", + id=tomato["id"], + growth_stage="flowering", + growth_stages=["flowering"], + watering="daily", ) - self.assertEqual(plant_patch_response.status_code, 200) - self.assertEqual(Plant.objects.get(pk=tomato["id"]).growth_stage, "flowering") - - plant_put_response = self.client.put( - f"/api/plants/{cucumber['id']}/", - data={ - "name": "Cucumber", - "light": "full sun", - "watering": "every day", - "soil": "sandy loam", - "temperature": "18-30C", - "growth_stage": "fruiting", - "planting_season": "spring", - "harvest_time": "70 days", - "spacing": "40 cm", - "fertilizer": "potassium rich", - }, - format="json", + self.assertEqual(updated_tomato["growth_stage"], "flowering") + self.assertEqual( + PlantCatalogSnapshot.objects.get(backend_plant_id=tomato["id"]).growth_stage, + "flowering", ) - self.assertEqual(plant_put_response.status_code, 200) - with patch( - "plant.views.fetch_plant_info_from_api", - return_value={ - "name": "Tomato", - "light": "full sun", - "watering": "daily", - "soil": "loamy", - "temperature": "20-28C", - "growth_stage": "flowering", - "planting_season": "spring", - "harvest_time": "90 days", - "spacing": "50 cm", - "fertilizer": "balanced NPK", - }, - ): - plant_fetch_response = self.client.post( - "/api/plants/fetch-info/", - data={"name": "Tomato"}, - format="json", - ) - self.assertEqual(plant_fetch_response.status_code, 200) - self.assertEqual(plant_fetch_response.json()["data"]["name"], "Tomato") - - plant_delete_response = self.client.delete(f"/api/plants/{removable_plant['id']}/") - self.assertEqual(plant_delete_response.status_code, 200) - self.assertFalse(Plant.objects.filter(pk=removable_plant["id"]).exists()) + updated_cucumber = self.create_plant_via_api( + "Cucumber", + id=cucumber["id"], + light="full sun", + watering="every day", + soil="sandy loam", + temperature="18-30C", + growth_stage="fruiting", + growth_stages=["fruiting"], + planting_season="spring", + harvest_time="70 days", + spacing="40 cm", + fertilizer="potassium rich", + ) + self.assertEqual(updated_cucumber["watering"], "every day") farm_uuid = uuid.uuid4() created_farm = self.upsert_farm_via_api( @@ -147,7 +102,7 @@ class FarmManagementJourneyTests(IntegrationAPITestCase): self.assertEqual(created_farm["farm_uuid"], str(farm_uuid)) farm_record = SensorData.objects.get(farm_uuid=farm_uuid) self.assertCountEqual( - list(farm_record.plants.values_list("id", flat=True)), + list(farm_record.plant_assignments.values_list("plant__backend_plant_id", flat=True)), [tomato["id"], cucumber["id"]], ) self.assertEqual(farm_record.irrigation_method_id, primary_method["id"]) @@ -172,7 +127,10 @@ class FarmManagementJourneyTests(IntegrationAPITestCase): farm_record.refresh_from_db() self.assertEqual(farm_record.irrigation_method_id, backup_method["id"]) - self.assertCountEqual(list(farm_record.plants.values_list("id", flat=True)), [tomato["id"]]) + self.assertCountEqual( + list(farm_record.plant_assignments.values_list("plant__backend_plant_id", flat=True)), + [tomato["id"]], + ) self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["soil_temperature"], 23.4) self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["soil_moisture"], 44.0) self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["nitrogen"], 19.5) diff --git a/integration_tests/test_reporting_and_ai_api_flow.py b/integration_tests/test_reporting_and_ai_api_flow.py index 646fe7d..3945568 100644 --- a/integration_tests/test_reporting_and_ai_api_flow.py +++ b/integration_tests/test_reporting_and_ai_api_flow.py @@ -11,6 +11,7 @@ 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 @@ -79,7 +80,7 @@ class ReportingAndAiJourneyTests(IntegrationAPITestCase): } }, ) - neighbor_sensor.plants.set([self.primary_plant["id"]]) + 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( diff --git a/location_data/cluster_recommendation.py b/location_data/cluster_recommendation.py index 1d5d5fb..820872f 100644 --- a/location_data/cluster_recommendation.py +++ b/location_data/cluster_recommendation.py @@ -2,6 +2,8 @@ from __future__ import annotations from copy import deepcopy from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal from typing import Any from django.db.models import Avg @@ -9,7 +11,7 @@ from django.db.models import Avg from crop_simulation.growth_simulation import GrowthSimulationContext, _run_projection_engine from crop_simulation.services import PcseSimulationManager, build_simulation_payload_from_farm from farm_data.services import get_canonical_farm_record, get_farm_plant_assignments -from .models import AnalysisGridObservation, RemoteSensingClusterBlock +from .models import AnalysisGridObservation, RemoteSensingClusterBlock, RemoteSensingSubdivisionResult from .satellite_snapshot import build_location_block_satellite_snapshots @@ -70,6 +72,23 @@ def _clamp(value: float, minimum: float, maximum: float) -> float: return max(minimum, min(value, maximum)) +def _json_safe(value: Any) -> Any: + if isinstance(value, Decimal): + return float(value) + if isinstance(value, datetime): + formatted = value.isoformat() + if formatted.endswith("+00:00"): + return formatted[:-6] + "Z" + return formatted + if isinstance(value, date): + return value.isoformat() + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(item) for item in value] + return value + + def _build_cluster_entries( snapshots: list[dict[str, Any]], *, @@ -353,6 +372,21 @@ def build_cluster_crop_recommendations(farm_uuid: str) -> dict[str, Any]: if not cluster_entries: raise ClusterRecommendationNotFound("برای این مزرعه هنوز کلاستر قابل استفاده پیدا نشد.") + recommendation_result_ids = sorted( + { + int(cluster_block.result_id) + for cluster_block in cluster_blocks_by_uuid.values() + if cluster_block.result_id + } + ) + cached_payload = _load_cached_cluster_recommendations( + farm_uuid=str(farm.farm_uuid), + result_ids=recommendation_result_ids, + plant_assignments=plant_assignments, + ) + if cached_payload is not None: + return cached_payload + base_payloads: dict[str, dict[str, Any]] = {} for assignment in plant_assignments: plant_name = str(getattr(assignment.plant, "name", "") or "").strip() @@ -392,7 +426,7 @@ def build_cluster_crop_recommendations(farm_uuid: str) -> dict[str, Any]: } ) - return { + payload = { "farm_uuid": str(farm.farm_uuid), "location_id": location.id, "evaluated_plant_count": len(base_payloads), @@ -413,3 +447,68 @@ def build_cluster_crop_recommendations(farm_uuid: str) -> dict[str, Any]: "snapshot_block_count": len(snapshots), }, } + _store_cached_cluster_recommendations( + farm_uuid=str(farm.farm_uuid), + result_ids=recommendation_result_ids, + plant_assignments=plant_assignments, + payload=payload, + ) + return payload + + +def _build_assignment_cache_signature(plant_assignments: list[Any]) -> list[dict[str, Any]]: + return [ + { + "plant_id": getattr(assignment.plant, "backend_plant_id", None), + "position": int(assignment.position or 0), + "stage": str(assignment.stage or ""), + } + for assignment in plant_assignments + ] + + +def _load_cached_cluster_recommendations( + *, + farm_uuid: str, + result_ids: list[int], + plant_assignments: list[Any], +) -> dict[str, Any] | None: + if not result_ids: + return None + cache_key = f"farm::{farm_uuid}" + assignment_signature = _build_assignment_cache_signature(plant_assignments) + for result in RemoteSensingSubdivisionResult.objects.filter(id__in=result_ids): + metadata = dict(result.metadata or {}) + recommendation_cache = dict(metadata.get("cluster_recommendations") or {}) + cached_entry = recommendation_cache.get(cache_key) + if not isinstance(cached_entry, dict): + continue + if cached_entry.get("assignment_signature") != assignment_signature: + continue + payload = cached_entry.get("payload") + if isinstance(payload, dict): + return payload + return None + + +def _store_cached_cluster_recommendations( + *, + farm_uuid: str, + result_ids: list[int], + plant_assignments: list[Any], + payload: dict[str, Any], +) -> None: + if not result_ids: + return + cache_key = f"farm::{farm_uuid}" + assignment_signature = _build_assignment_cache_signature(plant_assignments) + for result in RemoteSensingSubdivisionResult.objects.filter(id__in=result_ids): + metadata = dict(result.metadata or {}) + recommendation_cache = dict(metadata.get("cluster_recommendations") or {}) + recommendation_cache[cache_key] = { + "assignment_signature": assignment_signature, + "payload": _json_safe(payload), + } + metadata["cluster_recommendations"] = recommendation_cache + result.metadata = metadata + result.save(update_fields=["metadata", "updated_at"]) diff --git a/location_data/test_cluster_block_live_api.py b/location_data/test_cluster_block_live_api.py index 2137f0f..77137b4 100644 --- a/location_data/test_cluster_block_live_api.py +++ b/location_data/test_cluster_block_live_api.py @@ -8,6 +8,7 @@ from rest_framework.test import APIClient from location_data.models import ( AnalysisGridCell, + AnalysisGridObservation, BlockSubdivision, RemoteSensingClusterBlock, RemoteSensingRun, @@ -193,3 +194,61 @@ class RemoteSensingClusterBlockLiveApiTests(TestCase): expected_start = expected_end - timedelta(days=6) self.assertEqual(kwargs["temporal_start"], expected_start) self.assertEqual(kwargs["temporal_end"], expected_end) + + @patch("location_data.views.compute_remote_sensing_metrics") + def test_get_cluster_block_live_uses_database_cache_for_matching_window(self, compute_mock): + cell_1 = AnalysisGridCell.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + cell_code="cell-1", + chunk_size_sqm=900, + geometry=self.boundary, + centroid_lat="35.689250", + centroid_lon="51.389250", + ) + cell_2 = AnalysisGridCell.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + cell_code="cell-2", + chunk_size_sqm=900, + geometry=self.boundary, + centroid_lat="35.689750", + centroid_lon="51.389750", + ) + AnalysisGridObservation.objects.create( + cell=cell_1, + run=self.run, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + ndvi=0.44, + ndwi=0.12, + soil_vv=0.09, + soil_vv_db=-11.0, + metadata={"backend_name": "openeo"}, + ) + AnalysisGridObservation.objects.create( + cell=cell_2, + run=self.run, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + ndvi=0.64, + ndwi=0.22, + soil_vv=0.19, + soil_vv_db=-7.0, + metadata={"backend_name": "openeo"}, + ) + + response = self.client.get( + f"/remote-sensing/cluster-blocks/{self.cluster_block.uuid}/live/", + data={"temporal_start": "2025-01-01", "temporal_end": "2025-01-31"}, + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["source"], "database") + self.assertTrue(payload["metadata"]["cache_hit"]) + self.assertEqual(payload["summary"]["ndvi_mean"], 0.54) + self.assertEqual(payload["metrics"]["soil_vv_db"], -9.0) + compute_mock.assert_not_called() diff --git a/location_data/test_cluster_recommendation_api.py b/location_data/test_cluster_recommendation_api.py index ea4ee33..239acee 100644 --- a/location_data/test_cluster_recommendation_api.py +++ b/location_data/test_cluster_recommendation_api.py @@ -279,3 +279,37 @@ class RemoteSensingClusterRecommendationApiTests(TestCase): response.json()["msg"], "برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.", ) + + @patch("location_data.cluster_recommendation._simulate_candidate") + def test_cluster_recommendations_use_cached_payload_for_same_farm_assignments(self, simulate_mock): + simulate_mock.return_value = ( + { + "engine": "pcse", + "model_name": "Wofost81_NWLP_CWB_CNB", + "metrics": { + "yield_estimate": 100.0, + "biomass": 200.0, + "max_lai": 3.1, + }, + }, + None, + ) + + first_response = self.client.get( + "/remote-sensing/cluster-recommendations/", + data={"farm_uuid": str(self.farm.farm_uuid)}, + ) + self.assertEqual(first_response.status_code, 200) + self.assertGreater(simulate_mock.call_count, 0) + + simulate_mock.reset_mock() + simulate_mock.side_effect = AssertionError("cached recommendations should skip simulation") + + second_response = self.client.get( + "/remote-sensing/cluster-recommendations/", + data={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + self.assertEqual(second_response.status_code, 200) + self.assertEqual(first_response.json()["data"], second_response.json()["data"]) + simulate_mock.assert_not_called() diff --git a/location_data/test_remote_sensing_api.py b/location_data/test_remote_sensing_api.py index 934d3fa..5525d3a 100644 --- a/location_data/test_remote_sensing_api.py +++ b/location_data/test_remote_sensing_api.py @@ -46,7 +46,7 @@ class RemoteSensingApiTests(TestCase): self.farm = SensorData.objects.create( farm_uuid="11111111-1111-1111-1111-111111111111", center_location=self.location, - payload={}, + sensor_payload={}, ) self.temporal_end = timezone.localdate() - timedelta(days=1) self.temporal_start = self.temporal_end - timedelta(days=30) @@ -176,6 +176,241 @@ class RemoteSensingApiTests(TestCase): 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, diff --git a/location_data/views.py b/location_data/views.py index 39be17d..a564f94 100644 --- a/location_data/views.py +++ b/location_data/views.py @@ -1,6 +1,7 @@ from datetime import timedelta from types import SimpleNamespace from typing import Any +from uuid import uuid4 from django.apps import apps from django.core.paginator import EmptyPage, Paginator @@ -416,9 +417,16 @@ class RemoteSensingAnalysisView(APIView): @extend_schema( tags=["Location Data"], summary="اجرای async تحلیل سنجش‌ازدور و subdivision داده‌محور", - description="برای location موجود، pipeline کامل grid + openEO + observation persistence + KMeans clustering در Celery صف می‌شود و sync اجرا نمی‌شود.", + description=( + "اگر خروجی cache شده برای مزرعه موجود باشد، همان داده مستقیم برگردانده می‌شود. " + "در غیر این صورت pipeline کامل grid + openEO + observation persistence + KMeans clustering در Celery صف می‌شود." + ), request=RemoteSensingFarmRequestSerializer, responses={ + 200: build_response( + RemoteSensingEnvelopeSerializer, + "خروجی cache شده remote sensing بدون enqueue کردن Celery بازگردانده شد.", + ), 202: build_response( RemoteSensingQueuedEnvelopeSerializer, "درخواست تحلیل سنجش‌ازدور در صف قرار گرفت.", @@ -462,6 +470,28 @@ class RemoteSensingAnalysisView(APIView): temporal_end = timezone.localdate() - timedelta(days=1) temporal_start = temporal_end - timedelta(days=30) + if not payload.get("force_refresh", False): + cached_response = _build_cached_remote_sensing_response( + location=location, + farm_uuid=str(payload["farm_uuid"]), + block_code="", + start_date=temporal_start, + end_date=temporal_end, + page=payload.get("page", 1), + page_size=payload.get("page_size", 100), + ) + if cached_response is not None: + processing = cached_response.get("status") == "processing" + status_code = status.HTTP_202_ACCEPTED if processing else status.HTTP_200_OK + response_payload = cached_response + return Response( + { + "code": 202 if status_code == status.HTTP_202_ACCEPTED else 200, + "msg": "success" if processing else "داده cache شده بازگردانده شد.", + "data": response_payload, + }, + status=status_code, + ) run = RemoteSensingRun.objects.create( soil_location=location, block_code="", @@ -471,6 +501,7 @@ class RemoteSensingAnalysisView(APIView): status=RemoteSensingRun.STATUS_PENDING, metadata={ "requested_via": "api", + "stage": "queued", "status_label": "pending", "requested_cluster_count": None, "selected_features": list(DEFAULT_CLUSTER_FEATURES), @@ -585,92 +616,15 @@ class RemoteSensingAnalysisView(APIView): temporal_end = timezone.localdate() - timedelta(days=1) temporal_start = temporal_end - timedelta(days=30) - block_code = "" - observations = _get_remote_sensing_observations( + response_payload = _build_cached_remote_sensing_response( location=location, - block_code=block_code, + farm_uuid=str(payload["farm_uuid"]), + block_code="", start_date=temporal_start, end_date=temporal_end, - ) - run = _get_latest_remote_sensing_run( - location=location, - block_code=block_code, - start_date=temporal_start, - end_date=temporal_end, - ) - subdivision_result = _get_remote_sensing_subdivision_result( - location=location, - block_code=block_code, - start_date=temporal_start, - end_date=temporal_end, - ) - - if not observations.exists(): - processing = run is not None and run.status in { - RemoteSensingRun.STATUS_PENDING, - RemoteSensingRun.STATUS_RUNNING, - } - response_payload = { - "status": "processing" if processing else "not_found", - "source": "processing" if processing else "database", - "location": SoilLocationResponseSerializer(location).data, - "block_code": "", - "chunk_size_sqm": getattr(run, "chunk_size_sqm", None), - "temporal_extent": { - "start_date": temporal_start.isoformat(), - "end_date": temporal_end.isoformat(), - }, - "summary": _empty_remote_sensing_summary(), - "cells": [], - "run": RemoteSensingRunSerializer(run).data if run else None, - "subdivision_result": None, - } - return Response( - {"code": 200, "msg": "success", "data": response_payload}, - status=status.HTTP_200_OK, - ) - - paginated_observations = _paginate_observations( - observations, page=payload["page"], page_size=payload["page_size"], ) - paginated_assignments = [] - pagination = {"cells": paginated_observations["pagination"]} - if subdivision_result is not None: - paginated = _paginate_assignments( - subdivision_result, - page=payload["page"], - page_size=payload["page_size"], - ) - paginated_assignments = paginated["items"] - pagination["assignments"] = paginated["pagination"] - - cells_data = RemoteSensingCellObservationSerializer(paginated_observations["items"], many=True).data - subdivision_data = None - if subdivision_result is not None: - subdivision_data = RemoteSensingSubdivisionResultSerializer( - subdivision_result, - context={"paginated_assignments": paginated_assignments}, - ).data - - response_payload = { - "status": "success", - "source": "database", - "location": SoilLocationResponseSerializer(location).data, - "block_code": "", - "chunk_size_sqm": observations.first().cell.chunk_size_sqm, - "temporal_extent": { - "start_date": temporal_start.isoformat(), - "end_date": temporal_end.isoformat(), - }, - "summary": _build_remote_sensing_summary(observations), - "cells": cells_data, - "run": RemoteSensingRunSerializer(run).data if run else None, - "subdivision_result": subdivision_data, - } - if pagination is not None: - response_payload["pagination"] = pagination return Response( {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, @@ -805,6 +759,16 @@ class RemoteSensingClusterBlockLiveView(APIView): ) temporal_start, temporal_end = _resolve_live_remote_sensing_window(serializer.validated_data) + cached_cluster_payload = _build_cached_cluster_block_live_payload( + cluster_block=cluster_block, + temporal_start=temporal_start, + temporal_end=temporal_end, + ) + if cached_cluster_payload is not None: + return Response( + {"code": 200, "msg": "success", "data": cached_cluster_payload}, + status=status.HTTP_200_OK, + ) virtual_cell = _build_virtual_cluster_block_cell(cluster_block=cluster_block, geometry=geometry) try: remote_payload = compute_remote_sensing_metrics( @@ -1055,23 +1019,25 @@ def _build_remote_sensing_run_status_payload(run: RemoteSensingRun, *, page: int if run.status == RemoteSensingRun.STATUS_FAILURE: return status_payload + source_run = _resolve_status_source_run(run) location = _get_location_by_lat_lon(run.soil_location.latitude, run.soil_location.longitude, prefetch=True) observations = _get_remote_sensing_observations( - location=run.soil_location, - block_code=run.block_code, - start_date=run.temporal_start, - end_date=run.temporal_end, + location=source_run.soil_location, + block_code=source_run.block_code, + start_date=source_run.temporal_start, + end_date=source_run.temporal_end, + run=source_run, ) - subdivision_result = getattr(run, "subdivision_result", None) + subdivision_result = _resolve_status_subdivision_result(run, source_run=source_run) response_payload = { **status_payload, "location": SoilLocationResponseSerializer(location).data, - "block_code": run.block_code, - "chunk_size_sqm": run.chunk_size_sqm, + "block_code": source_run.block_code, + "chunk_size_sqm": source_run.chunk_size_sqm, "temporal_extent": { - "start_date": run.temporal_start.isoformat() if run.temporal_start else None, - "end_date": run.temporal_end.isoformat() if run.temporal_end else None, + "start_date": source_run.temporal_start.isoformat() if source_run.temporal_start else None, + "end_date": source_run.temporal_end.isoformat() if source_run.temporal_end else None, }, "summary": _empty_remote_sensing_summary(), "cells": [], @@ -1287,6 +1253,73 @@ def _build_remote_sensing_celery_payload(task_id: str) -> dict | None: return payload +def _create_cached_status_run( + *, + location: SoilLocation, + farm_uuid: str, + block_code: str, + temporal_start, + temporal_end, + cached_response: dict[str, Any], +) -> RemoteSensingRun: + source_run_id = ((cached_response.get("run") or {}).get("id")) + source_result_id = ((cached_response.get("subdivision_result") or {}).get("id")) + task_id = str(uuid4()) + return RemoteSensingRun.objects.create( + soil_location=location, + block_subdivision=None, + block_code=block_code or "", + chunk_size_sqm=int(cached_response.get("chunk_size_sqm") or _resolve_chunk_size_for_location(location, block_code)), + temporal_start=temporal_start, + temporal_end=temporal_end, + status=RemoteSensingRun.STATUS_SUCCESS, + started_at=timezone.now(), + finished_at=timezone.now(), + metadata={ + "requested_via": "api", + "farm_uuid": farm_uuid, + "task_id": task_id, + "stage": "completed", + "status_label": "completed", + "selected_features": list( + ((cached_response.get("subdivision_result") or {}).get("selected_features")) + or ((cached_response.get("run") or {}).get("selected_features")) + or DEFAULT_CLUSTER_FEATURES + ), + "scope": "all_blocks", + "cache_hit": True, + "source_run_id": source_run_id, + "source_result_id": source_result_id, + "timestamps": { + "queued_at": timezone.now().isoformat(), + "completed_at": timezone.now().isoformat(), + }, + }, + ) + + +def _resolve_status_source_run(run: RemoteSensingRun) -> RemoteSensingRun: + source_run_id = dict(run.metadata or {}).get("source_run_id") + if not source_run_id: + return run + return RemoteSensingRun.objects.filter(pk=source_run_id).select_related("soil_location").first() or run + + +def _resolve_status_subdivision_result( + run: RemoteSensingRun, + *, + source_run: RemoteSensingRun, +) -> RemoteSensingSubdivisionResult | None: + source_result_id = dict(run.metadata or {}).get("source_result_id") + if source_result_id: + return ( + RemoteSensingSubdivisionResult.objects.filter(pk=source_result_id) + .prefetch_related("assignments__cell", "cluster_blocks") + .first() + ) + return getattr(source_run, "subdivision_result", None) + + def _get_location_by_lat_lon(lat, lon, *, prefetch: bool = False): lat_rounded = round(lat, 6) lon_rounded = round(lon, 6) @@ -1428,6 +1461,210 @@ def _resolve_chunk_size_for_location(location: SoilLocation, block_code: str) -> return 900 +def _build_cached_remote_sensing_response( + *, + location: SoilLocation, + farm_uuid: str, + block_code: str, + start_date, + end_date, + page: int, + page_size: int, +) -> dict[str, Any] | None: + run = _get_latest_remote_sensing_run( + location=location, + farm_uuid=farm_uuid, + block_code=block_code, + start_date=start_date, + end_date=end_date, + ) + subdivision_result = _get_remote_sensing_subdivision_result( + location=location, + farm_uuid=farm_uuid, + block_code=block_code, + start_date=start_date, + end_date=end_date, + ) + observations = _get_remote_sensing_observations( + location=location, + block_code=block_code, + start_date=start_date, + end_date=end_date, + run=run if run is not None else getattr(subdivision_result, "run", None), + ) + if run is None and subdivision_result is None: + observations = observations.none() + + if not observations.exists(): + fallback_cached_response = _build_fallback_cached_remote_sensing_response( + location=location, + farm_uuid=farm_uuid, + block_code=block_code, + page=page, + page_size=page_size, + ) + if fallback_cached_response is not None: + return fallback_cached_response + if run is None: + return None + processing = run.status in { + RemoteSensingRun.STATUS_PENDING, + RemoteSensingRun.STATUS_RUNNING, + } + source = "processing" if processing else "database" + status_label = "processing" if processing else "not_found" + payload = { + "status": status_label, + "source": source, + "location": SoilLocationResponseSerializer(location).data, + "block_code": block_code or "", + "chunk_size_sqm": getattr(run, "chunk_size_sqm", None), + "temporal_extent": { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + "summary": _empty_remote_sensing_summary(), + "cells": [], + "run": RemoteSensingRunSerializer(run).data, + "subdivision_result": None, + "metadata": { + "farm_uuid": farm_uuid, + "cache_hit": True, + }, + } + return payload + + paginated_observations = _paginate_observations( + observations, + page=page, + page_size=page_size, + ) + paginated_assignments = [] + pagination = {"cells": paginated_observations["pagination"]} + if subdivision_result is not None: + paginated = _paginate_assignments( + subdivision_result, + page=page, + page_size=page_size, + ) + paginated_assignments = paginated["items"] + pagination["assignments"] = paginated["pagination"] + + subdivision_data = None + if subdivision_result is not None: + subdivision_data = RemoteSensingSubdivisionResultSerializer( + subdivision_result, + context={"paginated_assignments": paginated_assignments}, + ).data + + payload = { + "status": "success", + "source": "database", + "location": SoilLocationResponseSerializer(location).data, + "block_code": block_code or "", + "chunk_size_sqm": observations.first().cell.chunk_size_sqm, + "temporal_extent": { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + "summary": _build_remote_sensing_summary(observations), + "cells": RemoteSensingCellObservationSerializer( + paginated_observations["items"], + many=True, + ).data, + "run": RemoteSensingRunSerializer(run).data if run else None, + "subdivision_result": subdivision_data, + "pagination": pagination, + "metadata": { + "farm_uuid": farm_uuid, + "cache_hit": True, + }, + } + return payload + + +def _build_fallback_cached_remote_sensing_response( + *, + location: SoilLocation, + farm_uuid: str, + block_code: str, + page: int, + page_size: int, +) -> dict[str, Any] | None: + fallback_run = _get_latest_completed_remote_sensing_run( + location=location, + farm_uuid=farm_uuid, + block_code=block_code, + ) + if fallback_run is None: + return None + + fallback_observations = _get_remote_sensing_observations( + location=location, + block_code=block_code, + start_date=fallback_run.temporal_start, + end_date=fallback_run.temporal_end, + run=fallback_run, + ) + if not fallback_observations.exists(): + return None + + fallback_result = _get_remote_sensing_subdivision_result( + location=location, + farm_uuid=farm_uuid, + block_code=block_code, + start_date=fallback_run.temporal_start, + end_date=fallback_run.temporal_end, + ) + paginated_observations = _paginate_observations( + fallback_observations, + page=page, + page_size=page_size, + ) + paginated_assignments = [] + pagination = {"cells": paginated_observations["pagination"]} + if fallback_result is not None: + paginated = _paginate_assignments( + fallback_result, + page=page, + page_size=page_size, + ) + paginated_assignments = paginated["items"] + pagination["assignments"] = paginated["pagination"] + + subdivision_data = None + if fallback_result is not None: + subdivision_data = RemoteSensingSubdivisionResultSerializer( + fallback_result, + context={"paginated_assignments": paginated_assignments}, + ).data + + return { + "status": "success", + "source": "database", + "location": SoilLocationResponseSerializer(location).data, + "block_code": block_code or "", + "chunk_size_sqm": fallback_run.chunk_size_sqm, + "temporal_extent": { + "start_date": fallback_run.temporal_start.isoformat() if fallback_run.temporal_start else None, + "end_date": fallback_run.temporal_end.isoformat() if fallback_run.temporal_end else None, + }, + "summary": _build_remote_sensing_summary(fallback_observations), + "cells": RemoteSensingCellObservationSerializer( + paginated_observations["items"], + many=True, + ).data, + "run": RemoteSensingRunSerializer(fallback_run).data, + "subdivision_result": subdivision_data, + "pagination": pagination, + "metadata": { + "farm_uuid": farm_uuid, + "cache_hit": True, + "cache_match": "latest_completed_for_farm", + }, + } + + def _resolve_live_remote_sensing_window(payload: dict[str, Any]): temporal_start = payload.get("temporal_start") temporal_end = payload.get("temporal_end") @@ -1484,7 +1721,66 @@ def _build_virtual_cluster_block_cell( ) -def _get_remote_sensing_observations(*, location, block_code: str, start_date, end_date): +def _build_cached_cluster_block_live_payload( + *, + cluster_block: RemoteSensingClusterBlock, + temporal_start, + temporal_end, +) -> dict[str, Any] | None: + result = cluster_block.result + if result.temporal_start != temporal_start or result.temporal_end != temporal_end: + return None + + observations = ( + AnalysisGridObservation.objects.select_related("cell") + .filter( + cell__soil_location=cluster_block.soil_location, + cell__cell_code__in=list(cluster_block.cell_codes or []), + temporal_start=temporal_start, + temporal_end=temporal_end, + ) + .order_by("cell__cell_code") + ) + if not observations.exists(): + return None + + metrics = observations.aggregate( + ndvi=Avg("ndvi"), + ndwi=Avg("ndwi"), + soil_vv=Avg("soil_vv"), + soil_vv_db=Avg("soil_vv_db"), + ) + return { + "status": "success", + "source": "database", + "cluster_block": RemoteSensingClusterBlockSerializer(cluster_block).data, + "temporal_extent": { + "start_date": temporal_start.isoformat(), + "end_date": temporal_end.isoformat(), + }, + "selected_features": list(DEFAULT_CLUSTER_FEATURES), + "summary": { + "cell_count": int(cluster_block.cell_count or observations.count()), + "ndvi_mean": _round_or_none(metrics.get("ndvi")), + "ndwi_mean": _round_or_none(metrics.get("ndwi")), + "soil_vv_db_mean": _round_or_none(metrics.get("soil_vv_db")), + }, + "metrics": { + "ndvi": _round_or_none(metrics.get("ndvi")), + "ndwi": _round_or_none(metrics.get("ndwi")), + "soil_vv": _round_or_none(metrics.get("soil_vv")), + "soil_vv_db": _round_or_none(metrics.get("soil_vv_db")), + }, + "metadata": { + "requested_cluster_uuid": str(cluster_block.uuid), + "cache_hit": True, + "source_run_id": result.run_id, + "source_result_id": result.id, + }, + } + + +def _get_remote_sensing_observations(*, location, block_code: str, start_date, end_date, run=None): queryset = ( AnalysisGridObservation.objects.select_related("cell", "run") .filter( @@ -1494,24 +1790,56 @@ def _get_remote_sensing_observations(*, location, block_code: str, start_date, e ) .order_by("cell__cell_code") ) - return queryset.filter(cell__block_code=block_code or "") + queryset = queryset.filter(cell__block_code=block_code or "") + if run is not None: + queryset = queryset.filter(run=run) + return queryset -def _get_latest_remote_sensing_run(*, location, block_code: str, start_date, end_date): - return ( +def _select_farm_scoped_run(runs, farm_uuid: str): + legacy_candidate = None + for run in runs: + metadata = dict(run.metadata or {}) + scoped_farm_uuid = metadata.get("farm_uuid") + if scoped_farm_uuid == farm_uuid: + return run + if scoped_farm_uuid in (None, "") and legacy_candidate is None: + legacy_candidate = run + return legacy_candidate + + +def _get_latest_remote_sensing_run(*, location, farm_uuid: str, block_code: str, start_date, end_date): + runs = list( RemoteSensingRun.objects.filter( soil_location=location, block_code=block_code or "", temporal_start=start_date, temporal_end=end_date, - ) - .order_by("-created_at", "-id") - .first() + ).order_by("-created_at", "-id") ) + return _select_farm_scoped_run(runs, farm_uuid) -def _get_remote_sensing_subdivision_result(*, location, block_code: str, start_date, end_date): - return ( +def _get_latest_completed_remote_sensing_run(*, location, farm_uuid: str, block_code: str): + runs = list( + RemoteSensingRun.objects.filter( + soil_location=location, + block_code=block_code or "", + status=RemoteSensingRun.STATUS_SUCCESS, + ).order_by("-created_at", "-id") + ) + return _select_farm_scoped_run(runs, farm_uuid) + + +def _get_remote_sensing_subdivision_result( + *, + location, + farm_uuid: str, + block_code: str, + start_date, + end_date, +): + results = list( RemoteSensingSubdivisionResult.objects.filter( soil_location=location, block_code=block_code or "", @@ -1521,8 +1849,16 @@ def _get_remote_sensing_subdivision_result(*, location, block_code: str, start_d .select_related("run") .prefetch_related("assignments__cell", "cluster_blocks") .order_by("-created_at", "-id") - .first() ) + legacy_candidate = None + for result in results: + run = getattr(result, "run", None) + scoped_farm_uuid = dict(getattr(run, "metadata", {}) or {}).get("farm_uuid") + if scoped_farm_uuid == farm_uuid: + return result + if scoped_farm_uuid in (None, "") and legacy_candidate is None: + legacy_candidate = result + return legacy_candidate def _build_remote_sensing_summary(observations): diff --git a/plant/PLANT_NAMES_API.md b/plant/PLANT_NAMES_API.md deleted file mode 100644 index cdd6487..0000000 --- a/plant/PLANT_NAMES_API.md +++ /dev/null @@ -1,74 +0,0 @@ -# Plant Names API - -این API فقط لیست نام گیاه‌ها را به همراه آیکون و مراحل رشد برمی‌گرداند. - -## Endpoint - -- `GET /api/plants/names/` - -## کاربرد - -- گرفتن لیست سبک برای dropdown یا selector فرانت -- نمایش نام گیاه -- نمایش `icon` -- نمایش مراحل رشد هر گیاه - -## رفتار API - -- فقط فیلدهای `name`، `icon` و `growth_stages` را برمی‌گرداند -- اگر `growth_stage` برای یک گیاه خالی باشد، API به صورت خودکار این مراحل پیش‌فرض را اضافه و در دیتابیس ذخیره می‌کند: - - `initial` - - `vegetative` - - `flowering` - - `fruiting` - - `maturity` -- اگر `icon` خالی باشد، مقدار پیش‌فرض `leaf` ذخیره و برگردانده می‌شود -- اگر در `growth_profile.stage_thresholds` مرحله‌ای وجود داشته باشد، آن مرحله هم در خروجی `growth_stages` لحاظ می‌شود - -## نمونه درخواست - -```bash -curl -X GET http://localhost:8000/api/plants/names/ -``` - -## نمونه پاسخ - -```json -{ - "code": 200, - "msg": "success", - "data": [ - { - "name": "Tomato", - "icon": "leaf", - "growth_stages": [ - "vegetative", - "flowering", - "fruiting" - ] - }, - { - "name": "Pepper", - "icon": "leaf", - "growth_stages": [ - "initial", - "vegetative", - "flowering", - "fruiting", - "maturity" - ] - } - ] -} -``` - -## فیلدهای خروجی - -- `name`: نام گیاه -- `icon`: آیکون گیاه برای فرانت -- `growth_stages`: آرایه‌ای از مراحل رشد گیاه - -## نکته برای فرانت - -- این endpoint برای لیست سبک طراحی شده و مناسب صفحه‌های انتخاب گیاه است -- اگر جزئیات کامل گیاه لازم دارید، از `GET /api/plants/` یا `GET /api/plants/{id}/` استفاده کنید diff --git a/plant/apps.py b/plant/apps.py index 8d05f55..00b47d2 100644 --- a/plant/apps.py +++ b/plant/apps.py @@ -85,24 +85,27 @@ class PlantConfig(AppConfig): return self.growth_stage_aliases.get(normalized, value) def resolve_plant_name(self, plant_name: str | None) -> str | None: - from .models import Plant + from farm_data.models import PlantCatalogSnapshot value = (plant_name or "").strip() if not value: return value - plant = Plant.objects.filter(name=value).first() or Plant.objects.filter(name__iexact=value).first() + plant = ( + PlantCatalogSnapshot.objects.filter(name=value).first() + or PlantCatalogSnapshot.objects.filter(name__iexact=value).first() + ) if plant is not None: return plant.name normalized = self._normalize_lookup_value(value) alias_target = self.plant_aliases.get(normalized) if alias_target: - aliased_plant = Plant.objects.filter(name=alias_target).first() + aliased_plant = PlantCatalogSnapshot.objects.filter(name=alias_target).first() if aliased_plant is not None: return aliased_plant.name - for plant in Plant.objects.only("name").iterator(): + for plant in PlantCatalogSnapshot.objects.only("name").iterator(): if self._normalize_lookup_value(plant.name) == normalized: return plant.name diff --git a/plant/management/commands/seed_plants.py b/plant/management/commands/seed_plants.py deleted file mode 100644 index 95a6c21..0000000 --- a/plant/management/commands/seed_plants.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Management command to seed initial plant data. -Run: python manage.py seed_plants -""" - -from django.core.management.base import BaseCommand - -from plant.models import Plant - - -INITIAL_PLANTS = [ - { - "name": "گوجه‌فرنگی", - "light": "آفتاب کامل (۶-۸ ساعت)", - "watering": "منظم، هفته‌ای ۲-۳ بار", - "soil": "لومی، غنی از مواد آلی، pH بین ۶-۶.۸", - "temperature": "۲۰-۳۰ درجه سانتی‌گراد", - "planting_season": "بهار", - "harvest_time": "۷۰-۹۰ روز پس از کاشت", - "spacing": "۴۵-۶۰ سانتی‌متر", - "fertilizer": "کود NPK متعادل، کمپوست", - }, - { - "name": "خیار", - "light": "آفتاب کامل", - "watering": "روزانه در فصل گرم", - "soil": "لومی شنی، غنی از هوموس", - "temperature": "۱۸-۳۰ درجه سانتی‌گراد", - "planting_season": "بهار تا اوایل تابستان", - "harvest_time": "۵۰-۷۰ روز پس از کاشت", - "spacing": "۳۰-۴۵ سانتی‌متر", - "fertilizer": "کود ازته، کمپوست", - }, - { - "name": "فلفل دلمه‌ای", - "light": "آفتاب کامل (۶-۸ ساعت)", - "watering": "منظم، هفته‌ای ۲-۳ بار", - "soil": "لومی، زهکشی مناسب", - "temperature": "۲۰-۳۰ درجه سانتی‌گراد", - "planting_season": "بهار", - "harvest_time": "۶۰-۹۰ روز پس از کاشت", - "spacing": "۴۰-۵۰ سانتی‌متر", - "fertilizer": "کود فسفره و پتاسه", - }, - { - "name": "هویج", - "light": "آفتاب کامل تا نیمه‌سایه", - "watering": "منظم، خاک مرطوب", - "soil": "شنی لومی، عمیق، بدون سنگ", - "temperature": "۱۵-۲۵ درجه سانتی‌گراد", - "planting_season": "اوایل بهار یا پاییز", - "harvest_time": "۷۰-۸۰ روز پس از کاشت", - "spacing": "۵-۸ سانتی‌متر", - "fertilizer": "کود پتاسه، کمپوست پوسیده", - }, - { - "name": "کاهو", - "light": "نیمه‌سایه تا آفتاب کامل", - "watering": "منظم، خاک مرطوب", - "soil": "لومی، غنی از مواد آلی", - "temperature": "۱۰-۲۰ درجه سانتی‌گراد", - "planting_season": "بهار و پاییز", - "harvest_time": "۴۵-۶۰ روز پس از کاشت", - "spacing": "۲۰-۳۰ سانتی‌متر", - "fertilizer": "کود ازته، کمپوست", - }, - { - "name": "سیب‌زمینی", - "light": "آفتاب کامل", - "watering": "منظم، هفته‌ای ۲ بار", - "soil": "لومی شنی، اسیدی ملایم، pH بین ۵-۶", - "temperature": "۱۵-۲۲ درجه سانتی‌گراد", - "planting_season": "اواخر زمستان تا اوایل بهار", - "harvest_time": "۹۰-۱۲۰ روز پس از کاشت", - "spacing": "۳۰-۴۰ سانتی‌متر", - "fertilizer": "کود NPK، کمپوست", - }, - { - "name": "پیاز", - "light": "آفتاب کامل", - "watering": "منظم، خاک مرطوب ولی نه غرقابی", - "soil": "لومی، زهکشی خوب", - "temperature": "۱۲-۲۴ درجه سانتی‌گراد", - "planting_season": "پاییز یا اوایل بهار", - "harvest_time": "۹۰-۱۵۰ روز پس از کاشت", - "spacing": "۱۰-۱۵ سانتی‌متر", - "fertilizer": "کود فسفره، سولفات پتاسیم", - }, -] - - -class Command(BaseCommand): - help = "Seed initial plant data (7 common vegetables)" - - def handle(self, *args, **options): - created_count = 0 - for plant_data in INITIAL_PLANTS: - _, created = Plant.objects.get_or_create( - name=plant_data["name"], - defaults=plant_data, - ) - if created: - created_count += 1 - self.stdout.write( - self.style.SUCCESS(f" Created: {plant_data['name']}") - ) - self.stdout.write( - self.style.SUCCESS(f"\nDone. Created {created_count} new plants.") - ) diff --git a/plant/serializers.py b/plant/serializers.py deleted file mode 100644 index 37a1822..0000000 --- a/plant/serializers.py +++ /dev/null @@ -1,64 +0,0 @@ -from rest_framework import serializers - -from .models import Plant - - -DEFAULT_PLANT_GROWTH_STAGES = [ - "initial", - "vegetative", - "flowering", - "fruiting", - "maturity", -] - - -def normalize_growth_stage_values(plant: Plant) -> list[str]: - stages: list[str] = [] - - raw_stage = (plant.growth_stage or "").replace("،", ",") - for part in raw_stage.split(","): - value = part.strip() - if value and value not in stages: - stages.append(value) - - stage_thresholds = plant.growth_profile.get("stage_thresholds", {}) - if isinstance(stage_thresholds, dict): - for stage_name in stage_thresholds.keys(): - value = str(stage_name).strip() - if value and value not in stages: - stages.append(value) - - if not stages: - stages = list(DEFAULT_PLANT_GROWTH_STAGES) - - return stages - - -class PlantSerializer(serializers.ModelSerializer): - """سریالایزر خروجی / ورودی برای Plant.""" - - class Meta: - model = Plant - fields = [ - "id", - "name", - "icon", - "light", - "watering", - "soil", - "temperature", - "growth_stage", - "planting_season", - "harvest_time", - "spacing", - "fertilizer", - "created_at", - "updated_at", - ] - read_only_fields = ["id", "created_at", "updated_at"] - - -class PlantNameStageSerializer(serializers.Serializer): - name = serializers.CharField() - icon = serializers.CharField() - growth_stages = serializers.ListField(child=serializers.CharField()) diff --git a/plant/services.py b/plant/services.py deleted file mode 100644 index 4095dae..0000000 --- a/plant/services.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -سرویس‌های گیاه — دریافت مشخصات گیاه از API خارجی بر اساس نام. -""" - -import logging - -logger = logging.getLogger(__name__) - - -def fetch_plant_info_from_api(plant_name: str) -> dict | None: - """ - اتصال به API خارجی و دریافت مشخصات گیاه بر اساس نام. - - TODO: پیاده‌سازی اتصال واقعی به API. - در حال حاضر این تابع خالی است و None برمی‌گرداند. - - پارامترها: - plant_name: نام گیاه - - خروجی مورد انتظار (وقتی پیاده‌سازی شود): - { - "name": "گوجه‌فرنگی", - "light": "آفتاب کامل", - "watering": "منظم، هفته‌ای ۲-۳ بار", - "soil": "لومی، غنی از مواد آلی", - "temperature": "۲۰-۳۰ درجه سانتی‌گراد", - "planting_season": "بهار", - "harvest_time": "۷۰-۹۰ روز پس از کاشت", - "spacing": "۴۵-۶۰ سانتی‌متر", - "fertilizer": "کود NPK متعادل", - } - """ - # TODO: اتصال واقعی به API - return None diff --git a/plant/urls.py b/plant/urls.py deleted file mode 100644 index 67a4280..0000000 --- a/plant/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.urls import path - -from .views import ( - PlantDetailView, - PlantFetchInfoView, - PlantListCreateView, - PlantNameStageListView, -) - -urlpatterns = [ - path("", PlantListCreateView.as_view(), name="plant-list-create"), - path("names/", PlantNameStageListView.as_view(), name="plant-name-stage-list"), - path("/", PlantDetailView.as_view(), name="plant-detail"), - path("fetch-info/", PlantFetchInfoView.as_view(), name="plant-fetch-info"), -] diff --git a/plant/views.py b/plant/views.py deleted file mode 100644 index ec86d2f..0000000 --- a/plant/views.py +++ /dev/null @@ -1,364 +0,0 @@ -from drf_spectacular.utils import ( - OpenApiExample, - OpenApiResponse, - extend_schema, - inline_serializer, -) -from rest_framework import serializers as drf_serializers -from rest_framework import status -from rest_framework.response import Response -from rest_framework.views import APIView - -from config.openapi import build_envelope_serializer, build_response -from .models import Plant -from .serializers import ( - PlantNameStageSerializer, - PlantSerializer, - normalize_growth_stage_values, -) -from .services import fetch_plant_info_from_api - - -PlantListResponseSerializer = build_envelope_serializer( - "PlantListResponseSerializer", - PlantSerializer, - many=True, -) -PlantDetailResponseSerializer = build_envelope_serializer( - "PlantDetailResponseSerializer", - PlantSerializer, -) -PlantValidationErrorSerializer = build_envelope_serializer( - "PlantValidationErrorSerializer", - data_required=False, - allow_null=True, -) -PlantFetchInfoResponseSerializer = build_envelope_serializer( - "PlantFetchInfoResponseSerializer", - PlantSerializer, -) -PlantNameStageListResponseSerializer = build_envelope_serializer( - "PlantNameStageListResponseSerializer", - PlantNameStageSerializer, - many=True, -) - - -class PlantListCreateView(APIView): - """لیست تمام گیاهان و ایجاد گیاه جدید.""" - - @extend_schema( - tags=["Plant"], - summary="لیست گیاهان", - description="لیست تمام گیاهان ذخیره‌شده را برمی‌گرداند.", - responses={ - 200: build_response( - PlantListResponseSerializer, - "لیست گیاهان ذخیره‌شده.", - ), - }, - ) - def get(self, request): - plants = Plant.objects.all() - serializer = PlantSerializer(plants, many=True) - return Response( - {"code": 200, "msg": "success", "data": serializer.data}, - status=status.HTTP_200_OK, - ) - - @extend_schema( - tags=["Plant"], - summary="ایجاد گیاه جدید", - description="یک گیاه جدید با مشخصات داده‌شده ایجاد می‌کند.", - request=PlantSerializer, - responses={ - 201: build_response( - PlantDetailResponseSerializer, - "گیاه جدید با موفقیت ایجاد شد.", - ), - 400: build_response( - PlantValidationErrorSerializer, - "داده ورودی نامعتبر است.", - ), - }, - examples=[ - OpenApiExample( - "نمونه درخواست", - value={ - "name": "گوجه‌فرنگی", - "light": "آفتاب کامل", - "watering": "منظم، هفته‌ای ۲-۳ بار", - "soil": "لومی، غنی از مواد آلی", - "temperature": "۲۰-۳۰ درجه سانتی‌گراد", - "growth_stage": "رشد رویشی", - "planting_season": "بهار", - "harvest_time": "۷۰-۹۰ روز پس از کاشت", - "spacing": "۴۵-۶۰ سانتی‌متر", - "fertilizer": "کود NPK متعادل", - }, - request_only=True, - ), - ], - ) - def post(self, request): - serializer = PlantSerializer(data=request.data) - if not serializer.is_valid(): - return Response( - {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, - status=status.HTTP_400_BAD_REQUEST, - ) - serializer.save() - return Response( - {"code": 201, "msg": "success", "data": serializer.data}, - status=status.HTTP_201_CREATED, - ) - - -class PlantNameStageListView(APIView): - """لیست سبک از نام گیاه، آیکون و مراحل رشد.""" - - @extend_schema( - tags=["Plant"], - summary="لیست نام گیاهان با مراحل رشد", - description=( - "فقط نام گیاه، آیکون و مراحل رشد را برمی‌گرداند. " - "اگر برای گیاهی مرحله رشد ثبت نشده باشد، مراحل پیش‌فرض به آن اضافه و ذخیره می‌شود." - ), - responses={ - 200: build_response( - PlantNameStageListResponseSerializer, - "لیست نام گیاهان به همراه مراحل رشد و آیکون.", - ), - }, - ) - def get(self, request): - payload = [] - for plant in Plant.objects.all(): - growth_stages = normalize_growth_stage_values(plant) - serialized_stages = ", ".join(growth_stages) - update_fields: list[str] = [] - - if plant.growth_stage != serialized_stages: - plant.growth_stage = serialized_stages - update_fields.append("growth_stage") - if not plant.icon: - plant.icon = "leaf" - update_fields.append("icon") - if update_fields: - update_fields.append("updated_at") - plant.save(update_fields=update_fields) - - payload.append( - { - "name": plant.name, - "icon": plant.icon, - "growth_stages": growth_stages, - } - ) - - serializer = PlantNameStageSerializer(payload, many=True) - return Response( - {"code": 200, "msg": "success", "data": serializer.data}, - status=status.HTTP_200_OK, - ) - - -class PlantDetailView(APIView): - """دریافت، ویرایش و حذف یک گیاه.""" - - def _get_plant(self, pk): - return Plant.objects.filter(pk=pk).first() - - @extend_schema( - tags=["Plant"], - summary="جزئیات گیاه", - description="مشخصات یک گیاه را بر اساس شناسه برمی‌گرداند.", - responses={ - 200: build_response( - PlantDetailResponseSerializer, - "جزئیات گیاه.", - ), - 404: build_response( - PlantValidationErrorSerializer, - "گیاه یافت نشد.", - ), - }, - ) - def get(self, request, pk): - plant = self._get_plant(pk) - if not plant: - return Response( - {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, - status=status.HTTP_404_NOT_FOUND, - ) - serializer = PlantSerializer(plant) - return Response( - {"code": 200, "msg": "success", "data": serializer.data}, - status=status.HTTP_200_OK, - ) - - @extend_schema( - tags=["Plant"], - summary="ویرایش کامل گیاه", - description="تمام فیلدهای یک گیاه را آپدیت می‌کند.", - request=PlantSerializer, - responses={ - 200: build_response( - PlantDetailResponseSerializer, - "گیاه با موفقیت به‌روزرسانی شد.", - ), - 400: build_response( - PlantValidationErrorSerializer, - "داده ورودی نامعتبر است.", - ), - 404: build_response( - PlantValidationErrorSerializer, - "گیاه یافت نشد.", - ), - }, - ) - def put(self, request, pk): - plant = self._get_plant(pk) - if not plant: - return Response( - {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, - status=status.HTTP_404_NOT_FOUND, - ) - serializer = PlantSerializer(plant, data=request.data) - if not serializer.is_valid(): - return Response( - {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, - status=status.HTTP_400_BAD_REQUEST, - ) - serializer.save() - return Response( - {"code": 200, "msg": "success", "data": serializer.data}, - status=status.HTTP_200_OK, - ) - - @extend_schema( - tags=["Plant"], - summary="ویرایش جزئی گیاه", - description="فقط فیلدهای ارسال‌شده آپدیت می‌شوند.", - request=PlantSerializer, - responses={ - 200: build_response( - PlantDetailResponseSerializer, - "گیاه با موفقیت به‌روزرسانی شد.", - ), - 400: build_response( - PlantValidationErrorSerializer, - "داده ورودی نامعتبر است.", - ), - 404: build_response( - PlantValidationErrorSerializer, - "گیاه یافت نشد.", - ), - }, - ) - def patch(self, request, pk): - plant = self._get_plant(pk) - if not plant: - return Response( - {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, - status=status.HTTP_404_NOT_FOUND, - ) - serializer = PlantSerializer(plant, data=request.data, partial=True) - if not serializer.is_valid(): - return Response( - {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, - status=status.HTTP_400_BAD_REQUEST, - ) - serializer.save() - return Response( - {"code": 200, "msg": "success", "data": serializer.data}, - status=status.HTTP_200_OK, - ) - - @extend_schema( - tags=["Plant"], - summary="حذف گیاه", - description="یک گیاه را حذف می‌کند.", - responses={ - 200: build_response( - PlantValidationErrorSerializer, - "گیاه با موفقیت حذف شد.", - ), - 404: build_response( - PlantValidationErrorSerializer, - "گیاه یافت نشد.", - ), - }, - ) - def delete(self, request, pk): - plant = self._get_plant(pk) - if not plant: - return Response( - {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, - status=status.HTTP_404_NOT_FOUND, - ) - plant.delete() - return Response( - {"code": 200, "msg": "گیاه با موفقیت حذف شد.", "data": None}, - status=status.HTTP_200_OK, - ) - - -class PlantFetchInfoView(APIView): - """دریافت مشخصات گیاه از API خارجی بر اساس نام.""" - - @extend_schema( - tags=["Plant"], - summary="دریافت مشخصات گیاه از API خارجی", - description="بر اساس نام گیاه، مشخصات آن را از API خارجی دریافت می‌کند. (فعلاً خالی)", - request=inline_serializer( - name="PlantFetchInfoRequest", - fields={ - "name": drf_serializers.CharField(help_text="نام گیاه"), - }, - ), - responses={ - 200: build_response( - PlantFetchInfoResponseSerializer, - "اطلاعات گیاه از سرویس خارجی دریافت شد.", - ), - 400: build_response( - PlantValidationErrorSerializer, - "نام گیاه ارسال نشده است.", - ), - 503: build_response( - PlantValidationErrorSerializer, - "سرویس خارجی در دسترس نیست.", - ), - }, - examples=[ - OpenApiExample( - "نمونه درخواست", - value={"name": "گوجه‌فرنگی"}, - request_only=True, - ), - ], - ) - def post(self, request): - plant_name = request.data.get("name") - if not plant_name: - return Response( - {"code": 400, "msg": "نام گیاه الزامی است.", "data": None}, - status=status.HTTP_400_BAD_REQUEST, - ) - - result = fetch_plant_info_from_api(plant_name) - if result is None: - return Response( - { - "code": 503, - "msg": "سرویس API هنوز پیاده‌سازی نشده است.", - "data": None, - }, - status=status.HTTP_503_SERVICE_UNAVAILABLE, - ) - - return Response( - {"code": 200, "msg": "success", "data": result}, - status=status.HTTP_200_OK, - )