From 64e67c282c6067157bc12191d15c4d6d34ad767b Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Mon, 27 Apr 2026 00:40:59 +0330 Subject: [PATCH] UPDATE --- AI_ROUTE_CONNECTION_AUDIT.md | 78 +++++ API_DATA_SOURCE_AUDIT_FA.md | 328 ++++++++++++++++++++ API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md | 84 +++++ DASHBOARD_CARD_SOURCES.md | 12 +- FRONTEND_PAGES_APIS_GUIDE.md | 18 +- config/settings.py | 38 +++ config/swagger.py | 27 +- config/urls.py | 21 +- crop_health/mock_data.py | 8 + crop_health/serializers.py | 19 +- crop_health/tests.py | 110 +++++++ crop_health/urls.py | 3 +- crop_health/views.py | 75 ++++- economic_overview/serializers.py | 18 +- economic_overview/tests.py | 81 +++++ economic_overview/urls.py | 4 +- economic_overview/views.py | 94 +++++- external_api_adapter/adapter.py | 35 +++ farm_ai_assistant/views.py | 56 ++++ farm_alerts/serializers.py | 12 +- farm_alerts/urls.py | 11 +- farm_alerts/views.py | 127 ++++---- farm_hub/API_REFERENCE_FA.md | 85 ++++- farm_hub/serializers.py | 45 ++- farm_hub/services.py | 141 +++++++++ farm_hub/tests.py | 122 +++++++- farm_hub/views.py | 27 +- fertilization_recommendation/serializers.py | 44 +-- fertilization_recommendation/urls.py | 7 +- fertilization_recommendation/views.py | 131 +++++--- irrigation_recommendation/serializers.py | 67 ++-- irrigation_recommendation/tests.py | 141 +++++++++ irrigation_recommendation/urls.py | 9 +- irrigation_recommendation/views.py | 263 ++++++++++++---- pest_detection/pest_disease_urls.py | 9 + pest_detection/serializers.py | 75 ++++- pest_detection/tests.py | 229 ++++++++++++++ pest_detection/urls.py | 9 +- pest_detection/views.py | 293 ++++++++++++----- sensor_7_in_1/tests.py | 22 +- sensor_7_in_1/urls.py | 9 +- sensor_7_in_1/views.py | 51 ++- sensor_external_api/services.py | 30 +- sensor_external_api/tests.py | 28 +- soil/serializers.py | 60 +++- soil/tests.py | 210 +++++++++++++ soil/urls.py | 4 - soil/views.py | 172 ++++++---- water/serializers.py | 35 ++- water/tests.py | 108 +++++++ water/views.py | 198 +++++++++--- water/weather_urls.py | 7 + yield_harvest/serializers.py | 146 ++++++--- yield_harvest/tests.py | 294 ++++++++++++++++++ yield_harvest/urls.py | 26 +- yield_harvest/views.py | 301 +++++++++++++----- 56 files changed, 3912 insertions(+), 745 deletions(-) create mode 100644 AI_ROUTE_CONNECTION_AUDIT.md create mode 100644 API_DATA_SOURCE_AUDIT_FA.md create mode 100644 API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md create mode 100644 crop_health/tests.py create mode 100644 economic_overview/tests.py create mode 100644 irrigation_recommendation/tests.py create mode 100644 pest_detection/pest_disease_urls.py create mode 100644 pest_detection/tests.py create mode 100644 soil/tests.py create mode 100644 water/tests.py create mode 100644 water/weather_urls.py create mode 100644 yield_harvest/tests.py diff --git a/AI_ROUTE_CONNECTION_AUDIT.md b/AI_ROUTE_CONNECTION_AUDIT.md new file mode 100644 index 0000000..2784191 --- /dev/null +++ b/AI_ROUTE_CONNECTION_AUDIT.md @@ -0,0 +1,78 @@ +# بررسی اتصال routeهای درخواستی به سرویس AI + +این گزارش فقط بر اساس کد backend تهیه شده و معیار آن این است: + +- آیا در کد، `external_api_request(...)` یا `external_request(...)` با **همین route** به سرویس `ai` زده می‌شود یا نه +- اگر با route دیگری به AI متصل شده باشد، route واقعی ذکر شده +- اگر اصلا اتصال AI برای آن route پیدا نشود، به عنوان `متصل نیست` علامت خورده + +## متصل به AI با همین route + +| API | اتصال | شواهد | +|---|---|---| +| `POST /api/rag/chat/` | بله | `farm_ai_assistant/views.py:511` | +| `POST /api/soile/moisture-heatmap/` | بله | `soil/views.py:136` | +| `POST /api/soile/health-summary/` | بله | `soil/views.py:182` | +| `POST /api/soile/anomaly-detection/` | بله | `soil/views.py:90` | +| `POST /api/farm-data/` | بله | `farm_hub/services.py:166`, `farm_hub/services.py:89`, `sensor_external_api/services.py:165`, `sensor_external_api/services.py:125` | +| `POST /api/weather/water-need-prediction/` | بله | `water/views.py:136` | +| `POST /api/economy/overview/` | بله | `economic_overview/views.py:73` | +| `GET /api/irrigation/` | بله | `irrigation_recommendation/views.py:78` | +| `POST /api/irrigation/recommend/` | بله | `irrigation_recommendation/views.py:165` | +| `POST /api/fertilization/recommend/` | بله | `fertilization_recommendation/views.py:122` | +| `POST /api/crop-simulation/growth/` | بله | `yield_harvest/views.py:247` | +| `GET /api/crop-simulation/growth//status/` | بله | `yield_harvest/views.py:293` | +| `POST /api/crop-simulation/current-farm-chart/` | بله | `yield_harvest/views.py:145`, `yield_harvest/views.py:162` | +| `POST /api/crop-simulation/harvest-prediction/` | بله | `yield_harvest/views.py:174`, `yield_harvest/views.py:191` | +| `POST /api/crop-simulation/yield-prediction/` | بله | `yield_harvest/views.py:203`, `yield_harvest/views.py:220` | + +## به AI وصل هستند، اما نه با همین route + +| API درخواستی | وضعیت | route واقعی AI در کد | شواهد | +|---|---|---|---| +| `POST /api/weather/farm-card/` | با همین route به AI وصل نیست | `GET /weather-forecast/card` | `water/views.py:49` | +| `POST /api/irrigation/water-stress/` | با همین route به AI وصل نیست | `GET /api/water/stress-index/` | `irrigation_recommendation/views.py:246` | +| `POST /api/pest-disease/detect/` | با همین route به AI وصل نیست | `POST /api/pest-detection/analyze/` | `pest_detection/views.py:161` | +| `POST /api/pest-disease/risk/` | با همین route به AI وصل نیست | `POST /api/pest-detection/risk/` | `pest_detection/views.py:202` | +| `POST /api/pest-disease/risk-summary/` | با همین route به AI وصل نیست | `GET /api/pest-detection/risk-summary/` | `pest_detection/views.py:235` | +| `POST /api/soil-data/ndvi-health/` | با همین route به AI وصل نیست | برای این path اتصال AI پیدا نشد؛ endpoint محلی پروژه با path دیگری ارائه شده | `crop_health/urls.py:6`, `crop_health/tests.py:82` | +| `POST /api/irrigation/` | route به AI با همین method پیدا نشد | فقط `GET /api/irrigation/` در کد استفاده می‌شود | `irrigation_recommendation/views.py:78` | + +## متصل نیستند + +برای این routeها هیچ اتصال معناداری به سرویس AI با همین path در کد پیدا نشد. + +| API | وضعیت | توضیح | +|---|---|---| +| `POST /api/farm-alerts/tracker/` | متصل نیست | view محلی mock دارد و اصلا به AI call نمی‌زند | +| `POST /api/farm-alerts/timeline/` | متصل نیست | view محلی mock دارد و اصلا به AI call نمی‌زند | +| `GET /api/soil-data/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | +| `POST /api/soil-data/` | عملا با همین route متصل نیست | در `crop_zoning/services.py` call به `/soil-data` بدون پیشوند `/api` وجود دارد | +| `GET /api/soil-data/tasks//status/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | +| `GET /api/farm-data//detail/` | متصل نیست | هیچ call یا route معناداری پیدا نشد | +| `POST /api/farm-data/parameters/` | متصل نیست | هیچ call یا route معناداری پیدا نشد | +| `GET /api/plants/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | +| `POST /api/plants/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | +| `GET /api/plants//` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | +| `PUT /api/plants//` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | +| `PATCH /api/plants//` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | +| `DELETE /api/plants//` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | +| `POST /api/plants/fetch-info/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | +| `GET /api/irrigation//` | متصل نیست | route detail/call به AI پیدا نشد | +| `PUT /api/irrigation//` | متصل نیست | route detail/call به AI پیدا نشد | +| `PATCH /api/irrigation//` | متصل نیست | route detail/call به AI پیدا نشد | +| `DELETE /api/irrigation//` | متصل نیست | route detail/call به AI پیدا نشد | + +## نکات مهم + +- `crop-simulation` ها هنوز در `yield_harvest/views.py` به AI وصل هستند، ولی route عمومی backend آن‌ها حذف شده است. +- `farm-alerts/tracker` و `farm-alerts/timeline` در backend وجود دارند، اما داده‌شان mock است و به AI وصل نیستند. +- `weather/farm-card` برای AI از route دیگری استفاده می‌کند: `/weather-forecast/card`. +- `irrigation/water-stress` هم به جای route درخواستی، به `/api/water/stress-index/` روی AI وصل شده است. +- `soil-data` وضعیت یکدستی ندارد: specهای mock برای `/api/soil-data/...` موجود است، ولی call واقعی کد به `/soil-data` بدون پیشوند `/api` دیده می‌شود. + +## جمع‌بندی + +- متصل به AI با همین route: `15` مورد +- متصل به AI ولی با route متفاوت: `7` مورد +- متصل نیست: `18` مورد diff --git a/API_DATA_SOURCE_AUDIT_FA.md b/API_DATA_SOURCE_AUDIT_FA.md new file mode 100644 index 0000000..217be58 --- /dev/null +++ b/API_DATA_SOURCE_AUDIT_FA.md @@ -0,0 +1,328 @@ +# گزارش بررسی منبع داده APIها + +تاریخ بررسی: 2026-04-26 + +## جمع‌بندی خیلی کوتاه +z +- در این repo، بیشتر endpointهایی که `external_api_request("ai", ...)` صدا می‌زنند **از mock داخلی این backend استفاده نمی‌کنند** و به‌صورت HTTP به سرویس AI می‌روند. +- دلیلش این است که در `external_api_adapter/adapter.py` شرط mock این‌طور نوشته شده: `USE_EXTERNAL_API_MOCK and service_name != "ai"`. +- در `.env` فعلی، `USE_EXTERNAL_API_MOCK=true` است، اما چون سرویس این endpointها `ai` است، باز هم درخواست‌ها به سرویس واقعیِ تنظیم‌شده در `AI_SERVICE_BASE_URL=http://ai-web:8000` می‌روند. +- پس برای endpointهای proxy شده به `ai`، از نگاه این backend، **منبع فعلی داده = سرویس واقعی AI** است، نه فایل‌های mock همین repo. +- بعضی endpointها اصلاً external call ندارند و فقط از `mock_data.py` یا از دیتابیس خود Django استفاده می‌کنند. + +## مبنای تشخیص + +### تنظیمات سراسری + +- `USE_EXTERNAL_API_MOCK=true` +- `AI_SERVICE_BASE_URL=http://ai-web:8000` +- `AI_SERVICE_HOST_HEADER=localhost` + +### رفتار adapter + +در `external_api_adapter/adapter.py`: + +```python +use_mock = getattr(settings, "USE_EXTERNAL_API_MOCK", False) and service_name != "ai" +``` + +یعنی: + +- برای `farm_hub` و سرویس‌های غیر `ai` امکان mock داخلی هست. +- برای `ai` **حتی اگر** `USE_EXTERNAL_API_MOCK=true` باشد، درخواست mock نمی‌شود و مستقیم HTTP call زده می‌شود. + +> نکته: این گزارش فقط نشان می‌دهد این backend داده را از کجا می‌گیرد. اینکه خود سرویس `ai-web:8000` پشت صحنه mock برگرداند یا داده واقعی تولید کند، از داخل این repo قابل اثبات نیست. + +--- + +## 1) Pest Detection + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `POST /api/pest-detection/analyze/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/pest-detection/analyze/` | +| `POST /api/pest-detection/risk/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/pest-detection/risk/` | +| `GET /api/pest-detection/risk-summary/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/pest-detection/risk-summary/` | + +### توضیح + +- هر 3 endpoint در `pest_detection/views.py` مستقیم `external_api_request("ai", ...)` صدا می‌زنند. +- فایل `pest_detection/services.py` یک mock به نام `get_risk_summary_data` دارد، ولی **در endpoint واقعی `risk-summary` استفاده نمی‌شود**. +- نتیجه: در وضعیت فعلی این 3 endpoint **mock محلی backend نیستند**. + +--- + +## 2) Plant Simulator + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `GET /api/plant-simulator/config/` | `yield_harvest.mock_data.CONFIG_SLIDERS_ONLY` | mock/static | internal | +| `PATCH /api/plant-simulator/environment/` | پاسخ ثابت success | mock/static | internal | +| `POST /api/plant-simulator/reset/` | پاسخ ثابت success | mock/static | internal | +| `POST /api/plant-simulator/start/` | `yield_harvest.mock_data.START_RESPONSE_DATA` | mock/static | internal | +| `GET /api/plant-simulator/state/` | `yield_harvest.mock_data.STATE_RESPONSE_DATA` | mock/static | internal | +| `POST /api/plant-simulator/stop/` | پاسخ ثابت success | mock/static | internal | + +### توضیح + +- routeهای `plant-simulator` داخل `yield_harvest/views.py` تعریف شده‌اند. +- هیچ‌کدام external API call ندارند. +- همه این endpointها فعلاً **ماک/استاتیک** هستند. + +--- + +## 3) Soil + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `GET /api/soil/anomalies/` | دیتابیس مدل `farm_alerts.AnomalyDetection` در صورت وجود farm و رکورد، وگرنه `soil.mock_data.ANOMALY_DETECTION_CARD` | ترکیبی: DB / mock | internal DB | +| `GET /api/soil/avg-moisture/` | محاسبه از `get_soil_moisture_heatmap_data()` که خودش از mock می‌آید | mock-derived | internal | +| `GET /api/soil/moisture-heatmap/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/soile/moisture-heatmap/` | +| `GET /api/sensor-7-in-1/sensor-comparison-chart/` | `sensor_7_in_1.services.get_sensor_comparison_chart_data` | sensor 7 in 1 | internal | +| `GET /api/sensor-7-in-1/sensor-radar-chart/` | `sensor_7_in_1.services.get_sensor_radar_chart_data` | sensor 7 in 1 | internal | +| `GET /api/soil/summary/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/soile/health-summary/` | + +### توضیح + +- `avg-moisture` داده واقعی sensor یا external API نمی‌خواند؛ فقط از heatmap ماک میانگین می‌گیرد. +- `anomalies` تنها endpoint این گروه است که **می‌تواند** از DB داخلی بخواند. +- `summary` فقط wrapper همین سرویس‌های داخلی است و خودش external call ندارد. + +--- + +## 4) WATER + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `GET /api/water/card/` | سرویس AI خارجی + ذخیره در `water.WeatherForecastLog` | واقعی از دید backend | `http://ai-web:8000/weather-forecast/card` | +| `GET /api/water/need-prediction/` | اگر `farm_uuid` معتبر باشد: سرویس AI خارجی؛ اگر نباشد: داده مشتق‌شده از `irrigation_recommendation.IrrigationRecommendationRequest` یا mock | ترکیبی | `http://ai-web:8000/api/weather/water-need-prediction/` یا internal DB/mock | +| `GET /api/water/stress-index/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/water/stress-index/` | +| `GET /api/water/summary/` | local aggregation از `WeatherForecastLog` + `IrrigationRecommendationRequest` + mock fallback | ترکیبی | internal DB/mock | + +### توضیح + +- `water/card` در لحظه درخواست از AI می‌خواند و نتیجه را در `WeatherForecastLog` ذخیره می‌کند. +- `water/need-prediction` رفتار دوحالته دارد: + - اگر `farm_uuid` داده شود و farm پیدا شود: مستقیم به AI می‌رود. + - اگر farm نداشته باشد: از آخرین `IrrigationRecommendationRequest` می‌سازد؛ اگر چیزی نباشد، از `water.mock_data` می‌دهد. +- `water/summary` خودش به AI زنگ نمی‌زند؛ فقط داده cached/local را assemble می‌کند. + +--- + +## 5) WEATHER + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `POST /api/weather/farm-card/` | سرویس AI خارجی + ذخیره در `WeatherForecastLog` | واقعی از دید backend | `http://ai-web:8000/weather-forecast/card` | +### توضیح + +- در namespace `weather` فقط `POST /api/weather/farm-card/` باقی مانده است. +- endpoint `POST /api/weather/water-need-prediction/` حذف شده است. + +--- + +## 6) Yield & Harvest Prediction + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `GET /api/yield-harvest/summary/` | سرویس AI خارجی + ذخیره در `yield_harvest.YieldHarvestPredictionLog` | واقعی از دید backend | `http://ai-web:8000/yield-harvest/summary` | + +### توضیح + +- این endpoint در `yield_harvest/views.py` مستقیماً AI را صدا می‌زند. +- سپس نتیجه در `YieldHarvestPredictionLog` ذخیره می‌شود. +- سرویس `yield_harvest/services.py` برای خواندن از log و fallback mock وجود دارد، اما خود endpoint `summary` در حال حاضر **آن سرویس را استفاده نمی‌کند**. + +--- + +## 7) Fertilization Recommendation + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `GET /api/fertilization/config/` | `fertilization_recommendation.mock_data.CONFIG_RESPONSE_DATA` | mock/static | internal | +| `POST /api/fertilization/recommend/` | سرویس AI خارجی + ذخیره در `FertilizationRecommendationRequest` | واقعی از دید backend | `http://ai-web:8000/api/fertilization/recommend/` | + +### توضیح + +- `config` فعلاً فقط config ثابت برمی‌گرداند. +- `recommend` واقعی از نگاه backend است و پاسخ خام AI در DB ذخیره می‌شود. + +--- + +## 8) Irrigation Recommendation + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `GET /api/irrigation/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/irrigation/` | +| `GET /api/irrigation/config/` | `irrigation_recommendation.mock_data.CONFIG_RESPONSE_DATA` | mock/static | internal | +| `POST /api/irrigation/recommend/` | سرویس AI خارجی + ذخیره در `IrrigationRecommendationRequest` | واقعی از دید backend | `http://ai-web:8000/api/irrigation/recommend/` | + +### نکته مهم درباره مسیر درخواستی شما + +مسیر `POST /api/irrigation/water-stress/farm-alerts` در این backend **وجود ندارد**. + +مسیرهای نزدیک و واقعی این‌ها هستند: + +- `POST /api/irrigation/water-stress/` + - منبع فعلی: سرویس AI خارجی + - آدرس upstream: `http://ai-web:8000/api/water/stress-index/` +- endpointهای `farm-alerts` جدا هستند و زیر prefix دیگری قرار دارند: + - `/api/farm-alerts/...` + +--- + +## 9) Farm Alerts + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `GET /api/farm-alerts/anomalies/` | `farm_alerts.mock_data.ANOMALY_DETECTION_CARD` | mock/static | internal | +| `POST /api/farm-alerts/create/` | دیتابیس داخلی (`FarmAlert`) + ساخت `FarmNotification` | internal DB | internal DB | +| `GET /api/farm-alerts/recommendations/` | `farm_alerts.mock_data.RECOMMENDATIONS_LIST` | mock/static | internal | +| `GET /api/farm-alerts/timeline/` | `farm_alerts.mock_data.FARM_ALERTS_TIMELINE` | mock/static | internal | + +### نکته مهم درباره مسیر درخواستی شما + +مسیر `GET /api/farm-alerts/tracker/economy` در این backend **وجود ندارد**. + +مسیر درست موجود: + +- `GET /api/farm-alerts/tracker/` + - منبع فعلی: `farm_alerts.mock_data.ARM_ALERTS_TRACKER` + - نوع: mock/static + +### توضیح + +- با اینکه در `farm_alerts/services.py` توابعی برای خواندن tracker/timeline/anomalies/recommendations از DB وجود دارد، viewهای فعلی از آن‌ها استفاده نمی‌کنند. +- بنابراین در وضعیت فعلی: + - `tracker` = mock + - `timeline` = mock + - `anomalies` = mock + - `recommendations` = mock +- فقط `create` واقعاً به DB داخلی می‌نویسد. + +--- + +## 10) Economy + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `POST /api/economy/overview/` | سرویس AI خارجی + ذخیره در `economic_overview.EconomicOverviewLog` | واقعی از دید backend | `http://ai-web:8000/api/economy/overview/` | + +### توضیح + +- endpoint تکراری `GET /api/economy/summary/` حذف شده است. +- endpoint canonical اقتصادی فقط این است: + - `POST /api/economy/overview/` +- این endpoint داده را از AI می‌گیرد و در `EconomicOverviewLog` ذخیره می‌کند. + +--- + +## 11) Crop Health + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `POST /api/crop-health/ndvi-health/` | `crop_health.mock_data.NDVI_HEALTH_CARD` | mock/static | internal | +| `GET /api/crop-health/summary/` | `crop_health.mock_data.NDVI_HEALTH_CARD` + `crop_health.mock_data.FARM_HEALTH_SCORE` | mock/static | internal | + +### توضیح + +- `get_crop_health_summary_data(farm=None)` در `crop_health/services.py` پارامتر farm را نادیده می‌گیرد. +- پس حتی نسخه POST که farm را validate می‌کند هم در نهایت داده mock برمی‌گرداند. + +--- + +## 12) Soil Data + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `POST /api/soil/health/ndvi-health/` | همان `crop_health` | mock/static | internal | +| `GET /api/soil/health/summary/` | همان `crop_health` | mock/static | internal | + +### توضیح + +- در `config/urls.py` مسیرهای `crop_health` زیر `api/soil/health/` هم mount شده‌اند. +- یعنی این دو endpoint عملاً alias همان endpointهای `crop-health` هستند. +- بنابراین هر دو فعلاً **mock/static** هستند. + +--- + +## 13) Crop Simulation + +| Endpoint | منبع داده فعلی | نوع داده فعلی | آدرس/منبع دقیق | +|---|---|---|---| +| `POST /api/yield-harvest/crop-simulation/current-farm-chart/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/crop-simulation/current-farm-chart/` | +| `POST /api/yield-harvest/crop-simulation/harvest-prediction/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/crop-simulation/harvest-prediction/` | +| `POST /api/yield-harvest/crop-simulation/yield-prediction/` | سرویس AI خارجی | واقعی از دید backend | `http://ai-web:8000/api/crop-simulation/yield-prediction/` | + +### توضیح + +- هر سه endpoint در `yield_harvest/views.py` هستند. +- هر سه مستقیماً `external_api_request("ai", ...)` صدا می‌زنند. + +--- + +## جدول نهایی وضعیت فعلی + +### endpointهایی که الان از سرویس AI می‌گیرند + +- `POST /api/pest-detection/analyze/` +- `POST /api/pest-detection/risk/` +- `GET /api/pest-detection/risk-summary/` +- `GET /api/water/card/` +- `GET /api/water/stress-index/` +- `POST /api/weather/farm-card/` +- `GET /api/yield-harvest/summary/` +- `POST /api/fertilization/recommend/` +- `GET /api/irrigation/` +- `POST /api/irrigation/recommend/` +- `POST /api/irrigation/water-stress/` +- `POST /api/yield-harvest/crop-simulation/current-farm-chart/` +- `POST /api/yield-harvest/crop-simulation/harvest-prediction/` +- `POST /api/yield-harvest/crop-simulation/yield-prediction/` + +### endpointهایی که الان mock/static هستند + +- همه `plant-simulator` +- `GET /api/soil/moisture-heatmap/` +- `GET /api/sensor-7-in-1/sensor-comparison-chart/` +- `GET /api/sensor-7-in-1/sensor-radar-chart/` +- `GET /api/fertilization/config/` +- `GET /api/irrigation/config/` +- `GET /api/farm-alerts/tracker/` +- `GET /api/farm-alerts/timeline/` +- `GET /api/farm-alerts/anomalies/` +- `GET /api/farm-alerts/recommendations/` +- `POST /api/economy/overview/` +- `POST /api/crop-health/ndvi-health/` +- `GET /api/crop-health/summary/` +- `POST /api/soil/health/ndvi-health/` +- `GET /api/soil/health/summary/` + +### endpointهایی که ترکیبی هستند + +- `GET /api/soil/anomalies/` → DB اگر data باشد، وگرنه mock +- `GET /api/soil/avg-moisture/` → derived from mock heatmap +- `GET /api/soil/summary/` → سرویس AI خارجی +- `GET /api/water/need-prediction/` → با `farm_uuid` معتبر معمولاً AI، بدون آن local/mock +- `GET /api/water/summary/` → local DB/cache/mock + +### مسیرهایی که در این backend وجود ندارند + +- `POST /api/irrigation/water-stress/farm-alerts` +- `GET /api/farm-alerts/tracker/economy` + +--- + +## پیشنهاد برای اصلاح اگر هدف شما «داده واقعی» است + +اگر بخواهید این endpointها واقعاً از داده واقعی backend خودشان بخوانند، اولویت اصلاح منطقی این‌هاست: + +1. `farm_alerts/views.py` + - به‌جای mock مستقیم، از توابع `farm_alerts/services.py` استفاده شود. +2. `economic_overview/views.py` + - `summary` از `get_economic_overview_data()` استفاده کند، نه mock ثابت. +3. `crop_health/services.py` + - به‌جای mock ثابت، از منبع واقعی NDVI یا cache/database استفاده کند. +4. `soil/services.py` + - heatmap/radar/comparison از sensor logs واقعی ساخته شوند. +5. `water/summary` + - در صورت نیاز، به‌جای cache/log فقط، امکان refresh مستقیم از AI داشته باشد. diff --git a/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md b/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md new file mode 100644 index 0000000..6bf529b --- /dev/null +++ b/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md @@ -0,0 +1,84 @@ +# گزارش وضعیت استفاده APIهای درخواستی + +این گزارش فقط بر اساس کد همین repository تهیه شده است و برای هر API سه چیز بررسی شده: + +- آیا به عنوان route واقعی در Django backend اکسپوز شده است یا نه +- آیا در کد، تست‌ها، داکیومنت‌های پروژه یا adapterها به آن ارجاع داده شده است یا نه +- اگر path/method اشتباه باشد، نزدیک‌ترین endpoint واقعی پروژه چیست + +## 1) استفاده‌شده و فعال در backend + +این APIها هم در routeهای backend وجود دارند و هم در کد/تست/داک پروژه استفاده شده‌اند. + +| API | وضعیت | شواهد | +|---|---|---| +| `POST /api/weather/farm-card/` | فعال و استفاده‌شده | `water/weather_urls.py`, `water/views.py`, `water/tests.py` | +| `POST /api/economy/overview/` | فعال و استفاده‌شده | `economic_overview/urls.py`, `economic_overview/views.py`, `FRONTEND_PAGES_APIS_GUIDE.md` | +| `GET /api/irrigation/` | فعال و استفاده‌شده | `irrigation_recommendation/urls.py`, `irrigation_recommendation/views.py`, `API_DATA_SOURCE_AUDIT_FA.md` | +| `POST /api/irrigation/recommend/` | فعال و استفاده‌شده | `irrigation_recommendation/urls.py`, `irrigation_recommendation/views.py`, `irrigation_recommendation/tests.py` | +| `POST /api/irrigation/water-stress/` | فعال و استفاده‌شده | `irrigation_recommendation/urls.py`, `irrigation_recommendation/tests.py` | +| `POST /api/fertilization/recommend/` | فعال و استفاده‌شده | `fertilization_recommendation/urls.py`, `fertilization_recommendation/views.py`, `API_DATA_SOURCE_AUDIT_FA.md` | + +## 2) در پروژه استفاده شده‌اند، اما به عنوان endpoint مستقیم backend اکسپوز نیستند + +این‌ها یا فقط به عنوان path سرویس خارجی AI استفاده می‌شوند، یا route داخلی‌شان با path دیگری در backend ارائه شده است. + +| API | وضعیت | توضیح | شواهد | +|---|---|---|---| +| `POST /api/rag/chat/` | استفاده داخلی | route محلی نیست؛ به عنوان درخواست خروجی به سرویس AI استفاده می‌شود | `farm_ai_assistant/views.py`, `external_api_adapter/json/ai/index.json` | +| `GET /api/soil-data/` | فقط contract/mock | route محلی ندارد؛ فقط در adapter mock/spec آمده | `external_api_adapter/json/ai/index.json` | +| `POST /api/soil-data/` | استفاده داخلی/contract | route محلی ندارد؛ mock/spec دارد و integration نزدیک آن در `crop_zoning` به `/soil-data` صدا زده می‌شود | `external_api_adapter/json/ai/index.json`, `crop_zoning/services.py` | +| `GET /api/soil-data/tasks//status/` | فقط contract/mock | route محلی ندارد؛ فقط در adapter mock/spec آمده | `external_api_adapter/json/ai/index.json` | +| `POST /api/soile/moisture-heatmap/` | استفاده داخلی | backend به جای آن `POST /api/soil/moisture-heatmap/` را اکسپوز کرده و این path را به AI صدا می‌زند | `soil/views.py`, `soil/tests.py`, `soil/urls.py` | +| `POST /api/soile/health-summary/` | استفاده داخلی | backend به جای آن `POST /api/soil/summary/` را اکسپوز کرده و این path را به AI صدا می‌زند | `soil/views.py`, `soil/tests.py`, `soil/urls.py` | +| `POST /api/soile/anomaly-detection/` | استفاده داخلی | backend به جای آن `POST /api/soil/anomalies/` را اکسپوز کرده و این path را به AI صدا می‌زند | `soil/views.py`, `soil/tests.py`, `soil/urls.py` | +| `POST /api/farm-data/` | استفاده داخلی | route محلی ندارد؛ برای sync داده مزرعه به سرویس بیرونی استفاده می‌شود | `farm_hub/services.py`, `sensor_external_api/services.py`, `farm_hub/tests.py` | +| `POST /api/weather/water-need-prediction/` | استفاده داخلی | route محلی ندارد؛ backend endpoint معادل را با `GET /api/water/need-prediction/` ارائه می‌کند و خودش این path را به AI صدا می‌زند | `water/views.py`, `water/urls.py`, `water/tests.py` | +| `POST /api/crop-simulation/growth/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز در view/testها ارجاع مانده | `yield_harvest/views.py`, `yield_harvest/tests.py` | +| `GET /api/crop-simulation/growth//status/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز در view/testها ارجاع مانده | `yield_harvest/views.py`, `yield_harvest/tests.py` | +| `POST /api/crop-simulation/current-farm-chart/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز به عنوان AI path در کد وجود دارد | `yield_harvest/views.py`, `yield_harvest/tests.py` | +| `POST /api/crop-simulation/harvest-prediction/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز به عنوان AI path در کد وجود دارد | `yield_harvest/views.py`, `yield_harvest/tests.py` | +| `POST /api/crop-simulation/yield-prediction/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز به عنوان AI path در کد وجود دارد | `yield_harvest/views.py` | + +## 3) در لیست شما آمده‌اند، اما با method/path فعلی استفاده نمی‌شوند + +این‌ها یا method اشتباه دارند، یا path صحیح پروژه چیز دیگری است، یا اصلا implementation محلی برایشان پیدا نشد. + +| API | وضعیت | توضیح | شواهد | +|---|---|---|---| +| `POST /api/farm-alerts/tracker/` | استفاده نمی‌شود | path وجود دارد ولی فقط `GET` پیاده‌سازی شده | `farm_alerts/urls.py`, `farm_alerts/views.py`, `FRONTEND_PAGES_APIS_GUIDE.md` | +| `POST /api/farm-alerts/timeline/` | استفاده نمی‌شود | path وجود دارد ولی فقط `GET` پیاده‌سازی شده | `farm_alerts/urls.py`, `farm_alerts/views.py`, `FRONTEND_PAGES_APIS_GUIDE.md` | +| `POST /api/soil-data/ndvi-health/` | استفاده نمی‌شود | endpoint واقعی پروژه `POST /api/soil/health/ndvi-health/` است | `crop_health/tests.py`, `crop_health/urls.py` | +| `GET /api/farm-data//detail/` | استفاده نمی‌شود | route یا reference معناداری پیدا نشد | جستجو در کل repo | +| `POST /api/farm-data/parameters/` | استفاده نمی‌شود | route یا reference معناداری پیدا نشد | جستجو در کل repo | +| `GET /api/plants/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | +| `POST /api/plants/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | +| `GET /api/plants//` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | +| `PUT /api/plants//` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | +| `PATCH /api/plants//` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | +| `DELETE /api/plants//` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | +| `POST /api/plants/fetch-info/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | +| `POST /api/pest-disease/detect/` | استفاده نمی‌شود | endpoint واقعی پروژه `POST /api/pest-detection/analyze/` است | `pest_detection/urls.py`, `pest_detection/views.py` | +| `POST /api/pest-disease/risk/` | استفاده نمی‌شود | endpoint واقعی پروژه `POST /api/pest-detection/risk/` است | `pest_detection/urls.py`, `pest_detection/views.py` | +| `POST /api/pest-disease/risk-summary/` | استفاده نمی‌شود | path و method هر دو متفاوت‌اند؛ endpoint واقعی `GET /api/pest-detection/risk-summary/` است | `pest_detection/urls.py`, `pest_detection/views.py`, `pest_detection/tests.py` | +| `POST /api/irrigation/` | استفاده نمی‌شود | path وجود دارد ولی فقط `GET` list پیاده‌سازی شده | `irrigation_recommendation/urls.py`, `irrigation_recommendation/views.py` | +| `GET /api/irrigation//` | استفاده نمی‌شود | route detail پیدا نشد | `irrigation_recommendation/urls.py` | +| `PUT /api/irrigation//` | استفاده نمی‌شود | route detail/update پیدا نشد | `irrigation_recommendation/urls.py` | +| `PATCH /api/irrigation//` | استفاده نمی‌شود | route detail/update پیدا نشد | `irrigation_recommendation/urls.py` | +| `DELETE /api/irrigation//` | استفاده نمی‌شود | route detail/delete پیدا نشد | `irrigation_recommendation/urls.py` | + +## 4) جمع‌بندی سریع + +- فعال و قابل استفاده در backend: `6` مورد +- استفاده داخلی یا باقیمانده در کد ولی بدون route مستقیم: `14` مورد +- استفاده‌نشده / path یا method اشتباه / بدون implementation: `20` مورد + +## 5) نکات مهم برای پاک‌سازی + +- `crop-simulation` routeها حذف شده‌اند، ولی referenceهای آن هنوز در `yield_harvest/views.py` و `yield_harvest/tests.py` باقی مانده‌اند. +- `rag/chat` و `farm-data` بیشتر contract داخلی با سرویس AI هستند، نه endpoint قابل استفاده برای کلاینت frontend. +- چند API در لیست شما نام قدیمی یا اشتباه دارند و در backend با path جدیدتری پیاده‌سازی شده‌اند: + - `soil-data/ndvi-health` -> `soil/health/ndvi-health` + - `pest-disease/*` -> `pest-detection/*` + - `weather/water-need-prediction` -> `water/need-prediction` + - `soile/*` -> به صورت داخلی برای AI استفاده می‌شود، ولی route عمومی backend با `soil/*` است diff --git a/DASHBOARD_CARD_SOURCES.md b/DASHBOARD_CARD_SOURCES.md index 95a9da6..9f5b0e4 100644 --- a/DASHBOARD_CARD_SOURCES.md +++ b/DASHBOARD_CARD_SOURCES.md @@ -40,8 +40,8 @@ | Card ID | عنوان | منبع اصلی | service | endpoint | |---|---|---|---|---| -| `sensorRadarChart` | نمودار راداری سنسورها | `soil` | `get_sensor_radar_chart_data` | `GET /api/soil/sensor-radar-chart/` | -| `sensorComparisonChart` | مقایسه با هفته قبل | `soil` | `get_sensor_comparison_chart_data` | `GET /api/soil/sensor-comparison-chart/` | +| `sensorRadarChart` | نمودار راداری سنسورها | `sensor_7_in_1` | `get_sensor_radar_chart_data` | `GET /api/sensor-7-in-1/sensor-radar-chart/` | +| `sensorComparisonChart` | مقایسه با هفته قبل | `sensor_7_in_1` | `get_sensor_comparison_chart_data` | `GET /api/sensor-7-in-1/sensor-comparison-chart/` | ### `alertsWater` @@ -82,7 +82,7 @@ | Card ID | عنوان | منبع اصلی | service | endpoint | |---|---|---|---|---| -| `economicOverview` | نمای اقتصادی | `economic_overview` | `get_economic_overview_data` | `GET /api/economic-overview/summary/` | +| `economicOverview` | نمای اقتصادی | `economic_overview` | `EconomyOverviewView` | `POST /api/economy/overview/` | ## endpoint های summary جدید برای app ها @@ -97,15 +97,15 @@ - `GET /api/water/summary/` - `soil` - `GET /api/soil/avg-moisture/` - - `GET /api/soil/sensor-radar-chart/` - - `GET /api/soil/sensor-comparison-chart/` + - `GET /api/sensor-7-in-1/sensor-radar-chart/` + - `GET /api/sensor-7-in-1/sensor-comparison-chart/` - `GET /api/soil/anomalies/` - `GET /api/soil/moisture-heatmap/` - `GET /api/soil/summary/` - `yield_harvest` - `GET /api/yield-harvest/summary/` - `economic_overview` - - `GET /api/economic-overview/summary/` + - `POST /api/economy/overview/` ## وضعیت فعلی کارت‌ها diff --git a/FRONTEND_PAGES_APIS_GUIDE.md b/FRONTEND_PAGES_APIS_GUIDE.md index 597e016..3414f4c 100644 --- a/FRONTEND_PAGES_APIS_GUIDE.md +++ b/FRONTEND_PAGES_APIS_GUIDE.md @@ -158,8 +158,8 @@ app جدید برای: ## Soil APIs - `GET /api/soil/avg-moisture/` -- `GET /api/soil/sensor-radar-chart/` -- `GET /api/soil/sensor-comparison-chart/` +- `GET /api/sensor-7-in-1/sensor-radar-chart/` +- `GET /api/sensor-7-in-1/sensor-comparison-chart/` - `GET /api/soil/anomalies/` - `GET /api/soil/moisture-heatmap/` - `GET /api/soil/summary/` @@ -198,7 +198,7 @@ Plant Simulator routes که الان implementationشان زیر `yield_harvest` ## Economic Overview APIs -- `GET /api/economic-overview/summary/` +- `POST /api/economy/overview/` کاربرد: @@ -289,8 +289,8 @@ Plant Simulator routes که الان implementationشان زیر `yield_harvest` | `farmWeatherCard` | `weatherAlerts` | `WATER` | `GET /api/water/card/` | Water Page | | `farmAlertsTracker` | `weatherAlerts` | `farm_alerts` | `GET /api/farm-alerts/tracker/` | Alerts Page | | `sensorValuesList` | `sensorMonitoring` | فعلا `dashboard` | فعلا فقط dashboard | Sensor Page در آینده | -| `sensorRadarChart` | `sensorCharts` | `soil` | `GET /api/soil/sensor-radar-chart/` | Soil Page | -| `sensorComparisonChart` | `sensorCharts` | `soil` | `GET /api/soil/sensor-comparison-chart/` | Soil Page | +| `sensorRadarChart` | `sensorCharts` | `sensor_7_in_1` | `GET /api/sensor-7-in-1/sensor-radar-chart/` | Soil Page | +| `sensorComparisonChart` | `sensorCharts` | `sensor_7_in_1` | `GET /api/sensor-7-in-1/sensor-comparison-chart/` | Soil Page | | `anomalyDetectionCard` | `alertsWater` | `soil` | `GET /api/soil/anomalies/` | Soil Page | | `farmAlertsTimeline` | `alertsWater` | `farm_alerts` | `GET /api/farm-alerts/timeline/` | Alerts Page | | `waterNeedPrediction` | `alertsWater` | `WATER` | `GET /api/water/need-prediction/` | Water Page | @@ -299,7 +299,7 @@ Plant Simulator routes که الان implementationشان زیر `yield_harvest` | `soilMoistureHeatmap` | `soilHeatmap` | `soil` | `GET /api/soil/moisture-heatmap/` | Soil Page | | `ndviHealthCard` | `ndviRecommendations` | `crop_health` | `GET /api/crop-health/summary/` | Crop Health Page | | `recommendationsList` | `ndviRecommendations` | ترکیبی | dashboard aggregate | Recommendations / Alerts / Domain Pages | -| `economicOverview` | `economic` | `economic_overview` | `GET /api/economic-overview/summary/` | Economic Overview Page | +| `economicOverview` | `economic` | `economic_overview` | `POST /api/economy/overview/` | Economic Overview Page | --- @@ -354,8 +354,8 @@ endpoint ها: endpoint ها: - `GET /api/soil/avg-moisture/` -- `GET /api/soil/sensor-radar-chart/` -- `GET /api/soil/sensor-comparison-chart/` +- `GET /api/sensor-7-in-1/sensor-radar-chart/` +- `GET /api/sensor-7-in-1/sensor-comparison-chart/` - `GET /api/soil/anomalies/` - `GET /api/soil/moisture-heatmap/` - یا یکجا `GET /api/soil/summary/` @@ -418,7 +418,7 @@ endpoint: endpoint: -- `GET /api/economic-overview/summary/` +- `POST /api/economy/overview/` --- diff --git a/config/settings.py b/config/settings.py index c5755ed..0f891ea 100644 --- a/config/settings.py +++ b/config/settings.py @@ -7,6 +7,8 @@ from dotenv import load_dotenv load_dotenv() BASE_DIR = Path(__file__).resolve().parent.parent +LOG_DIR = BASE_DIR / "logs" +LOG_DIR.mkdir(exist_ok=True) def _get_csv_env(name, default=""): @@ -224,3 +226,39 @@ CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIP CELERY_TASK_TIME_LIMIT = int(os.getenv("CELERY_TASK_TIME_LIMIT", "120")) CELERY_TASK_SOFT_TIME_LIMIT = int(os.getenv("CELERY_TASK_SOFT_TIME_LIMIT", "90")) CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = os.getenv("CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP", "true").lower() == "true" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": "%(asctime)s %(levelname)s %(name)s %(message)s", + }, + }, + "handlers": { + "farm_ai_assistant_file": { + "level": "WARNING", + "class": "logging.FileHandler", + "filename": LOG_DIR / "farm_ai_assistant.log", + "formatter": "standard", + }, + "external_api_adapter_file": { + "level": "WARNING", + "class": "logging.FileHandler", + "filename": LOG_DIR / "external_api_adapter.log", + "formatter": "standard", + }, + }, + "loggers": { + "farm_ai_assistant": { + "handlers": ["farm_ai_assistant_file"], + "level": "WARNING", + "propagate": False, + }, + "external_api_adapter": { + "handlers": ["external_api_adapter_file"], + "level": "WARNING", + "propagate": False, + }, + }, +} diff --git a/config/swagger.py b/config/swagger.py index 73b6fe6..56a7f04 100644 --- a/config/swagger.py +++ b/config/swagger.py @@ -1,6 +1,10 @@ from rest_framework import serializers -from drf_spectacular.utils import inline_serializer +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, inline_serializer + + +FARM_UUID_DEFAULT = "11111111-1111-1111-1111-111111111111" class AuthTokenSerializer(serializers.Serializer): @@ -28,3 +32,24 @@ def status_response(name, data=None): if data is not None: fields["data"] = data return inline_serializer(name=name, fields=fields) + + +def farm_uuid_query_param(required=False, description="UUID of the farm."): + return OpenApiParameter( + name="farm_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=required, + description=description, + default=FARM_UUID_DEFAULT, + ) + + +def sensor_uuid_query_param(required=False, description="Optional sensor UUID."): + return OpenApiParameter( + name="sensor_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=required, + description=description, + ) diff --git a/config/urls.py b/config/urls.py index 0b8b878..a3f8cbd 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,7 +1,6 @@ from django.contrib import admin from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView -from yield_harvest.urls import plant_simulator_urlpatterns urlpatterns = [ path("admin/", admin.site.urls), @@ -17,17 +16,23 @@ urlpatterns = [ path("api/farm-dashboard/", include("dashboard.urls")), path("api/crop-health/", include("crop_health.urls")), path("api/soil/", include("soil.urls")), + path("api/crop-zoning/", include("crop_zoning.urls")), - path("api/plant-simulator/", include(plant_simulator_urlpatterns)), - path("api/pest-detection/", include("pest_detection.urls")), - path("api/sensor-7-in-1/", include("sensor_7_in_1.urls")), - path("api/irrigation-recommendation/", include("irrigation_recommendation.urls")), - path("api/water/", include("water.urls")), path("api/yield-harvest/", include("yield_harvest.urls")), - path("api/economic-overview/", include("economic_overview.urls")), - path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")), + + path("api/pest-detection/", include("pest_detection.urls")), + path("api/pest-disease/", include("pest_detection.pest_disease_urls")), + path("api/sensor-7-in-1/", include("sensor_7_in_1.urls")), + path("api/irrigation/", include("irrigation_recommendation.urls")), + + path("api/weather/", include("water.weather_urls")), + path("api/water/", include("water.urls")), + path("api/economy/", include("economic_overview.urls")), + + path("api/fertilization/", include("fertilization_recommendation.urls")), path("api/farm-ai-assistant/", include("farm_ai_assistant.urls")), path("api/notifications/", include("notifications.urls")), path("api/farm-alerts/", include("farm_alerts.urls")), + path("api/sensor-external-api/", include("sensor_external_api.urls")), ] diff --git a/crop_health/mock_data.py b/crop_health/mock_data.py index cea1f54..95ad089 100644 --- a/crop_health/mock_data.py +++ b/crop_health/mock_data.py @@ -12,6 +12,14 @@ FARM_HEALTH_SCORE = { NDVI_HEALTH_CARD = { "ndviIndex": 0.78, + "mean_ndvi": 0.78, + "ndvi_map": { + "type": "FeatureCollection", + "features": [], + }, + "vegetation_health_class": "Healthy", + "observation_date": "2026-04-10", + "satellite_source": "sentinel-2", "healthData": [ {"title": "تنش نیتروژن", "value": "پایین", "color": "success", "icon": "tabler-leaf"}, {"title": "سلامت محصول", "value": "خوب", "color": "success", "icon": "tabler-plant"}, diff --git a/crop_health/serializers.py b/crop_health/serializers.py index b1c633b..97dea2c 100644 --- a/crop_health/serializers.py +++ b/crop_health/serializers.py @@ -1,15 +1,24 @@ from rest_framework import serializers +class CropHealthRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(help_text="UUID مزرعه برای دریافت تحلیل سلامت گیاه.") + + class HealthDataItemSerializer(serializers.Serializer): - title = serializers.CharField(required=False, allow_blank=True) - value = serializers.CharField(required=False, allow_blank=True) - color = serializers.CharField(required=False, allow_blank=True) - icon = serializers.CharField(required=False, allow_blank=True) + title = serializers.CharField(required=False, allow_blank=True, help_text="عنوان آیتم سلامت.") + value = serializers.JSONField(required=False, help_text="مقدار آیتم سلامت؛ می‌تواند عدد، متن یا ساختار JSON باشد.") + color = serializers.CharField(required=False, allow_blank=True, help_text="رنگ نمایشی آیتم سلامت.") + icon = serializers.CharField(required=False, allow_blank=True, help_text="آیکون نمایشی آیتم سلامت.") class NdviHealthCardSerializer(serializers.Serializer): - ndviIndex = serializers.FloatField(required=False) + ndviIndex = serializers.FloatField(required=False, help_text="شاخص NDVI نرمال‌شده برای مزرعه.") + mean_ndvi = serializers.FloatField(required=False, help_text="میانگین NDVI محاسبه‌شده.") + ndvi_map = serializers.JSONField(required=False, help_text="لایه یا متادیتای نقشه NDVI.") + vegetation_health_class = serializers.CharField(required=False, allow_blank=True, help_text="کلاس سلامت پوشش گیاهی.") + observation_date = serializers.DateField(required=False, help_text="تاریخ مشاهده ماهواره‌ای.") + satellite_source = serializers.CharField(required=False, allow_blank=True, help_text="منبع تصویر ماهواره‌ای.") healthData = HealthDataItemSerializer(many=True, required=False) diff --git a/crop_health/tests.py b/crop_health/tests.py new file mode 100644 index 0000000..cb96139 --- /dev/null +++ b/crop_health/tests.py @@ -0,0 +1,110 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import Resolver404, resolve +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType +from unittest.mock import patch + +from .views import CropHealthSummaryView, NdviHealthView + + +class NdviHealthViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="ndvi-user", + password="secret123", + email="ndvi@example.com", + phone_number="09120000020", + ) + self.other_user = get_user_model().objects.create_user( + username="ndvi-other-user", + password="secret123", + email="ndvi-other@example.com", + phone_number="09120000021", + ) + self.farm_type = FarmType.objects.create(name="NDVI Farm Type") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="NDVI Farm", + ) + + @patch("crop_health.views.external_api_request") + def test_post_ndvi_health_returns_expected_payload(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"ndviIndex": 0.78, "mean_ndvi": 0.78, "vegetation_health_class": "Healthy", "satellite_source": "sentinel-2"}}}, + ) + + request = self.factory.post( + "/api/crop-health/ndvi-health/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = NdviHealthView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["msg"], "success") + self.assertEqual(response.data["data"]["ndviIndex"], 0.78) + self.assertEqual(response.data["data"]["mean_ndvi"], 0.78) + self.assertEqual(response.data["data"]["vegetation_health_class"], "Healthy") + self.assertEqual(response.data["data"]["satellite_source"], "sentinel-2") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/soil-data/ndvi-health/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_post_ndvi_health_requires_farm_uuid(self): + request = self.factory.post("/api/crop-health/ndvi-health/", {}, format="json") + force_authenticate(request, user=self.user) + + response = NdviHealthView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("farm_uuid", response.data) + + def test_post_ndvi_health_returns_404_for_missing_farm(self): + request = self.factory.post( + "/api/crop-health/ndvi-health/", + {"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = NdviHealthView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Farm not found.") + + def test_post_ndvi_health_does_not_expose_other_users_farm(self): + request = self.factory.post( + "/api/crop-health/ndvi-health/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.other_user) + + response = NdviHealthView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Farm not found.") + + def test_crop_health_routes_exist(self): + self.assertIs(resolve("/api/crop-health/ndvi-health/").func.view_class, NdviHealthView) + self.assertIs(resolve("/api/crop-health/summary/").func.view_class, CropHealthSummaryView) + + def test_removed_soil_health_alias_routes_no_longer_resolve(self): + with self.assertRaises(Resolver404): + resolve("/api/soil/health/ndvi-health/") + with self.assertRaises(Resolver404): + resolve("/api/soil/health/summary/") + with self.assertRaises(Resolver404): + resolve("/api/soil-data/ndvi-health/") diff --git a/crop_health/urls.py b/crop_health/urls.py index 79cd178..68ddf6d 100644 --- a/crop_health/urls.py +++ b/crop_health/urls.py @@ -1,7 +1,8 @@ from django.urls import path -from .views import CropHealthSummaryView +from .views import CropHealthSummaryView, NdviHealthView urlpatterns = [ + path("ndvi-health/", NdviHealthView.as_view(), name="crop-health-ndvi-health"), path("summary/", CropHealthSummaryView.as_view(), name="crop-health-summary"), ] diff --git a/crop_health/views.py b/crop_health/views.py index 30b1bc6..3c10098 100644 --- a/crop_health/views.py +++ b/crop_health/views.py @@ -1,11 +1,14 @@ from rest_framework import status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, extend_schema +from drf_spectacular.utils import extend_schema -from config.swagger import status_response -from .serializers import CropHealthSummarySerializer +from config.swagger import farm_uuid_query_param, status_response +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub + +from .serializers import CropHealthRequestSerializer, CropHealthSummarySerializer, NdviHealthCardSerializer from .services import get_crop_health_summary_data @@ -13,14 +16,7 @@ class CropHealthSummaryView(APIView): @extend_schema( tags=["Crop Health"], parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=False, - description="UUID of the farm for crop health data.", - default="11111111-1111-1111-1111-111111111111", - ), + farm_uuid_query_param(required=False, description="UUID of the farm for crop health data."), ], responses={200: status_response("CropHealthSummaryResponse", data=CropHealthSummarySerializer())}, ) @@ -29,3 +25,58 @@ class CropHealthSummaryView(APIView): {"status": "success", "data": get_crop_health_summary_data()}, status=status.HTTP_200_OK, ) + + +class NdviHealthView(APIView): + permission_classes = [IsAuthenticated] + + @staticmethod + def _extract_result(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + @extend_schema( + tags=["Crop Health"], + request=CropHealthRequestSerializer, + responses={200: status_response("NdviHealthResponse", data=NdviHealthCardSerializer())}, + ) + def post(self, request): + serializer = CropHealthRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + farm = FarmHub.objects.get(farm_uuid=serializer.validated_data["farm_uuid"], owner=request.user) + except FarmHub.DoesNotExist: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + adapter_response = external_api_request( + "ai", + "/api/soil-data/ndvi-health/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + data = self._extract_result(adapter_response.data) + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) diff --git a/economic_overview/serializers.py b/economic_overview/serializers.py index 2bf7e5c..2f495b7 100644 --- a/economic_overview/serializers.py +++ b/economic_overview/serializers.py @@ -2,11 +2,11 @@ from rest_framework import serializers class EconomicDataItemSerializer(serializers.Serializer): - title = serializers.CharField() - value = serializers.CharField() - subtitle = serializers.CharField() - avatarIcon = serializers.CharField() - avatarColor = serializers.CharField() + title = serializers.CharField(help_text="عنوان شاخص اقتصادی.") + value = serializers.CharField(help_text="مقدار شاخص اقتصادی.") + subtitle = serializers.CharField(help_text="توضیح تکمیلی شاخص.") + avatarIcon = serializers.CharField(help_text="آیکون نمایشی شاخص.") + avatarColor = serializers.CharField(help_text="رنگ نمایشی شاخص.") class ChartSeriesSerializer(serializers.Serializer): @@ -15,6 +15,12 @@ class ChartSeriesSerializer(serializers.Serializer): class EconomicOverviewSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + source = serializers.CharField(required=False, allow_blank=True, help_text="منبع داده یا نوع تولید پاسخ.") economicData = EconomicDataItemSerializer(many=True) chartSeries = ChartSeriesSerializer(many=True) - chartCategories = serializers.ListField(child=serializers.CharField()) + chartCategories = serializers.ListField(child=serializers.CharField(), help_text="برچسب‌های محور افقی نمودار اقتصادی.") + + +class EconomicOverviewRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت نمای اقتصادی.") diff --git a/economic_overview/tests.py b/economic_overview/tests.py new file mode 100644 index 0000000..dc22dba --- /dev/null +++ b/economic_overview/tests.py @@ -0,0 +1,81 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import Resolver404, resolve +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType + +from .views import EconomyOverviewView + + +class EconomyOverviewViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + + @patch("economic_overview.views.external_api_request") + def test_overview_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "source": "mock", + "economicData": [{"title": "Revenue", "value": "10"}], + "chartSeries": [{"name": "Revenue", "data": [1.0, 2.0]}], + "chartCategories": ["فروردین", "اردیبهشت"], + } + } + }, + ) + + request = self.factory.post("/api/economy/overview/", {"farm_uuid": str(self.farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = EconomyOverviewView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + self.assertEqual(response.data["data"]["source"], "mock") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/economy/overview/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_overview_rejects_foreign_farm_uuid(self): + request = self.factory.post("/api/economy/overview/", {"farm_uuid": str(self.other_farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = EconomyOverviewView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + + def test_economy_routes_exist_only_under_economy_prefix(self): + self.assertIs(resolve("/api/economy/overview/").func.view_class, EconomyOverviewView) + + with self.assertRaises(Resolver404): + resolve("/api/economy/summary/") + + with self.assertRaises(Resolver404): + resolve("/api/economic-overview/summary/") diff --git a/economic_overview/urls.py b/economic_overview/urls.py index 172caf8..c04f3d4 100644 --- a/economic_overview/urls.py +++ b/economic_overview/urls.py @@ -1,7 +1,7 @@ from django.urls import path -from .views import EconomicOverviewView +from .views import EconomyOverviewView urlpatterns = [ - path("summary/", EconomicOverviewView.as_view(), name="economic-overview-summary"), + path("overview/", EconomyOverviewView.as_view(), name="economy-overview"), ] diff --git a/economic_overview/views.py b/economic_overview/views.py index 1fed30a..e79e9e8 100644 --- a/economic_overview/views.py +++ b/economic_overview/views.py @@ -1,11 +1,93 @@ +from drf_spectacular.utils import extend_schema +from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView -from .mock_data import ECONOMIC_OVERVIEW -from .serializers import EconomicOverviewSerializer +from config.swagger import status_response +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub +from .models import EconomicOverviewLog +from .serializers import EconomicOverviewRequestSerializer, EconomicOverviewSerializer -class EconomicOverviewView(APIView): - def get(self, request): - serializer = EconomicOverviewSerializer(ECONOMIC_OVERVIEW) - return Response({"status": "success", "result": serializer.data}) +class EconomyOverviewView(APIView): + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + @staticmethod + def _extract_result(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + @staticmethod + def _persist_log(farm, overview_data): + if not isinstance(overview_data, dict): + return + EconomicOverviewLog.objects.create( + farm=farm, + economic_data=overview_data.get("economicData", []), + chart_series=overview_data.get("chartSeries", []), + chart_categories=overview_data.get("chartCategories", []), + ) + + @extend_schema( + tags=["Economy"], + request=EconomicOverviewRequestSerializer, + responses={200: status_response("EconomyOverviewResponse", data=EconomicOverviewSerializer())}, + ) + def post(self, request): + serializer = EconomicOverviewRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + farm, error_response = self._get_farm(request, serializer.validated_data["farm_uuid"]) + if error_response is not None: + return error_response + + payload = {"farm_uuid": str(farm.farm_uuid)} + adapter_response = external_api_request( + "ai", + "/api/economy/overview/", + method="POST", + payload=payload, + ) + + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + overview_data = self._extract_result(adapter_response.data) + if isinstance(overview_data, dict): + overview_data.setdefault("farm_uuid", str(farm.farm_uuid)) + self._persist_log(farm, overview_data) + return Response({"code": 200, "msg": "success", "data": overview_data}, status=status.HTTP_200_OK) diff --git a/external_api_adapter/adapter.py b/external_api_adapter/adapter.py index 343b675..d71540d 100644 --- a/external_api_adapter/adapter.py +++ b/external_api_adapter/adapter.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +import logging import requests from django.conf import settings @@ -9,6 +10,9 @@ from .mock_loader import MockLoader from .services import ServiceRegistry +logger = logging.getLogger(__name__) + + @dataclass class AdapterResponse: status_code: int @@ -26,6 +30,16 @@ class ExternalAPIAdapter: request_method = method.upper() self._validate_method(request_method) service = self.service_registry.get(service_name) + logger.warning( + "External API adapter request start: service=%s method=%s path=%s payload_type=%s payload_keys=%s query_keys=%s header_keys=%s", + service_name, + request_method, + path, + type(payload).__name__, + sorted(payload.keys()) if isinstance(payload, dict) else None, + sorted(query.keys()) if isinstance(query, dict) else None, + sorted(headers.keys()) if isinstance(headers, dict) else None, + ) use_mock = getattr(settings, "USE_EXTERNAL_API_MOCK", False) and service_name != "ai" if use_mock: @@ -91,6 +105,16 @@ class ExternalAPIAdapter: else: request_kwargs["json"] = request_payload + logger.warning( + "External API adapter outbound request: method=%s url=%s has_files=%s json_keys=%s data_keys=%s timeout=%s", + method, + url, + bool(files), + sorted(request_payload.keys()) if isinstance(request_payload, dict) and not files else None, + sorted(request_payload.keys()) if isinstance(request_payload, dict) and files else None, + request_kwargs["timeout"], + ) + response = requests.request( **request_kwargs, ) @@ -102,6 +126,17 @@ class ExternalAPIAdapter: except ValueError: response_data = response.text + logger.warning( + "External API adapter inbound response: method=%s url=%s status_code=%s response_type=%s response_keys=%s text_length=%s", + method, + url, + response.status_code, + type(response_data).__name__, + sorted(response_data.keys()) if isinstance(response_data, dict) else None, + len(response_data) if isinstance(response_data, str) else None, + ) + logger.warning("Response : %s",response_data) + return AdapterResponse( status_code=response.status_code, data=response_data, diff --git a/farm_ai_assistant/views.py b/farm_ai_assistant/views.py index f904a7f..8d7028a 100644 --- a/farm_ai_assistant/views.py +++ b/farm_ai_assistant/views.py @@ -1,6 +1,7 @@ """Farm AI Assistant API views.""" import json +import logging from copy import deepcopy from django.db.models import Count @@ -29,6 +30,9 @@ from .serializers import ( ) +logger = logging.getLogger(__name__) + + class FarmAccessMixin: @staticmethod def _get_farm(request, farm_uuid): @@ -268,18 +272,63 @@ class ConversationAccessMixin(FarmAccessMixin): if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): payload_source = adapter_data["data"] + logger.warning( + "Farm AI assistant parsing response: conversation_id=%s adapter_type=%s adapter_keys=%s payload_source_type=%s payload_source_keys=%s", + str(conversation.uuid), + type(adapter_data).__name__, + sorted(adapter_data.keys()) if isinstance(adapter_data, dict) else None, + type(payload_source).__name__, + sorted(payload_source.keys()) if isinstance(payload_source, dict) else None, + ) + content = "" sections = [] if isinstance(payload_source, dict): content = payload_source.get("content") or "" sections = self._normalize_sections(payload_source.get("sections")) + logger.warning( + "Farm AI assistant payload_source parsed: conversation_id=%s raw_content_present=%s raw_sections_type=%s normalized_sections_count=%s", + str(conversation.uuid), + bool(content), + type(payload_source.get("sections")).__name__ if payload_source.get("sections") is not None else None, + len(sections), + ) + logger.warning("%s %s", isinstance(payload_source, dict), not sections and isinstance(adapter_data, dict)) if not sections and isinstance(adapter_data, dict): sections = self._normalize_sections(adapter_data.get("sections")) + logger.warning( + "Farm AI assistant root-level sections fallback: conversation_id=%s raw_sections_type=%s normalized_sections_count=%s", + str(conversation.uuid), + type(adapter_data.get("sections")).__name__ if adapter_data.get("sections") is not None else None, + len(sections), + ) if not content and isinstance(adapter_data, dict): content = adapter_data.get("body") or adapter_data.get("content") or "" + logger.warning( + "Farm AI assistant content fallback: conversation_id=%s body_present=%s content_present=%s final_content_present=%s", + str(conversation.uuid), + bool(adapter_data.get("body")), + bool(adapter_data.get("content")), + bool(content), + ) + + if isinstance(adapter_data, dict) and adapter_data.get("result") is not None: + logger.warning( + "Farm AI assistant unparsed result detected: conversation_id=%s result_type=%s result_keys=%s", + str(conversation.uuid), + type(adapter_data.get("result")).__name__, + sorted(adapter_data["result"].keys()) if isinstance(adapter_data.get("result"), dict) else None, + ) + + logger.warning( + "Farm AI assistant final parsed payload: conversation_id=%s content_length=%s sections_count=%s", + str(conversation.uuid), + len(content or ""), + len(sections), + ) return { "message_id": "", @@ -463,6 +512,13 @@ class ChatView(ConversationAccessMixin, APIView): method="POST", payload=adapter_payload, ) + logger.warning( + "Farm AI assistant adapter response received: conversation_id=%s status_code=%s response_type=%s response_keys=%s", + str(conversation.uuid), + adapter_response.status_code, + type(adapter_response.data).__name__, + adapter_response + ) if adapter_response.status_code >= 400: return Response( { diff --git a/farm_alerts/serializers.py b/farm_alerts/serializers.py index 98e3dec..e070537 100644 --- a/farm_alerts/serializers.py +++ b/farm_alerts/serializers.py @@ -49,9 +49,9 @@ class RecommendationsListSerializer(serializers.Serializer): class CreateAlertSerializer(serializers.Serializer): - farm_uuid = serializers.UUIDField(required=False, allow_null=True) - title = serializers.CharField(max_length=255) - description = serializers.CharField(required=False, default="", allow_blank=True) - color = serializers.ChoiceField(choices=["info", "warning", "error", "success"], default="info") - avatar_icon = serializers.CharField(required=False, default="", allow_blank=True) - avatar_color = serializers.CharField(required=False, default="", allow_blank=True) + farm_uuid = serializers.UUIDField(required=False, allow_null=True, help_text="UUID مزرعه برای اتصال alert به مزرعه.") + title = serializers.CharField(max_length=255, help_text="عنوان هشدار.") + description = serializers.CharField(required=False, default="", allow_blank=True, help_text="توضیح هشدار.") + color = serializers.ChoiceField(choices=["info", "warning", "error", "success"], default="info", help_text="سطح یا رنگ هشدار.") + avatar_icon = serializers.CharField(required=False, default="", allow_blank=True, help_text="آیکون هشدار.") + avatar_color = serializers.CharField(required=False, default="", allow_blank=True, help_text="رنگ آواتار هشدار.") diff --git a/farm_alerts/urls.py b/farm_alerts/urls.py index 1812267..ef7cc3a 100644 --- a/farm_alerts/urls.py +++ b/farm_alerts/urls.py @@ -1,17 +1,8 @@ from django.urls import path -from .views import ( - AlertTrackerView, - AlertTimelineView, - AnomalyDetectionView, - RecommendationsListView, - CreateAlertView, -) +from .views import AlertTimelineView, AlertTrackerView urlpatterns = [ path("tracker/", AlertTrackerView.as_view(), name="farm-alerts-tracker"), path("timeline/", AlertTimelineView.as_view(), name="farm-alerts-timeline"), - path("anomalies/", AnomalyDetectionView.as_view(), name="farm-alerts-anomalies"), - path("recommendations/", RecommendationsListView.as_view(), name="farm-alerts-recommendations"), - path("create/", CreateAlertView.as_view(), name="farm-alerts-create"), ] diff --git a/farm_alerts/views.py b/farm_alerts/views.py index 6509d60..d9c0efa 100644 --- a/farm_alerts/views.py +++ b/farm_alerts/views.py @@ -2,79 +2,74 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView -from farm_hub.models import FarmHub +from external_api_adapter import request as external_api_request -from .mock_data import ( - ANOMALY_DETECTION_CARD, - ARM_ALERTS_TRACKER, - FARM_ALERTS_TIMELINE, - RECOMMENDATIONS_LIST, -) -from .serializers import ( - AlertTimelineSerializer, - AlertTrackerSerializer, - AnomalyDetectionSerializer, - CreateAlertSerializer, - RecommendationsListSerializer, -) -from .services import AlertService +from .serializers import AlertTimelineSerializer, AlertTrackerSerializer -class AlertTrackerView(APIView): - def get(self, request): - serializer = AlertTrackerSerializer(ARM_ALERTS_TRACKER) - return Response({"status": "success", "result": serializer.data}) +class FarmAlertsBaseView(APIView): + @staticmethod + def _extract_result(adapter_data): + if not isinstance(adapter_data, dict): + return {} + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data -class AlertTimelineView(APIView): - def get(self, request): - serializer = AlertTimelineSerializer(FARM_ALERTS_TIMELINE) - return Response({"status": "success", "result": serializer.data}) + result = adapter_data.get("result") + if isinstance(result, dict): + return result + return adapter_data -class AnomalyDetectionView(APIView): - def get(self, request): - serializer = AnomalyDetectionSerializer(ANOMALY_DETECTION_CARD) - return Response({"status": "success", "result": serializer.data}) - - -class RecommendationsListView(APIView): - def get(self, request): - serializer = RecommendationsListSerializer(RECOMMENDATIONS_LIST) - return Response({"status": "success", "result": serializer.data}) - - -class CreateAlertView(APIView): - def post(self, request): - serializer = CreateAlertSerializer(data=request.data) - if not serializer.is_valid(): - return Response( - {"status": "error", "errors": serializer.errors}, - status=status.HTTP_400_BAD_REQUEST, - ) - - data = serializer.validated_data - farm = None - farm_uuid = data.get("farm_uuid") - if farm_uuid: - try: - farm = FarmHub.objects.get(uuid=farm_uuid) - except FarmHub.DoesNotExist: - return Response( - {"status": "error", "message": "farm not found"}, - status=status.HTTP_404_NOT_FOUND, - ) - - alert = AlertService.create_alert( - title=data["title"], - description=data.get("description", ""), - color=data.get("color", "info"), - avatar_icon=data.get("avatar_icon", ""), - avatar_color=data.get("avatar_color", ""), - farm=farm, + @staticmethod + def _error_response(adapter_response): + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} ) - return Response( - {"status": "success", "result": {"uuid": str(alert.uuid), "title": alert.title}}, - status=status.HTTP_201_CREATED, + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + +class AlertTrackerView(FarmAlertsBaseView): + def post(self, request): + adapter_response = external_api_request( + "ai", + "/api/farm-alerts/tracker/", + method="POST", + payload=request.data, + ) + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + payload = self._extract_result(adapter_response.data) + serializer = AlertTrackerSerializer(data=payload) + serializer.is_valid(raise_exception=True) + return Response({"code": 200, "msg": "success", "data": serializer.validated_data}, status=status.HTTP_200_OK) + + +class AlertTimelineView(FarmAlertsBaseView): + def post(self, request): + adapter_response = external_api_request( + "ai", + "/api/farm-alerts/timeline/", + method="POST", + payload=request.data, + ) + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + payload = self._extract_result(adapter_response.data) + serializer = AlertTimelineSerializer(data=payload) + serializer.is_valid(raise_exception=True) + return Response( + {"code": 200, "msg": "success", "data": serializer.validated_data}, + status=status.HTTP_200_OK, ) diff --git a/farm_hub/API_REFERENCE_FA.md b/farm_hub/API_REFERENCE_FA.md index c9e8db3..a6b77a3 100644 --- a/farm_hub/API_REFERENCE_FA.md +++ b/farm_hub/API_REFERENCE_FA.md @@ -180,20 +180,39 @@ Content-Type: application/json } } ], - "area_geojson": { - "type": "Feature", - "properties": {}, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [51.418934, 35.706815], - [51.423054, 35.691062], - [51.384258, 35.689389], - [51.418934, 35.706815] - ] + "farm_boundary": { + "corners": [ + {"lat": 35.70, "lon": 51.39}, + {"lat": 35.70, "lon": 51.41}, + {"lat": 35.72, "lon": 51.41}, + {"lat": 35.72, "lon": 51.39} + ] + }, + "sensor_key": "sensor-7-1", + "sensor_payload": { + "soil_moisture": 45.2, + "soil_temperature": 22.5 + }, + "irrigation_method_id": 3 +} +``` + +برای `farm_boundary` هر دو فرم زیر پشتیبانی می‌شوند: + +```json +{ + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.418934, 35.706815], + [51.423054, 35.691062], + [51.384258, 35.689389], + [51.418934, 35.706815] ] - } + ] } } ``` @@ -207,7 +226,11 @@ Content-Type: application/json | `farm_type_uuid` | uuid | بله | UUID نوع مزرعه | | `product_uuids` | array[uuid] | بله | لیست UUID محصولات؛ خالی بودن مجاز نیست | | `sensors` | array | خیر | لیست سنسورهای مزرعه | -| `area_geojson` | object | خیر | محدوده زمین به صورت GeoJSON از نوع `Polygon` | +| `area_geojson` | object | خیر | محدوده زمین به صورت GeoJSON از نوع `Polygon`؛ اگر `farm_boundary` هم ارسال شود، این فیلد override می‌شود | +| `farm_boundary` | object | خیر | alias برای محدوده مزرعه؛ هم `Polygon` و هم فرم `corners` را می‌پذیرد | +| `sensor_key` | string | خیر | کلید سنسور برای normalize کردن `sensor_payload`؛ پیش فرض `sensor-7-1` | +| `sensor_payload` | object | خیر | داده سنسور که همراه ساخت مزرعه به Farm Data sync می‌شود | +| `irrigation_method_id` | integer/null | خیر | شناسه روش آبیاری که همراه ساخت مزرعه به Farm Data sync می‌شود | ### فیلدهای هر سنسور در `sensors` @@ -224,6 +247,8 @@ Content-Type: application/json ### اعتبارسنجی‌ها - `farm_uuid` اگر از سمت کلاینت ارسال شود نادیده گرفته می‌شود. +- اگر `farm_boundary` به فرم `corners` ارسال شود، به Polygon تبدیل می‌شود. +- `sensor_payload` باید object باشد، وگرنه خطای validation برمی‌گردد. - `farm_type_uuid` باید معتبر باشد، وگرنه: ```json @@ -268,6 +293,11 @@ Content-Type: application/json } ``` +### رفتار داخلی + +- بعد از ساخت مزرعه و zoning، backend درخواست `POST /api/farm-data/` را نیز با `farm_uuid`، `farm_boundary`، `plant_ids` و در صورت وجود `sensor_payload`/`irrigation_method_id` ارسال می‌کند. +- اگر sync با Farm Data شکست بخورد، پاسخ endpoint با کد `502` برمی‌گردد. + - `area_geojson` باید object معتبر باشد. - اگر `area_geojson.type == "Feature"` باشد، مقدار `geometry` بررسی می‌شود. - `geometry.type` فقط باید `Polygon` باشد. @@ -551,7 +581,19 @@ Content-Type: application/json "type": "solar" } } - ] + ], + "farm_boundary": { + "corners": [ + {"lat": 35.70, "lon": 51.39}, + {"lat": 35.70, "lon": 51.41}, + {"lat": 35.72, "lon": 51.41}, + {"lat": 35.72, "lon": 51.39} + ] + }, + "sensor_payload": { + "soil_moisture": 45.2 + }, + "irrigation_method_id": 3 } ``` @@ -561,7 +603,8 @@ Content-Type: application/json - اگر `farm_type_uuid` ارسال شود، نوع مزرعه به‌روزرسانی می‌شود. - اگر `product_uuids` ارسال شود، همه محصولات مزرعه با لیست جدید جایگزین می‌شوند. - اگر `sensors` ارسال شود، همه سنسورهای قبلی حذف و سپس سنسورهای جدید از نو ساخته می‌شوند. -- `area_geojson` در متد `update` دریافت می‌شود ولی در حال حاضر برای update نادیده گرفته می‌شود و zoning مجدد انجام نمی‌شود. +- اگر `area_geojson` یا `farm_boundary` ارسال شود، zoning مجدد انجام می‌شود و `current_crop_area` به‌روزرسانی می‌شود. +- در هر update نیز درخواست sync به `POST /api/farm-data/` با `farm_uuid`، `farm_boundary`، `plant_ids` و در صورت وجود `sensor_payload`/`irrigation_method_id` ارسال می‌شود. ### اعتبارسنجی @@ -570,6 +613,7 @@ Content-Type: application/json - در update، اگر `farm_type_uuid` ارسال نشود، از `farm_type` فعلی استفاده می‌شود. - در update، اگر `product_uuids` ارسال نشود، محصولات فعلی حفظ می‌شوند. - در update، اگر `sensors` ارسال نشود، سنسورهای فعلی حفظ می‌شوند. +- در update نیز `sensor_payload` باید object باشد. ### Response 200 @@ -604,6 +648,15 @@ Content-Type: application/json } ``` +### Response 502 + +```json +{ + "code": 502, + "msg": "Farm data API returned status 400: ..." +} +``` + --- ## 7) حذف مزرعه diff --git a/farm_hub/serializers.py b/farm_hub/serializers.py index e236c64..443254e 100644 --- a/farm_hub/serializers.py +++ b/farm_hub/serializers.py @@ -5,6 +5,7 @@ from access_control.catalog import GOLD_PLAN_CODE from access_control.services import get_effective_subscription_plan from .models import FarmHub, FarmSensor, FarmType, Product +from .services import normalize_farm_boundary_input from sensor_catalog.models import SensorCatalog @@ -116,6 +117,7 @@ class FarmSensorWriteSerializer(serializers.ModelSerializer): class FarmHubCreateSerializer(serializers.ModelSerializer): area_geojson = serializers.JSONField(write_only=True, required=False) + farm_boundary = serializers.JSONField(write_only=True, required=False) farm_type_uuid = serializers.UUIDField(write_only=True) subscription_plan_uuid = serializers.UUIDField(write_only=True, required=False, allow_null=True) product_uuids = serializers.ListField( @@ -124,6 +126,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): allow_empty=False, ) sensors = FarmSensorWriteSerializer(many=True, required=False) + sensor_key = serializers.CharField(write_only=True, required=False, allow_blank=True, default="sensor-7-1") + sensor_payload = serializers.JSONField(write_only=True, required=False) + irrigation_method_id = serializers.IntegerField(write_only=True, required=False, allow_null=True) class Meta: model = FarmHub @@ -135,6 +140,10 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): "product_uuids", "sensors", "area_geojson", + "farm_boundary", + "sensor_key", + "sensor_payload", + "irrigation_method_id", ] def to_internal_value(self, data): @@ -144,23 +153,27 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): return super().to_internal_value(data) def validate_area_geojson(self, value): + try: + return normalize_farm_boundary_input(value) + except ValueError as exc: + raise serializers.ValidationError(str(exc)) from exc + + def validate_farm_boundary(self, value): + try: + return normalize_farm_boundary_input(value) + except ValueError as exc: + raise serializers.ValidationError(str(exc)) from exc + + def validate_sensor_payload(self, value): if not isinstance(value, dict): - raise serializers.ValidationError("`area_geojson` must be a GeoJSON object.") - - geometry = value.get("geometry") if value.get("type") == "Feature" else value - if not isinstance(geometry, dict): - raise serializers.ValidationError("`area_geojson.geometry` is required.") - - if geometry.get("type") != "Polygon": - raise serializers.ValidationError("`area_geojson.geometry.type` must be `Polygon`.") - - coordinates = geometry.get("coordinates") - if not isinstance(coordinates, list) or not coordinates or not isinstance(coordinates[0], list): - raise serializers.ValidationError("`area_geojson.geometry.coordinates` must be a polygon ring.") - + raise serializers.ValidationError("`sensor_payload` must be an object.") return value def validate(self, attrs): + farm_boundary = attrs.pop("farm_boundary", serializers.empty) + if farm_boundary is not serializers.empty: + attrs["area_geojson"] = farm_boundary + farm_type_uuid = attrs.get("farm_type_uuid") subscription_plan_uuid = attrs.get("subscription_plan_uuid", serializers.empty) product_uuids = attrs.get("product_uuids") @@ -208,6 +221,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): def create(self, validated_data): validated_data.pop("area_geojson", None) + validated_data.pop("sensor_key", None) + validated_data.pop("sensor_payload", None) + validated_data.pop("irrigation_method_id", None) sensors_data = validated_data.pop("sensors", []) products = validated_data.pop("products", []) validated_data["farm_type"] = validated_data.pop("farm_type") @@ -225,6 +241,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): validated_data.pop("area_geojson", None) + validated_data.pop("sensor_key", None) + validated_data.pop("sensor_payload", None) + validated_data.pop("irrigation_method_id", None) sensors_data = validated_data.pop("sensors", None) products = validated_data.pop("products", None) farm_type = validated_data.pop("farm_type", None) diff --git a/farm_hub/services.py b/farm_hub/services.py index c64f937..1642bec 100644 --- a/farm_hub/services.py +++ b/farm_hub/services.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import transaction from crop_zoning.services import ( @@ -6,6 +7,12 @@ from crop_zoning.services import ( get_initial_zones_payload, normalize_area_feature, ) +from external_api_adapter import request as external_api_request +from external_api_adapter.exceptions import ExternalAPIRequestError + + +class FarmDataSyncError(Exception): + pass def dispatch_farm_zoning(area_feature, farm): @@ -13,13 +20,147 @@ def dispatch_farm_zoning(area_feature, farm): return crop_area, get_initial_zones_payload(crop_area) +def normalize_farm_boundary_input(area_feature): + if area_feature is None: + return get_default_area_feature() + + if not isinstance(area_feature, dict): + raise ValueError("`farm_boundary` must be a GeoJSON object or corners payload.") + + corners = area_feature.get("corners") + if isinstance(corners, list) and corners: + ring = [] + for corner in corners: + if not isinstance(corner, dict): + raise ValueError("Each farm boundary corner must be an object.") + lat = corner.get("lat") + lon = corner.get("lon") + if lat is None or lon is None: + raise ValueError("Each farm boundary corner must include `lat` and `lon`.") + ring.append([float(lon), float(lat)]) + + if ring[0] != ring[-1]: + ring.append(ring[0]) + + area_feature = { + "type": "Feature", + "properties": {}, + "geometry": {"type": "Polygon", "coordinates": [ring]}, + } + + return normalize_area_feature(area_feature) + + +def sync_farm_data( + *, + farm, + area_feature=None, + sensor_key="sensor-7-1", + sensor_payload=None, + plant_ids=None, + irrigation_method_id=None, +): + request_payload = { + "farm_uuid": str(farm.farm_uuid), + "farm_boundary": _extract_boundary_geometry(area_feature, farm=farm), + } + + normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=sensor_payload) + if normalized_sensor_payload: + request_payload["sensor_key"] = sensor_key or "sensor-7-1" + request_payload["sensor_payload"] = normalized_sensor_payload + + if plant_ids: + request_payload["plant_ids"] = [int(plant_id) for plant_id in plant_ids] + + if irrigation_method_id is not None: + request_payload["irrigation_method_id"] = int(irrigation_method_id) + + if not any(key in request_payload for key in ("sensor_payload", "plant_ids", "irrigation_method_id")): + raise FarmDataSyncError( + "At least one of `sensor_payload`, `plant_ids`, or `irrigation_method_id` is required for farm data sync." + ) + + api_key = getattr(settings, "FARM_DATA_API_KEY", "") + if not api_key: + raise FarmDataSyncError("FARM_DATA_API_KEY is not configured.") + + try: + response = external_api_request( + "ai", + _get_farm_data_path(), + method="POST", + payload=request_payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": api_key, + "Authorization": f"Api-Key {api_key}", + }, + ) + except ExternalAPIRequestError as exc: + raise FarmDataSyncError(f"Farm data API request failed: {exc}") from exc + + if response.status_code >= 400: + response_body = response.data + raise FarmDataSyncError(f"Farm data API returned status {response.status_code}: {response_body}") + + return request_payload + + def create_farm_with_zoning(serializer, owner): area_feature = serializer.validated_data.pop("area_geojson", None) or get_default_area_feature() + sensor_key = serializer.validated_data.pop("sensor_key", "sensor-7-1") + sensor_payload = serializer.validated_data.pop("sensor_payload", None) + irrigation_method_id = serializer.validated_data.pop("irrigation_method_id", None) with transaction.atomic(): farm = serializer.save(owner=owner) crop_area, zoning_payload = dispatch_farm_zoning(area_feature, farm) farm.current_crop_area = crop_area farm.save(update_fields=["current_crop_area", "updated_at"]) + sync_farm_data( + farm=farm, + area_feature=area_feature, + sensor_key=sensor_key, + sensor_payload=sensor_payload, + plant_ids=[product.id for product in farm.products.all()], + irrigation_method_id=irrigation_method_id, + ) return farm, zoning_payload + + +def _normalize_sensor_payload(*, sensor_key, sensor_payload): + if not sensor_payload: + return None + if not isinstance(sensor_payload, dict): + raise ValueError("`sensor_payload` must be an object.") + + normalized_sensor_key = sensor_key or "sensor-7-1" + if all(isinstance(value, dict) for value in sensor_payload.values()): + return sensor_payload + return {normalized_sensor_key: sensor_payload} + + +def _extract_boundary_geometry(area_feature, *, farm): + if area_feature is not None: + geometry = (area_feature.get("geometry") or {}) if area_feature.get("type") == "Feature" else area_feature + if geometry.get("type") != "Polygon": + raise FarmDataSyncError("Farm boundary geometry must be a Polygon.") + return geometry + + crop_area = farm.current_crop_area or farm.crop_areas.order_by("-created_at", "-id").first() + if crop_area is None: + raise FarmDataSyncError("Farm boundary is not configured for this farm.") + + geometry = crop_area.geometry or {} + if geometry.get("type") == "Feature": + geometry = geometry.get("geometry") or {} + if geometry.get("type") != "Polygon": + raise FarmDataSyncError("Farm boundary geometry must be a Polygon.") + return geometry + + +def _get_farm_data_path(): + return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/") diff --git a/farm_hub/tests.py b/farm_hub/tests.py index 1558a14..230049b 100644 --- a/farm_hub/tests.py +++ b/farm_hub/tests.py @@ -1,15 +1,17 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory, force_authenticate +from unittest.mock import patch from access_control.models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan from access_control.services import build_farm_access_profile from access_control.views import FarmAccessProfileView from crop_zoning.models import CropArea +from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType, Product from farm_hub.serializers import FarmHubSerializer from farm_hub.seeds import seed_admin_farm -from farm_hub.views import FarmListCreateView, FarmTypeListView, FarmTypeProductsView +from farm_hub.views import FarmDetailView, FarmListCreateView, FarmTypeListView, FarmTypeProductsView from sensor_catalog.models import SensorCatalog @@ -33,6 +35,7 @@ AREA_GEOJSON = { @override_settings( USE_EXTERNAL_API_MOCK=True, CROP_ZONE_CHUNK_AREA_SQM=200000, + FARM_DATA_API_KEY="farm-data-key", ) class FarmListCreateViewTests(TestCase): def setUp(self): @@ -52,7 +55,9 @@ class FarmListCreateViewTests(TestCase): defaults={"supported_power_sources": ["solar", "direct_power"]}, ) - def test_create_farm_with_area_geojson_creates_crop_zoning_payload(self): + @patch("farm_hub.services.external_api_request") + def test_create_farm_with_area_geojson_creates_crop_zoning_payload(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) physical_device_uuid = "33333333-3333-3333-3333-333333333333" request = self.factory.post( "/api/farm-hub/", @@ -94,8 +99,26 @@ class FarmListCreateViewTests(TestCase): CropArea.objects.get().zone_count, ) self.assertEqual(CropArea.objects.count(), 1) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/farm-data/", + method="POST", + payload={ + "farm_uuid": response.data["data"]["farm_uuid"], + "farm_boundary": AREA_GEOJSON["geometry"], + "plant_ids": [self.wheat.id], + }, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": "farm-data-key", + "Authorization": "Api-Key farm-data-key", + }, + ) - def test_create_farm_ignores_client_farm_uuid_and_generates_new_one(self): + @patch("farm_hub.services.external_api_request") + def test_create_farm_ignores_client_farm_uuid_and_generates_new_one(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) request = self.factory.post( "/api/farm-hub/", { @@ -114,7 +137,9 @@ class FarmListCreateViewTests(TestCase): self.assertNotEqual(response.data["data"]["farm_uuid"], "11111111-1111-1111-1111-111111111111") self.assertIsNotNone(response.data["data"]["area_uuid"]) - def test_create_farm_rejects_unknown_sensor_catalog_uuid(self): + @patch("farm_hub.services.external_api_request") + def test_create_farm_rejects_unknown_sensor_catalog_uuid(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) request = self.factory.post( "/api/farm-hub/", { @@ -137,7 +162,9 @@ class FarmListCreateViewTests(TestCase): self.assertEqual(response.status_code, 400) self.assertIn("sensor_catalog_uuid", response.data["sensors"][0]) - def test_create_farm_defaults_to_gold_plan_when_not_provided(self): + @patch("farm_hub.services.external_api_request") + def test_create_farm_defaults_to_gold_plan_when_not_provided(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) request = self.factory.post( "/api/farm-hub/", { @@ -154,6 +181,91 @@ class FarmListCreateViewTests(TestCase): self.assertEqual(response.status_code, 201) self.assertEqual(response.data["data"]["subscription_plan"]["code"], "gold") + def test_create_farm_rejects_non_object_sensor_payload(self): + request = self.factory.post( + "/api/farm-hub/", + { + "name": "farm-invalid-sensor-payload", + "farm_type_uuid": str(self.farm_type.uuid), + "product_uuids": [str(self.wheat.uuid)], + "sensor_payload": ["invalid"], + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["sensor_payload"], ["`sensor_payload` must be an object."]) + + @patch("farm_hub.services.external_api_request") + def test_patch_farm_forwards_farm_data_fields(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) + farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="patch-target", + ) + farm.products.add(self.wheat) + + request = self.factory.patch( + f"/api/farm-hub/{farm.farm_uuid}/", + { + "farm_boundary": { + "corners": [ + {"lat": 35.70, "lon": 51.39}, + {"lat": 35.70, "lon": 51.41}, + {"lat": 35.72, "lon": 51.41}, + {"lat": 35.72, "lon": 51.39}, + ] + }, + "sensor_payload": {"soil_moisture": 45.2}, + "irrigation_method_id": 3, + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmDetailView.as_view()(request, farm_uuid=farm.farm_uuid) + + self.assertEqual(response.status_code, 200) + farm.refresh_from_db() + self.assertIsNotNone(farm.current_crop_area) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/farm-data/", + method="POST", + payload={ + "farm_uuid": str(farm.farm_uuid), + "farm_boundary": { + "type": "Polygon", + "coordinates": [ + [ + [51.39, 35.7], + [51.41, 35.7], + [51.41, 35.72], + [51.39, 35.72], + [51.39, 35.7], + ] + ], + }, + "sensor_key": "sensor-7-1", + "sensor_payload": { + "sensor-7-1": {"soil_moisture": 45.2}, + }, + "plant_ids": [self.wheat.id], + "irrigation_method_id": 3, + }, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": "farm-data-key", + "Authorization": "Api-Key farm-data-key", + }, + ) + @override_settings( USE_EXTERNAL_API_MOCK=True, diff --git a/farm_hub/views.py b/farm_hub/views.py index a404093..6a34479 100644 --- a/farm_hub/views.py +++ b/farm_hub/views.py @@ -1,3 +1,4 @@ +from django.db import transaction from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated @@ -14,7 +15,7 @@ from .serializers import ( FarmTypeSerializer, ProductSerializer, ) -from .services import create_farm_with_zoning +from .services import FarmDataSyncError, create_farm_with_zoning, dispatch_farm_zoning, sync_farm_data class FarmHubBaseView(APIView): @@ -64,6 +65,8 @@ class FarmListCreateView(FarmHubBaseView): farm, zoning_payload = create_farm_with_zoning(serializer, owner=request.user) except ValueError as exc: raise serializers.ValidationError({"area_geojson": [str(exc)]}) from exc + except FarmDataSyncError as exc: + return Response({"code": 502, "msg": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) except ImproperlyConfigured as exc: return Response({"code": 500, "msg": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) data = FarmHubSerializer(farm).data @@ -137,7 +140,27 @@ class FarmDetailView(FarmHubBaseView): return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) serializer = FarmHubCreateSerializer(farm, data=request.data, partial=True) serializer.is_valid(raise_exception=True) - serializer.save() + area_feature = serializer.validated_data.get("area_geojson", None) + sensor_key = serializer.validated_data.get("sensor_key", "sensor-7-1") + sensor_payload = serializer.validated_data.get("sensor_payload", None) + irrigation_method_id = serializer.validated_data.get("irrigation_method_id", None) + try: + with transaction.atomic(): + serializer.save() + if area_feature is not None: + crop_area, _zoning_payload = dispatch_farm_zoning(area_feature, serializer.instance) + serializer.instance.current_crop_area = crop_area + serializer.instance.save(update_fields=["current_crop_area", "updated_at"]) + sync_farm_data( + farm=serializer.instance, + area_feature=area_feature, + sensor_key=sensor_key, + sensor_payload=sensor_payload, + plant_ids=[product.id for product in serializer.instance.products.all()], + irrigation_method_id=irrigation_method_id, + ) + except FarmDataSyncError as exc: + return Response({"code": 502, "msg": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) farm.refresh_from_db() data = FarmHubSerializer(farm).data return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) diff --git a/fertilization_recommendation/serializers.py b/fertilization_recommendation/serializers.py index 8cc9f82..d8a018e 100644 --- a/fertilization_recommendation/serializers.py +++ b/fertilization_recommendation/serializers.py @@ -8,38 +8,24 @@ class FertilizationFarmDataSerializer(serializers.Serializer): class FertilizationRecommendRequestSerializer(serializers.Serializer): - farm_uuid = serializers.UUIDField(required=True) - crop_id = serializers.CharField(required=False, allow_blank=True) - growth_stage = serializers.CharField(required=False, allow_blank=True) - farm_data = FertilizationFarmDataSerializer(required=False) - soilType = serializers.CharField(required=False, allow_blank=True) - organicMatter = serializers.CharField(required=False, allow_blank=True) - waterEC = serializers.CharField(required=False, allow_blank=True) + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه کودی.") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.") + growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.") -class FertilizationPlanSerializer(serializers.Serializer): - npkRatio = serializers.CharField(required=False, allow_blank=True) - amountPerHectare = serializers.CharField(required=False, allow_blank=True) +class FertilizationSectionSerializer(serializers.Serializer): + type = serializers.ChoiceField(choices=["text", "list", "recommendation", "warning"]) + title = serializers.CharField(required=False, allow_blank=True) + icon = serializers.CharField(required=False, allow_blank=True) + content = serializers.CharField(required=False, allow_blank=True) + items = serializers.ListField(child=serializers.CharField(), required=False) + fertilizerType = serializers.CharField(required=False, allow_blank=True) + amount = serializers.CharField(required=False, allow_blank=True) applicationMethod = serializers.CharField(required=False, allow_blank=True) - applicationInterval = serializers.CharField(required=False, allow_blank=True) - reasoning = serializers.CharField(required=False, allow_blank=True) + timing = serializers.CharField(required=False, allow_blank=True) + validityPeriod = serializers.CharField(required=False, allow_blank=True) + expandableExplanation = serializers.CharField(required=False, allow_blank=True) class FertilizationRecommendResponseDataSerializer(serializers.Serializer): - plan = FertilizationPlanSerializer(required=False) - - -class FertilizationTaskSubmitDataSerializer(serializers.Serializer): - task_id = serializers.CharField(required=False, allow_blank=True) - status = serializers.CharField(required=False, allow_blank=True) - - -class FertilizationTaskProgressSerializer(serializers.Serializer): - message = serializers.CharField(required=False, allow_blank=True) - - -class FertilizationTaskStatusDataSerializer(serializers.Serializer): - task_id = serializers.CharField(required=False, allow_blank=True) - status = serializers.CharField(required=False, allow_blank=True) - progress = FertilizationTaskProgressSerializer(required=False) - result = FertilizationRecommendResponseDataSerializer(required=False) + sections = FertilizationSectionSerializer(many=True, read_only=True) diff --git a/fertilization_recommendation/urls.py b/fertilization_recommendation/urls.py index e7e8a3c..e52d26d 100644 --- a/fertilization_recommendation/urls.py +++ b/fertilization_recommendation/urls.py @@ -1,13 +1,8 @@ from django.urls import path -from .views import ConfigView, RecommendTaskStatusView, RecommendView +from .views import ConfigView, RecommendView urlpatterns = [ path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"), path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"), - path( - "recommend/status//", - RecommendTaskStatusView.as_view(), - name="fertilization-recommendation-task-status", - ), ] diff --git a/fertilization_recommendation/views.py b/fertilization_recommendation/views.py index 3d98bd3..656b8d9 100644 --- a/fertilization_recommendation/views.py +++ b/fertilization_recommendation/views.py @@ -2,11 +2,12 @@ Fertilization Recommendation API views. """ +import logging + from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, extend_schema +from drf_spectacular.utils import extend_schema from config.swagger import status_response from external_api_adapter import request as external_api_request @@ -16,11 +17,12 @@ from .models import FertilizationRecommendationRequest from .serializers import ( FertilizationRecommendRequestSerializer, FertilizationRecommendResponseDataSerializer, - FertilizationTaskStatusDataSerializer, - FertilizationTaskSubmitDataSerializer, ) +logger = logging.getLogger(__name__) + + class FarmAccessMixin: @staticmethod def _get_farm(request, farm_uuid): @@ -35,9 +37,6 @@ class FarmAccessMixin: class ConfigView(FarmAccessMixin, APIView): @extend_schema( tags=["Fertilization Recommendation"], - parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), - ], responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())}, ) def get(self, request): @@ -48,6 +47,62 @@ class ConfigView(FarmAccessMixin, APIView): class RecommendView(FarmAccessMixin, APIView): + @staticmethod + def _normalize_sections(raw_sections): + if not isinstance(raw_sections, list): + return [] + + allowed_keys = { + "type", + "title", + "icon", + "content", + "items", + "fertilizerType", + "amount", + "applicationMethod", + "timing", + "validityPeriod", + "expandableExplanation", + } + + normalized_sections = [] + for section in raw_sections: + if not isinstance(section, dict) or not section.get("type"): + continue + + normalized_section = {} + for key in allowed_keys: + value = section.get(key) + if value is None: + continue + if key == "items": + if not isinstance(value, list): + continue + normalized_section[key] = [str(item) for item in value] + continue + normalized_section[key] = str(value) if key != "type" else value + + normalized_sections.append(normalized_section) + return normalized_sections + + def _extract_public_sections(self, adapter_data): + if not isinstance(adapter_data, dict): + return [] + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("sections"), list): + return self._normalize_sections(data.get("sections")) + + result = data.get("result") if isinstance(data, dict) else None + if isinstance(result, dict) and isinstance(result.get("sections"), list): + return self._normalize_sections(result.get("sections")) + + if isinstance(adapter_data.get("sections"), list): + return self._normalize_sections(adapter_data.get("sections")) + + return [] + @extend_schema( tags=["Fertilization Recommendation"], request=FertilizationRecommendRequestSerializer, @@ -59,47 +114,53 @@ class RecommendView(FarmAccessMixin, APIView): payload = serializer.validated_data.copy() farm = self._get_farm(request, payload.get("farm_uuid")) payload["farm_uuid"] = str(farm.farm_uuid) + payload["plant_name"] = payload.get("plant_name", "") + payload["growth_stage"] = payload.get("growth_stage", "") adapter_response = external_api_request( "ai", - "/fertilization/recommend", + "/api/fertilization/recommend/", method="POST", payload=payload, ) response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + public_sections = self._extract_public_sections(response_data) + + logger.warning( + "Fertilization recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s", + str(farm.farm_uuid), + adapter_response.status_code, + sorted(response_data.keys()) if isinstance(response_data, dict) else None, + len(public_sections), + ) + FertilizationRecommendationRequest.objects.create( farm=farm, - crop_id=payload.get("crop_id", ""), + crop_id=payload.get("plant_name", ""), growth_stage=payload.get("growth_stage", ""), - task_id=str(response_data.get("data", {}).get("task_id") or response_data.get("task_id") or ""), - status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), + task_id="", + status="success" if adapter_response.status_code < 400 else "error", request_payload=payload, response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) - return Response(adapter_response.data, status=adapter_response.status_code) + if adapter_response.status_code >= 400: + return Response( + { + "code": adapter_response.status_code, + "msg": "error", + "data": response_data if isinstance(response_data, dict) else {"message": str(adapter_response.data)}, + }, + status=adapter_response.status_code, + ) - -class RecommendTaskStatusView(FarmAccessMixin, APIView): - @extend_schema( - tags=["Fertilization Recommendation"], - parameters=[ - OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH), - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), - ], - responses={200: status_response("FertilizationRecommendTaskStatusResponse", data=FertilizationTaskStatusDataSerializer())}, - ) - def get(self, request, task_id): - farm = self._get_farm(request, request.query_params.get("farm_uuid")) - adapter_response = external_api_request( - "ai", - f"/fertilization/status/{task_id}", - method="GET", - query={"farm_uuid": str(farm.farm_uuid)}, + return Response( + { + "code": 200, + "msg": "success", + "data": { + "sections": public_sections, + }, + }, + status=status.HTTP_200_OK, ) - response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} - FertilizationRecommendationRequest.objects.filter(farm=farm, task_id=task_id).update( - status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), - response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, - ) - return Response(adapter_response.data, status=adapter_response.status_code) diff --git a/irrigation_recommendation/serializers.py b/irrigation_recommendation/serializers.py index f7ff40e..74ce63d 100644 --- a/irrigation_recommendation/serializers.py +++ b/irrigation_recommendation/serializers.py @@ -8,56 +8,31 @@ class IrrigationFarmDataSerializer(serializers.Serializer): class IrrigationRecommendRequestSerializer(serializers.Serializer): - farm_uuid = serializers.UUIDField(required=True) - crop_id = serializers.CharField(required=False, allow_blank=True) - farm_data = IrrigationFarmDataSerializer(required=False) - soilType = serializers.CharField(required=False, allow_blank=True) - waterQuality = serializers.CharField(required=False, allow_blank=True) - climateZone = serializers.CharField(required=False, allow_blank=True) + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه آبیاری.") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.") + growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.") + irrigation_method_name = serializers.CharField(required=False, allow_blank=True, help_text="نام روش آبیاری انتخابی.") -class IrrigationPlanSerializer(serializers.Serializer): - frequencyPerWeek = serializers.CharField(required=False, allow_blank=True) - durationMinutes = serializers.CharField(required=False, allow_blank=True) - bestTimeOfDay = serializers.CharField(required=False, allow_blank=True) - moistureLevel = serializers.CharField(required=False, allow_blank=True) - warning = serializers.CharField(required=False, allow_blank=True) +class WaterStressRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای محاسبه تنش آبی.") + sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور برای فیلتر اختیاری.") -class IrrigationWaterBalanceDaySerializer(serializers.Serializer): - forecast_date = serializers.CharField(required=False, allow_blank=True) - et0_mm = serializers.FloatField(required=False) - etc_mm = serializers.FloatField(required=False) - effective_rainfall_mm = serializers.FloatField(required=False) - gross_irrigation_mm = serializers.FloatField(required=False) - irrigation_timing = serializers.CharField(required=False, allow_blank=True) - - -class IrrigationCropProfileSerializer(serializers.Serializer): - kc_initial = serializers.FloatField(required=False) - kc_mid = serializers.FloatField(required=False) - kc_end = serializers.FloatField(required=False) - - -class IrrigationWaterBalanceSerializer(serializers.Serializer): - daily = IrrigationWaterBalanceDaySerializer(many=True, required=False) - crop_profile = IrrigationCropProfileSerializer(required=False) - active_kc = serializers.FloatField(required=False) +class IrrigationMethodSerializer(serializers.Serializer): + id = serializers.IntegerField(required=False) + name = serializers.CharField(required=False, allow_blank=True) + category = serializers.CharField(required=False, allow_blank=True) + description = serializers.CharField(required=False, allow_blank=True) + water_efficiency_percent = serializers.FloatField(required=False) + water_pressure_required = serializers.CharField(required=False, allow_blank=True) + flow_rate = serializers.CharField(required=False, allow_blank=True) + coverage_area = serializers.CharField(required=False, allow_blank=True) + soil_type = serializers.CharField(required=False, allow_blank=True) + climate_suitability = serializers.CharField(required=False, allow_blank=True) + created_at = serializers.DateTimeField(required=False) + updated_at = serializers.DateTimeField(required=False) class IrrigationRecommendResponseDataSerializer(serializers.Serializer): - plan = IrrigationPlanSerializer(required=False) - raw_response = serializers.CharField(required=False, allow_blank=True) - water_balance = IrrigationWaterBalanceSerializer(required=False) - status = serializers.CharField(required=False, allow_blank=True) - - -class IrrigationTaskSubmitDataSerializer(serializers.Serializer): - task_id = serializers.CharField(required=False, allow_blank=True) - status = serializers.CharField(required=False, allow_blank=True) - - -class IrrigationTaskStatusDataSerializer(serializers.Serializer): - task_id = serializers.CharField(required=False, allow_blank=True) - status = serializers.CharField(required=False, allow_blank=True) - result = IrrigationRecommendResponseDataSerializer(required=False) + sections = serializers.ListField(child=serializers.DictField(), read_only=True) diff --git a/irrigation_recommendation/tests.py b/irrigation_recommendation/tests.py new file mode 100644 index 0000000..02d79d1 --- /dev/null +++ b/irrigation_recommendation/tests.py @@ -0,0 +1,141 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType + +from .views import IrrigationMethodListView, WaterStressView + + +class WaterStressViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + + @patch("irrigation_recommendation.views.external_api_request") + def test_post_proxies_request_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "waterStressIndex": 12, + "level": "پایین", + "sourceMetric": {"soilMoisture": 24}, + } + } + }, + ) + + request = self.factory.post( + "/api/irrigation/water-stress/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = WaterStressView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["msg"], "success") + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + self.assertEqual(response.data["data"]["waterStressIndex"], 12) + self.assertEqual(response.data["data"]["level"], "پایین") + self.assertEqual(response.data["data"]["sourceMetric"], {"soilMoisture": 24}) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/water-stress/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_post_rejects_foreign_farm_uuid(self): + request = self.factory.post( + "/api/irrigation/water-stress/", + {"farm_uuid": str(self.other_farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = WaterStressView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") + + +class IrrigationMethodListViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + + @patch("irrigation_recommendation.views.external_api_request") + def test_get_proxies_irrigation_methods_from_ai(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": [ + { + "id": 1, + "name": "Drip", + "category": "micro", + "description": "Efficient irrigation", + "water_efficiency_percent": 90.0, + } + ] + }, + ) + + request = self.factory.get("/api/irrigation/") + response = IrrigationMethodListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"][0]["name"], "Drip") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/", + method="GET", + ) + + @patch("irrigation_recommendation.views.external_api_request") + def test_post_proxies_irrigation_method_creation_to_ai(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=201, + data={ + "data": { + "id": 1, + "name": "Drip", + "category": "micro", + } + }, + ) + + request = self.factory.post("/api/irrigation/", {"name": "Drip"}, format="json") + response = IrrigationMethodListView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["data"]["name"], "Drip") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/", + method="POST", + payload={"name": "Drip"}, + ) diff --git a/irrigation_recommendation/urls.py b/irrigation_recommendation/urls.py index 7a7d466..6d6c66a 100644 --- a/irrigation_recommendation/urls.py +++ b/irrigation_recommendation/urls.py @@ -1,13 +1,10 @@ from django.urls import path -from .views import ConfigView, RecommendTaskStatusView, RecommendView +from .views import ConfigView, IrrigationMethodListView, RecommendView, WaterStressView urlpatterns = [ + path("", IrrigationMethodListView.as_view(), name="irrigation-method-list"), path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"), path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"), - path( - "recommend/status//", - RecommendTaskStatusView.as_view(), - name="irrigation-recommendation-task-status", - ), + path("water-stress/", WaterStressView.as_view(), name="irrigation-water-stress"), ] diff --git a/irrigation_recommendation/views.py b/irrigation_recommendation/views.py index 83c57ee..0b2a689 100644 --- a/irrigation_recommendation/views.py +++ b/irrigation_recommendation/views.py @@ -2,25 +2,31 @@ Irrigation Recommendation API views. """ +import logging + from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, extend_schema +from drf_spectacular.utils import extend_schema from config.swagger import status_response from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub +from water.serializers import WaterStressIndexSerializer +from water.views import WaterStressIndexView from .mock_data import CONFIG_RESPONSE_DATA from .models import IrrigationRecommendationRequest from .serializers import ( + IrrigationMethodSerializer, IrrigationRecommendRequestSerializer, IrrigationRecommendResponseDataSerializer, - IrrigationTaskStatusDataSerializer, - IrrigationTaskSubmitDataSerializer, + WaterStressRequestSerializer, ) +logger = logging.getLogger(__name__) + + class FarmAccessMixin: @staticmethod def _get_farm(request, farm_uuid): @@ -35,9 +41,6 @@ class FarmAccessMixin: class ConfigView(FarmAccessMixin, APIView): @extend_schema( tags=["Irrigation Recommendation"], - parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), - ], responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())}, ) def get(self, request): @@ -47,7 +50,133 @@ class ConfigView(FarmAccessMixin, APIView): return Response({"status": "success", "data": data}, status=status.HTTP_200_OK) +class IrrigationMethodListView(APIView): + @staticmethod + def _extract_methods(adapter_data): + if not isinstance(adapter_data, dict): + return adapter_data if isinstance(adapter_data, list) else [] + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), list): + return data["result"] + if isinstance(data, list): + return data + + result = adapter_data.get("result") + if isinstance(result, list): + return result + + return [] + + @extend_schema( + tags=["Irrigation Recommendation"], + responses={200: status_response("IrrigationMethodListResponse", data=IrrigationMethodSerializer(many=True))}, + ) + def get(self, request): + adapter_response = external_api_request( + "ai", + "/api/irrigation/", + method="GET", + ) + + if adapter_response.status_code >= 400: + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_methods(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Irrigation Recommendation"], + request=serializers.JSONField, + responses={201: status_response("IrrigationMethodCreateResponse", data=IrrigationMethodSerializer())}, + ) + def post(self, request): + adapter_response = external_api_request( + "ai", + "/api/irrigation/", + method="POST", + payload=request.data, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + if adapter_response.status_code >= 400: + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + payload = self._extract_methods(adapter_response.data) + if not payload: + payload = response_data.get("data", response_data) + + return Response( + {"code": adapter_response.status_code, "msg": "success", "data": payload}, + status=adapter_response.status_code, + ) + + class RecommendView(FarmAccessMixin, APIView): + @staticmethod + def _normalize_sections(raw_sections): + if not isinstance(raw_sections, list): + return [] + + allowed_keys = { + "type", + "title", + "icon", + "content", + "items", + "frequency", + "amount", + "timing", + "validityPeriod", + "expandableExplanation", + } + + normalized_sections = [] + for section in raw_sections: + if not isinstance(section, dict) or not section.get("type"): + continue + + normalized_section = {} + for key in allowed_keys: + value = section.get(key) + if value is None: + continue + if key == "items": + if not isinstance(value, list): + continue + normalized_section[key] = [str(item) for item in value] + continue + normalized_section[key] = str(value) if key != "type" else value + + normalized_sections.append(normalized_section) + return normalized_sections + + def _extract_public_sections(self, adapter_data): + if not isinstance(adapter_data, dict): + return [] + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("sections"), list): + return self._normalize_sections(data.get("sections")) + + result = data.get("result") if isinstance(data, dict) else None + if isinstance(result, dict) and isinstance(result.get("sections"), list): + return self._normalize_sections(result.get("sections")) + + if isinstance(adapter_data.get("sections"), list): + return self._normalize_sections(adapter_data.get("sections")) + + return [] + @extend_schema( tags=["Irrigation Recommendation"], request=IrrigationRecommendRequestSerializer, @@ -62,75 +191,103 @@ class RecommendView(FarmAccessMixin, APIView): adapter_response = external_api_request( "ai", - "/irrigation/recommend", + "/api/irrigation/recommend/", method="POST", payload=payload, ) response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + public_sections = self._extract_public_sections(response_data) + + logger.warning( + "Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s", + str(farm.farm_uuid), + adapter_response.status_code, + sorted(response_data.keys()) if isinstance(response_data, dict) else None, + len(public_sections), + ) + IrrigationRecommendationRequest.objects.create( farm=farm, - crop_id=payload.get("crop_id", ""), - task_id=str(response_data.get("data", {}).get("task_id") or response_data.get("task_id") or ""), - status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), + crop_id=payload.get("plant_name", ""), + task_id="", + status="success" if adapter_response.status_code < 400 else "error", request_payload=payload, response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) - return Response(adapter_response.data, status=adapter_response.status_code) + if adapter_response.status_code >= 400: + return Response( + { + "code": adapter_response.status_code, + "msg": "error", + "data": response_data if isinstance(response_data, dict) else {"message": str(adapter_response.data)}, + }, + status=adapter_response.status_code, + ) + + return Response( + { + "code": 200, + "msg": "success", + "data": { + "sections": public_sections, + }, + }, + status=status.HTTP_200_OK, + ) -class RecommendTaskCreateView(FarmAccessMixin, APIView): +class WaterStressView(APIView): + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + @extend_schema( tags=["Irrigation Recommendation"], - request=IrrigationRecommendRequestSerializer, - responses={200: status_response("IrrigationRecommendTaskCreateResponse", data=IrrigationTaskSubmitDataSerializer())}, + request=WaterStressRequestSerializer, + responses={200: status_response("WaterStressResponse", data=WaterStressIndexSerializer())}, ) def post(self, request): - serializer = IrrigationRecommendRequestSerializer(data=request.data) + serializer = WaterStressRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) payload = serializer.validated_data.copy() - farm = self._get_farm(request, payload.get("farm_uuid")) - payload["farm_uuid"] = str(farm.farm_uuid) + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + query = {"farm_uuid": str(farm.farm_uuid)} + sensor_uuid = payload.get("sensor_uuid") + if sensor_uuid: + query["sensor_uuid"] = str(sensor_uuid) adapter_response = external_api_request( "ai", - "/irrigation/recommend", + "/api/irrigation/water-stress/", method="POST", - payload=payload, + payload=query, ) - response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} - IrrigationRecommendationRequest.objects.create( - farm=farm, - crop_id=payload.get("crop_id", ""), - task_id=str(response_data.get("data", {}).get("task_id") or response_data.get("task_id") or ""), - status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), - request_payload=payload, - response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, - ) - return Response(adapter_response.data, status=adapter_response.status_code) + if adapter_response.status_code >= 400: + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) - -class RecommendTaskStatusView(FarmAccessMixin, APIView): - @extend_schema( - tags=["Irrigation Recommendation"], - parameters=[ - OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH), - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), - ], - responses={200: status_response("IrrigationRecommendTaskStatusResponse", data=IrrigationTaskStatusDataSerializer())}, - ) - def get(self, request, task_id): - farm = self._get_farm(request, request.query_params.get("farm_uuid")) - adapter_response = external_api_request( - "ai", - f"/irrigation/recommend/status/{task_id}", - method="GET", - query={"farm_uuid": str(farm.farm_uuid)}, + stress_payload = WaterStressIndexView.extract_stress_payload(adapter_response.data, farm.farm_uuid) + return Response( + {"code": 200, "msg": "success", "data": stress_payload}, + status=status.HTTP_200_OK, ) - response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} - IrrigationRecommendationRequest.objects.filter(farm=farm, task_id=task_id).update( - status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), - response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, - ) - return Response(adapter_response.data, status=adapter_response.status_code) diff --git a/pest_detection/pest_disease_urls.py b/pest_detection/pest_disease_urls.py new file mode 100644 index 0000000..95a0d91 --- /dev/null +++ b/pest_detection/pest_disease_urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import AnalyzeView, RiskSummaryView, RiskView + +urlpatterns = [ + path("detect/", AnalyzeView.as_view(), name="pest-disease-detect"), + path("risk/", RiskView.as_view(), name="pest-disease-risk"), + path("risk-summary/", RiskSummaryView.as_view(), name="pest-disease-risk-summary"), +] diff --git a/pest_detection/serializers.py b/pest_detection/serializers.py index 66b84b8..21cb82a 100644 --- a/pest_detection/serializers.py +++ b/pest_detection/serializers.py @@ -1,13 +1,64 @@ from rest_framework import serializers -class RiskDetailsSerializer(serializers.Serializer): - risk_level = serializers.CharField(required=False, allow_blank=True) - risk_percentage = serializers.IntegerField(required=False) - detected_diseases = serializers.ListField(child=serializers.DictField(), required=False) - detected_pests = serializers.ListField(child=serializers.DictField(), required=False) - last_assessed_at = serializers.CharField(required=False, allow_blank=True) - recommendation = serializers.CharField(required=False, allow_blank=True) +class PestDetectionAnalyzeRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای تحلیل آفت/بیماری.") + sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور مرتبط در صورت وجود.") + plant_name = serializers.CharField(required=False, allow_blank=True, default="", help_text="نام گیاه یا محصول.") + query = serializers.CharField(required=False, allow_blank=True, default="", help_text="پرسش یا توضیح متنی کاربر.") + image_urls = serializers.ListField( + child=serializers.CharField(), + required=False, + default=list, + ) + image = serializers.CharField(required=False, allow_blank=True, default="") + images = serializers.ListField( + child=serializers.CharField(), + required=False, + default=list, + ) + + def validate(self, attrs): + attrs["query"] = (attrs.get("query") or "").strip() + attrs["plant_name"] = (attrs.get("plant_name") or "").strip() + return attrs + + +class PestDetectionAnalyzeResponseSerializer(serializers.Serializer): + has_issue = serializers.BooleanField(required=False) + category = serializers.CharField(required=False, allow_blank=True) + confidence = serializers.FloatField(required=False) + severity = serializers.CharField(required=False, allow_blank=True) + summary = serializers.CharField(required=False, allow_blank=True) + detected_signs = serializers.ListField(child=serializers.CharField(), required=False) + possible_causes = serializers.ListField(child=serializers.CharField(), required=False) + immediate_actions = serializers.ListField(child=serializers.CharField(), required=False) + reasoning = serializers.ListField(child=serializers.CharField(), required=False) + + +class PestDetectionRiskRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای تحلیل ریسک آفت/بیماری.") + sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور مرتبط در صورت وجود.") + plant_name = serializers.CharField(required=False, allow_blank=True, default="", help_text="نام محصول یا گیاه.") + growth_stage = serializers.CharField(required=False, allow_blank=True, default="", help_text="مرحله رشد گیاه.") + query = serializers.CharField(required=False, allow_blank=True, default="", help_text="پرسش تکمیلی کاربر.") + + +class RiskBreakdownSerializer(serializers.Serializer): + score = serializers.FloatField(required=False) + level = serializers.CharField(required=False, allow_blank=True) + likely_conditions = serializers.ListField(child=serializers.CharField(), required=False) + reasoning = serializers.ListField(child=serializers.CharField(), required=False) + + +class PestDetectionRiskResponseSerializer(serializers.Serializer): + summary = serializers.CharField(required=False, allow_blank=True) + forecast_window = serializers.CharField(required=False, allow_blank=True) + overall_risk = serializers.CharField(required=False, allow_blank=True) + disease_risk = RiskBreakdownSerializer(required=False) + pest_risk = RiskBreakdownSerializer(required=False) + key_drivers = serializers.ListField(child=serializers.CharField(), required=False) + recommended_actions = serializers.ListField(child=serializers.CharField(), required=False) class RiskCardSerializer(serializers.Serializer): @@ -19,9 +70,11 @@ class RiskCardSerializer(serializers.Serializer): avatarIcon = serializers.CharField(required=False, allow_blank=True) chipText = serializers.CharField(required=False, allow_blank=True) chipColor = serializers.CharField(required=False, allow_blank=True) - details = RiskDetailsSerializer(required=False) + details = serializers.DictField(required=False) -class RiskSummaryDataSerializer(serializers.Serializer): - disease_risk = RiskCardSerializer(required=False) - pest_risk = RiskCardSerializer(required=False) +class PestDetectionRiskSummaryResponseSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=False, allow_null=True) + diseaseRisk = RiskCardSerializer(required=False) + pestRisk = RiskCardSerializer(required=False) + drivers = serializers.DictField(required=False) diff --git a/pest_detection/tests.py b/pest_detection/tests.py new file mode 100644 index 0000000..84949a8 --- /dev/null +++ b/pest_detection/tests.py @@ -0,0 +1,229 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import resolve +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType + +from .views import AnalyzeView, RiskSummaryView, RiskView + + +class PestDetectionViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + + @patch("pest_detection.views.external_api_request") + def test_analyze_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "has_issue": True, + "category": "disease", + "confidence": 0.93, + "severity": "medium", + "summary": "Leaf spot symptoms detected.", + "detected_signs": ["Brown leaf spots"], + "possible_causes": ["Fungal pressure"], + "immediate_actions": ["Isolate affected plants"], + "reasoning": ["Pattern matched common fungal lesions"], + } + } + }, + ) + + request = self.factory.post( + "/api/pest-detection/analyze/", + {"farm_uuid": str(self.farm.farm_uuid), "image_urls": ["https://example.com/leaf.jpg"]}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = AnalyzeView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["category"], "disease") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/pest-disease/detect/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "", + "query": "", + "image_urls": ["https://example.com/leaf.jpg"], + }, + ) + + def test_analyze_requires_at_least_one_image(self): + request = self.factory.post( + "/api/pest-detection/analyze/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = AnalyzeView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], 400) + self.assertIn("images", response.data["data"]) + + @patch("pest_detection.views.external_api_request") + def test_risk_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "summary": "Warm humidity raises fungal pressure.", + "forecast_window": "72h", + "overall_risk": "medium", + "disease_risk": {"score": 0.7, "level": "medium", "likely_conditions": [], "reasoning": []}, + "pest_risk": {"score": 0.4, "level": "low", "likely_conditions": [], "reasoning": []}, + "key_drivers": ["High humidity"], + "recommended_actions": ["Scout vulnerable rows"], + } + } + }, + ) + + request = self.factory.post( + "/api/pest-detection/risk/", + {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["overall_risk"], "medium") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/pest-disease/risk/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "wheat", + "growth_stage": "", + "query": "", + }, + ) + + @patch("pest_detection.views.external_api_request") + def test_risk_summary_maps_response_shape(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "disease_risk": {"title": "Disease"}, + "pest_risk": {"title": "Pest"}, + "drivers": {"humidity": "high"}, + } + } + }, + ) + + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + self.assertEqual(response.data["data"]["diseaseRisk"]["title"], "Disease") + self.assertEqual(response.data["data"]["pestRisk"]["title"], "Pest") + self.assertEqual(response.data["data"]["drivers"], {"humidity": "high"}) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/pest-disease/risk-summary/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + @patch("pest_detection.views.external_api_request") + def test_risk_summary_post_uses_pest_disease_route(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "disease_risk": {"title": "Disease"}, + "pest_risk": {"title": "Pest"}, + "drivers": {"humidity": "high"}, + } + } + }, + ) + + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/pest-disease/risk-summary/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_risk_summary_rejects_foreign_farm_uuid(self): + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(self.other_farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") + + def test_risk_summary_get_is_not_allowed(self): + request = self.factory.get(f"/api/pest-disease/risk-summary/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 405) + + def test_pest_disease_alias_routes_exist(self): + self.assertIs(resolve("/api/pest-disease/detect/").func.view_class, AnalyzeView) + self.assertIs(resolve("/api/pest-disease/risk/").func.view_class, RiskView) + self.assertIs(resolve("/api/pest-disease/risk-summary/").func.view_class, RiskSummaryView) diff --git a/pest_detection/urls.py b/pest_detection/urls.py index 044d959..637600f 100644 --- a/pest_detection/urls.py +++ b/pest_detection/urls.py @@ -1,8 +1 @@ -from django.urls import path - -from .views import AnalyzeView, RiskSummaryView - -urlpatterns = [ - path("analyze/", AnalyzeView.as_view(), name="pest-detection-analyze"), - path("risk-summary/", RiskSummaryView.as_view(), name="pest-detection-risk-summary"), -] +urlpatterns = [] diff --git a/pest_detection/views.py b/pest_detection/views.py index 3a1ab55..bb122e7 100644 --- a/pest_detection/views.py +++ b/pest_detection/views.py @@ -1,101 +1,254 @@ """ -Pest Detection API views. -No database. All responses are static mock data. -Response format: {"status": "success", "data": }. HTTP 200 only. -No processing, validation, or use of input parameters in responses. +Pest detection API views. """ -from rest_framework import serializers, status +import json + +from drf_spectacular.utils import extend_schema +from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, extend_schema from config.swagger import status_response from external_api_adapter import request as external_api_request -from .mock_data import ANALYZE_RESPONSE_DATA -from .serializers import RiskSummaryDataSerializer +from farm_hub.models import FarmHub +from .serializers import ( + PestDetectionAnalyzeRequestSerializer, + PestDetectionAnalyzeResponseSerializer, + PestDetectionRiskRequestSerializer, + PestDetectionRiskResponseSerializer, + PestDetectionRiskSummaryResponseSerializer, +) -class AnalyzeView(APIView): - """ - POST endpoint for pest detection analysis. +class PestDetectionFarmMixin: + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) - Purpose: - Returns a static pest detection result (pest name, confidence, - description, treatment). Used when the user uploads a plant image - and requests analysis. No processing is performed on the request. + @staticmethod + def _parse_json_array(value): + if not isinstance(value, str): + return None + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return None + return parsed if isinstance(parsed, list) else None - Input parameters: - - body (optional): JSON or form-data; may contain image or file. - Data type: object. Location: body. Not read or validated; not used in response. + def _collect_uploaded_images(self, request): + uploaded_images = [] + single_image = request.FILES.get("image") + if single_image is not None: + uploaded_images.append(single_image) + uploaded_images.extend(request.FILES.getlist("images")) + return uploaded_images - Response structure: - - status: string, always "success". - - data: object with keys pest (string), confidence (number), - description (string), treatment (string). + def _prepare_image_urls(self, request): + image_urls = request.data.get("image_urls", []) + if isinstance(image_urls, str): + parsed = self._parse_json_array(image_urls) + image_urls = parsed if parsed is not None else [image_urls] + return [str(item) for item in image_urls if str(item).strip()] - No processing or validation is performed on inputs. - """ + @staticmethod + def _attach_uploaded_files(payload, uploaded_images): + if not uploaded_images: + return payload - @extend_schema( - tags=["Pest Detection"], - request=OpenApiTypes.OBJECT, - responses={200: status_response("PestDetectionAnalyzeResponse", data=serializers.JSONField())}, - ) - def post(self, request): + files = [] + for uploaded_image in uploaded_images: + files.append( + ( + "images", + ( + uploaded_image.name, + uploaded_image, + getattr(uploaded_image, "content_type", "application/octet-stream"), + ), + ) + ) + + multipart_payload = dict(payload) + multipart_payload["image_urls"] = json.dumps(payload.get("image_urls", []), ensure_ascii=False) + multipart_payload["__files__"] = files + return multipart_payload + + @staticmethod + def _extract_result_payload(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data.get("result", {}) + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + @staticmethod + def _error_response(adapter_response): + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) return Response( - {"status": "success", "data": ANALYZE_RESPONSE_DATA}, - status=status.HTTP_200_OK, + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, ) -class RiskSummaryView(APIView): - """ - GET endpoint for combined pest and disease risk summary. - - Purpose: - Returns disease_risk and pest_risk card data for the farm dashboard. - Calls the AI external adapter for live/mock risk assessment results. - - Input parameters: - - farm_uuid (query, optional): UUID of the farm to assess. - - Response structure: - - status: string, always "success". - - data: object with keys disease_risk and pest_risk, - each containing card display fields (id, title, subtitle, stats, - avatarColor, avatarIcon, chipText, chipColor) and a details object. - """ - +class AnalyzeView(PestDetectionFarmMixin, APIView): @extend_schema( tags=["Pest Detection"], - parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=False, - description="UUID of the farm for risk assessment.", - default="11111111-1111-1111-1111-111111111111"), - ], - responses={200: status_response("PestDetectionRiskSummaryResponse", data=RiskSummaryDataSerializer())}, + request=PestDetectionAnalyzeRequestSerializer, + responses={200: status_response("PestDetectionAnalyzeResponse", data=PestDetectionAnalyzeResponseSerializer())}, ) - def get(self, request): - farm_uuid = request.query_params.get("farm_uuid") - query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {} + def post(self, request): + serializer = PestDetectionAnalyzeRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + image_urls = self._prepare_image_urls(request) + uploaded_images = self._collect_uploaded_images(request) + if not image_urls and not uploaded_images: + return Response( + { + "code": 400, + "msg": "error", + "data": { + "images": ["At least one image must be provided via image_urls, image, or images."], + }, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + ai_payload = { + "farm_uuid": str(farm.farm_uuid), + "plant_name": payload.get("plant_name", ""), + "query": payload.get("query", ""), + "image_urls": image_urls, + } + sensor_uuid = payload.get("sensor_uuid") + if sensor_uuid: + ai_payload["sensor_uuid"] = str(sensor_uuid) + + ai_payload = self._attach_uploaded_files(ai_payload, uploaded_images) adapter_response = external_api_request( "ai", - "/pest-detection/risk-summary", - method="GET", - query=query, + "/api/pest-disease/detect/", + method="POST", + payload=ai_payload, ) - response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} - result = response_data.get("result", response_data.get("data", response_data)) + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) return Response( - {"status": "success", "data": result}, + {"code": 200, "msg": "success", "data": self._extract_result_payload(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class RiskView(PestDetectionFarmMixin, APIView): + @extend_schema( + tags=["Pest Detection"], + request=PestDetectionRiskRequestSerializer, + responses={200: status_response("PestDetectionRiskResponse", data=PestDetectionRiskResponseSerializer())}, + ) + def post(self, request): + serializer = PestDetectionRiskRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + ai_payload = { + "farm_uuid": str(farm.farm_uuid), + "plant_name": payload.get("plant_name", ""), + "growth_stage": payload.get("growth_stage", ""), + "query": payload.get("query", ""), + } + sensor_uuid = payload.get("sensor_uuid") + if sensor_uuid: + ai_payload["sensor_uuid"] = str(sensor_uuid) + + adapter_response = external_api_request( + "ai", + "/api/pest-disease/risk/", + method="POST", + payload=ai_payload, + ) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_result_payload(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class RiskSummaryView(PestDetectionFarmMixin, APIView): + @extend_schema( + tags=["Pest Detection"], + request=PestDetectionRiskRequestSerializer, + responses={200: status_response("PestDetectionRiskSummaryResponse", data=PestDetectionRiskSummaryResponseSerializer())}, + ) + def post(self, request): + farm_uuid = request.data.get("farm_uuid") + sensor_uuid = request.data.get("sensor_uuid") + + farm, error_response = self._get_farm(request, farm_uuid) + if error_response is not None: + return error_response + + payload = {"farm_uuid": str(farm.farm_uuid)} + if sensor_uuid: + payload["sensor_uuid"] = str(sensor_uuid) + + adapter_response = external_api_request( + "ai", + "/api/pest-disease/risk-summary/", + method="POST", + payload=payload, + ) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + result = self._extract_result_payload(adapter_response.data) + response_payload = { + "farm_uuid": str(farm.farm_uuid), + "diseaseRisk": result.get("diseaseRisk") or result.get("disease_risk") or {}, + "pestRisk": result.get("pestRisk") or result.get("pest_risk") or {}, + "drivers": result.get("drivers") if isinstance(result.get("drivers"), dict) else {}, + } + return Response( + {"code": 200, "msg": "success", "data": response_payload}, status=status.HTTP_200_OK, ) diff --git a/sensor_7_in_1/tests.py b/sensor_7_in_1/tests.py index 0c18d81..57fde42 100644 --- a/sensor_7_in_1/tests.py +++ b/sensor_7_in_1/tests.py @@ -9,7 +9,7 @@ from sensor_external_api.models import SensorExternalRequestLog from dashboard.services import get_farm_dashboard_cards from .services import get_sensor_7_in_1_summary_data -from .views import Sensor7In1SummaryView +from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView class Sensor7In1BaseTestCase(TestCase): @@ -118,3 +118,23 @@ class Sensor7In1ViewTests(Sensor7In1BaseTestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.data["farm_uuid"][0], "This field is required.") + + def test_radar_chart_view_returns_sensor_chart(self): + request = self.factory.get(f"/api/sensor-7-in-1/sensor-radar-chart/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = Sensor7In1RadarChartView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["series"][0]["name"], "اکنون") + + def test_comparison_chart_view_returns_sensor_chart(self): + request = self.factory.get(f"/api/sensor-7-in-1/sensor-comparison-chart/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = Sensor7In1ComparisonChartView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["currentValue"], 48.5) diff --git a/sensor_7_in_1/urls.py b/sensor_7_in_1/urls.py index fa4e921..b4e8fca 100644 --- a/sensor_7_in_1/urls.py +++ b/sensor_7_in_1/urls.py @@ -1,9 +1,14 @@ from django.urls import path -from .views import Sensor7In1SummaryView +from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView urlpatterns = [ path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"), + path("sensor-radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"), + path( + "sensor-comparison-chart/", + Sensor7In1ComparisonChartView.as_view(), + name="sensor-7-in-1-comparison-chart", + ), ] - diff --git a/sensor_7_in_1/views.py b/sensor_7_in_1/views.py index 626ac81..b34ffd9 100644 --- a/sensor_7_in_1/views.py +++ b/sensor_7_in_1/views.py @@ -2,14 +2,18 @@ from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, extend_schema +from drf_spectacular.utils import extend_schema -from config.swagger import code_response +from config.swagger import code_response, farm_uuid_query_param from farm_hub.models import FarmHub +from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer from .serializers import Sensor7In1SummarySerializer -from .services import get_sensor_7_in_1_summary_data +from .services import ( + get_sensor_7_in_1_comparison_chart_data, + get_sensor_7_in_1_radar_chart_data, + get_sensor_7_in_1_summary_data, +) class Sensor7In1SummaryView(APIView): @@ -29,13 +33,7 @@ class Sensor7In1SummaryView(APIView): @extend_schema( tags=["Sensor 7 in 1"], parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=True, - default="11111111-1111-1111-1111-111111111111", - ) + farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.") ], responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())}, ) @@ -46,3 +44,34 @@ class Sensor7In1SummaryView(APIView): status=status.HTTP_200_OK, ) + +class Sensor7In1RadarChartView(Sensor7In1SummaryView): + @extend_schema( + tags=["Sensor 7 in 1"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.") + ], + responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())}, + ) + def get(self, request): + farm = self._get_farm(request) + return Response( + {"code": 200, "msg": "OK", "data": get_sensor_7_in_1_radar_chart_data(farm)}, + status=status.HTTP_200_OK, + ) + + +class Sensor7In1ComparisonChartView(Sensor7In1SummaryView): + @extend_schema( + tags=["Sensor 7 in 1"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.") + ], + responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())}, + ) + def get(self, request): + farm = self._get_farm(request) + return Response( + {"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)}, + status=status.HTTP_200_OK, + ) diff --git a/sensor_external_api/services.py b/sensor_external_api/services.py index 262552e..cff8d30 100644 --- a/sensor_external_api/services.py +++ b/sensor_external_api/services.py @@ -1,7 +1,8 @@ -import requests from django.conf import settings from django.db import OperationalError, ProgrammingError, transaction +from external_api_adapter import request as external_api_request +from external_api_adapter.exceptions import ExternalAPIRequestError from farm_hub.models import FarmSensor from notifications.services import create_notification_for_farm_uuid @@ -108,7 +109,6 @@ def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None): raise ValueError("Physical device not found.") farm_boundary = _get_farm_boundary(sensor=sensor) - url = _build_farm_data_url() api_key = getattr(settings, "FARM_DATA_API_KEY", "") if not api_key: raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.") @@ -122,25 +122,23 @@ def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None): } try: - response = requests.post( - url, - json=request_payload, + response = external_api_request( + "ai", + _get_farm_data_path(), + method="POST", + payload=request_payload, headers={ "Accept": "application/json", "Content-Type": "application/json", "X-API-Key": api_key, "Authorization": f"Api-Key {api_key}", }, - timeout=getattr(settings, "FARM_DATA_API_TIMEOUT", 30), ) - except requests.RequestException as exc: + except ExternalAPIRequestError as exc: raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc if response.status_code >= 400: - try: - response_body = response.json() - except ValueError: - response_body = response.text + response_body = response.data raise FarmDataForwardError( f"Farm data API returned status {response.status_code}: {response_body}" ) @@ -163,11 +161,5 @@ def _get_farm_boundary(*, sensor): return geometry -def _build_farm_data_url(): - base_url = getattr(settings, "AI_SERVICE_BASE_URL", "").rstrip("/") - path = "/api/farm-data/" - - if not base_url: - raise FarmDataForwardError("FARM_DATA_API_HOST is not configured.") - - return f"{base_url}/{path.lstrip('/')}" +def _get_farm_data_path(): + return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/") diff --git a/sensor_external_api/tests.py b/sensor_external_api/tests.py index b80c0d2..7cd35f2 100644 --- a/sensor_external_api/tests.py +++ b/sensor_external_api/tests.py @@ -1,9 +1,10 @@ -import requests from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory -from unittest.mock import Mock, patch +from unittest.mock import patch +from external_api_adapter.adapter import AdapterResponse +from external_api_adapter.exceptions import ExternalAPIRequestError from crop_zoning.models import CropArea from farm_hub.models import FarmHub, FarmSensor, FarmType from notifications.models import FarmNotification @@ -16,8 +17,6 @@ from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView @override_settings( SENSOR_EXTERNAL_API_KEY="12345", - FARM_DATA_API_HOST="http://localhost", - FARM_DATA_API_PORT="8020", FARM_DATA_API_KEY="farm-data-key", ) class SensorExternalAPIViewTests(TestCase): @@ -85,9 +84,9 @@ class SensorExternalAPIViewTests(TestCase): self.assertEqual(response.status_code, 401) - @patch("sensor_external_api.services.requests.post") - def test_creates_notification_and_request_log_for_device_uuid(self, mock_post): - mock_post.return_value = Mock(status_code=201) + @patch("sensor_external_api.services.external_api_request") + def test_creates_notification_and_request_log_for_device_uuid(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) request = self.factory.post( "/api/sensor-external-api/", {"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}}, @@ -112,9 +111,11 @@ class SensorExternalAPIViewTests(TestCase): payload={"temp": 12}, ).exists() ) - mock_post.assert_called_once_with( - "http://localhost:8020/api/farm-data/", - json={ + mock_external_api_request.assert_called_once_with( + "ai", + "/api/farm-data/", + method="POST", + payload={ "farm_uuid": str(self.farm.farm_uuid), "farm_boundary": self.crop_area.geometry, "sensor_payload": { @@ -127,7 +128,6 @@ class SensorExternalAPIViewTests(TestCase): "X-API-Key": "farm-data-key", "Authorization": "Api-Key farm-data-key", }, - timeout=30, ) def test_returns_404_for_unknown_device_uuid(self): @@ -142,9 +142,9 @@ class SensorExternalAPIViewTests(TestCase): self.assertEqual(response.status_code, 404) - @patch("sensor_external_api.services.requests.post") - def test_returns_503_when_farm_data_api_is_unavailable(self, mock_post): - mock_post.side_effect = requests.RequestException("connection error") + @patch("sensor_external_api.services.external_api_request") + def test_returns_503_when_farm_data_api_is_unavailable(self, mock_external_api_request): + mock_external_api_request.side_effect = ExternalAPIRequestError("connection error") request = self.factory.post( "/api/sensor-external-api/", diff --git a/soil/serializers.py b/soil/serializers.py index 9d6a468..f05edeb 100644 --- a/soil/serializers.py +++ b/soil/serializers.py @@ -2,14 +2,14 @@ from rest_framework import serializers class SoilKpiSerializer(serializers.Serializer): - id = serializers.CharField(required=False, allow_blank=True) - title = serializers.CharField(required=False, allow_blank=True) - subtitle = serializers.CharField(required=False, allow_blank=True) - stats = serializers.CharField(required=False, allow_blank=True) - avatarColor = serializers.CharField(required=False, allow_blank=True) - avatarIcon = serializers.CharField(required=False, allow_blank=True) - chipText = serializers.CharField(required=False, allow_blank=True) - chipColor = serializers.CharField(required=False, allow_blank=True) + id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه کارت KPI.") + title = serializers.CharField(required=False, allow_blank=True, help_text="عنوان کارت KPI.") + subtitle = serializers.CharField(required=False, allow_blank=True, help_text="زیرعنوان کارت KPI.") + stats = serializers.CharField(required=False, allow_blank=True, help_text="مقدار اصلی KPI.") + avatarColor = serializers.CharField(required=False, allow_blank=True, help_text="رنگ آواتار کارت.") + avatarIcon = serializers.CharField(required=False, allow_blank=True, help_text="آیکون کارت.") + chipText = serializers.CharField(required=False, allow_blank=True, help_text="متن وضعیت KPI.") + chipColor = serializers.CharField(required=False, allow_blank=True, help_text="رنگ وضعیت KPI.") class SoilRadarSeriesSerializer(serializers.Serializer): @@ -39,7 +39,18 @@ class SoilAnomalyItemSerializer(serializers.Serializer): class SoilAnomalyDetectionSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + summary = serializers.CharField(required=False, allow_blank=True, help_text="خلاصه کوتاه ناهنجاری خاک.") + explanation = serializers.CharField(required=False, allow_blank=True, help_text="توضیح کوتاه درباره ناهنجاری.") + likely_cause = serializers.CharField(required=False, allow_blank=True, help_text="علت محتمل ناهنجاری.") + recommended_action = serializers.CharField(required=False, allow_blank=True, help_text="اقدام پیشنهادی برای رفع مشکل.") + monitoring_priority = serializers.CharField(required=False, allow_blank=True, help_text="اولویت پایش؛ low/medium/high/urgent.") + confidence = serializers.FloatField(required=False, help_text="میزان اطمینان مدل به تحلیل.") + generated_at = serializers.CharField(required=False, allow_blank=True, help_text="زمان تولید تحلیل.") anomalies = SoilAnomalyItemSerializer(many=True, required=False) + interpretation = serializers.DictField(required=False, help_text="تفسیر ساختاریافته ناهنجاری‌ها.") + knowledge_base = serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="مرجع دانشی استفاده‌شده.") + raw_response = serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="پاسخ خام upstream در صورت وجود.") class SoilHeatmapPointSerializer(serializers.Serializer): @@ -52,15 +63,32 @@ class SoilHeatmapSeriesSerializer(serializers.Serializer): data = SoilHeatmapPointSerializer(many=True, required=False) +class SoilGenericDictSerializer(serializers.Serializer): + class Meta: + ref_name = "SoilGenericDict" + + class SoilMoistureHeatmapSerializer(serializers.Serializer): - zones = serializers.ListField(child=serializers.CharField(), required=False) - hours = serializers.ListField(child=serializers.CharField(), required=False) - series = SoilHeatmapSeriesSerializer(many=True, required=False) + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + location = serializers.DictField(required=False, help_text="اطلاعات مکانی مزرعه یا ناحیه تحلیل.") + current_sensor = serializers.DictField(required=False, help_text="مشخصات سنسور فعال فعلی.") + soil_profile = serializers.ListField(child=serializers.DictField(), required=False, help_text="پروفایل خاک در لایه‌های مختلف.") + timestamp = serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="زمان تولید heatmap.") + grid_resolution = serializers.DictField(required=False, help_text="رزولوشن شبکه heatmap.") + grid_cells = serializers.ListField(child=serializers.DictField(), required=False, help_text="سلول‌های شبکه heatmap.") + sensor_points = serializers.ListField(child=serializers.DictField(), required=False, help_text="نقاط سنسور مؤثر در heatmap.") + quality_legend = serializers.DictField(required=False, help_text="legend یا بازه‌بندی کیفیت رطوبت.") + depth_layers = serializers.ListField(child=serializers.DictField(), required=False, help_text="لایه‌های عمقی خاک.") + model_metadata = serializers.DictField(required=False, help_text="متادیتای مدل تولیدکننده heatmap.") + summary = serializers.DictField(required=False, help_text="خلاصه تفسیری heatmap.") class SoilSummarySerializer(serializers.Serializer): - avgSoilMoisture = SoilKpiSerializer(required=False) - sensorRadarChart = SoilRadarChartSerializer(required=False) - sensorComparisonChart = SoilComparisonChartSerializer(required=False) - anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False) - soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False) + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + healthScore = serializers.IntegerField(required=False, help_text="امتیاز سلامت کلی خاک.") + profileSource = serializers.CharField(required=False, allow_blank=True, help_text="منبع پروفایل مرجع یا محصول هدف.") + healthScoreDetails = serializers.DictField(required=False, help_text="جزئیات تشکیل‌دهنده health score.") + healthLanguage = serializers.DictField(required=False, help_text="توضیحات متنی قابل نمایش برای سلامت خاک.") + avgSoilMoisture = serializers.IntegerField(required=False, help_text="میانگین رطوبت خاک به‌صورت عدد گرد شده.") + avgSoilMoistureRaw = serializers.FloatField(required=False, help_text="میانگین خام رطوبت خاک.") + avgSoilMoistureStatus = serializers.CharField(required=False, allow_blank=True, help_text="وضعیت متنی رطوبت خاک.") diff --git a/soil/tests.py b/soil/tests.py new file mode 100644 index 0000000..e72f1b4 --- /dev/null +++ b/soil/tests.py @@ -0,0 +1,210 @@ +from unittest.mock import patch + +from django.test import TestCase +from rest_framework.test import APIRequestFactory + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType +from account.models import User + +from .views import SoilAnomalyDetectionView, SoilMoistureHeatmapView, SoilSummaryView + + +class SoilAnomalyDetectionViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = User.objects.create_user( + username="soil-user", + password="secret123", + email="soil@example.com", + phone_number="09120000100", + ) + self.farm_type = FarmType.objects.create(name="Soil Farm Type") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Soil Farm", + ) + + @patch("soil.views.external_api_request") + def test_anomalies_proxy_to_soile_anomaly_detection(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "summary": "summary", + "explanation": "explanation", + "likely_cause": "cause", + "recommended_action": "action", + "monitoring_priority": "high", + "confidence": 0.91, + "generated_at": "2026-04-26T10:00:00Z", + "anomalies": [], + "interpretation": {}, + "knowledge_base": None, + "raw_response": None, + } + }, + ) + + request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={self.farm.farm_uuid}") + response = SoilAnomalyDetectionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["monitoring_priority"], "high") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/soile/anomaly-detection/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_anomalies_require_farm_uuid(self): + request = self.factory.get("/api/soil/anomalies/") + response = SoilAnomalyDetectionView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], 400) + self.assertEqual(response.data["data"]["farm_uuid"][0], "This field is required.") + + def test_anomalies_return_404_for_missing_farm(self): + request = self.factory.get("/api/soil/anomalies/?farm_uuid=11111111-1111-1111-1111-111111111111") + response = SoilAnomalyDetectionView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") + + +class SoilMoistureHeatmapViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = User.objects.create_user( + username="soil-heatmap-user", + password="secret123", + email="soil-heatmap@example.com", + phone_number="09120000101", + ) + self.farm_type = FarmType.objects.create(name="Soil Heatmap Farm Type") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Heatmap Farm", + ) + + @patch("soil.views.external_api_request") + def test_heatmap_proxies_to_soile_moisture_heatmap(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "location": {}, + "current_sensor": {}, + "soil_profile": [], + "timestamp": "2026-04-26T10:00:00Z", + "grid_resolution": {}, + "grid_cells": [], + "sensor_points": [], + "quality_legend": {}, + "depth_layers": [], + "model_metadata": {}, + "summary": {}, + } + }, + ) + + request = self.factory.get(f"/api/soil/moisture-heatmap/?farm_uuid={self.farm.farm_uuid}") + response = SoilMoistureHeatmapView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/soile/moisture-heatmap/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_heatmap_requires_farm_uuid(self): + request = self.factory.get("/api/soil/moisture-heatmap/") + response = SoilMoistureHeatmapView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], 400) + self.assertEqual(response.data["data"]["farm_uuid"][0], "This field is required.") + + def test_heatmap_returns_404_for_missing_farm(self): + request = self.factory.get("/api/soil/moisture-heatmap/?farm_uuid=11111111-1111-1111-1111-111111111111") + response = SoilMoistureHeatmapView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") + + +class SoilSummaryViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = User.objects.create_user( + username="soil-summary-user", + password="secret123", + email="soil-summary@example.com", + phone_number="09120000102", + ) + self.farm_type = FarmType.objects.create(name="Soil Summary Farm Type") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Summary Farm", + ) + + @patch("soil.views.external_api_request") + def test_summary_proxies_to_soile_health_summary(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "healthScore": 82, + "profileSource": "Tomato", + "healthScoreDetails": {}, + "healthLanguage": {}, + "avgSoilMoisture": 46, + "avgSoilMoistureRaw": 46.0, + "avgSoilMoistureStatus": "بهینه", + } + }, + ) + + request = self.factory.get(f"/api/soil/summary/?farm_uuid={self.farm.farm_uuid}") + response = SoilSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["healthScore"], 82) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/soile/health-summary/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_summary_requires_farm_uuid(self): + request = self.factory.get("/api/soil/summary/") + response = SoilSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], 400) + self.assertEqual(response.data["data"]["farm_uuid"][0], "This field is required.") + + def test_summary_returns_404_for_missing_farm(self): + request = self.factory.get("/api/soil/summary/?farm_uuid=11111111-1111-1111-1111-111111111111") + response = SoilSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") diff --git a/soil/urls.py b/soil/urls.py index 7c1f420..1d766ca 100644 --- a/soil/urls.py +++ b/soil/urls.py @@ -2,8 +2,6 @@ from django.urls import path from .views import ( AvgSoilMoistureView, - SensorComparisonChartView, - SensorRadarChartView, SoilAnomalyDetectionView, SoilMoistureHeatmapView, SoilSummaryView, @@ -11,8 +9,6 @@ from .views import ( urlpatterns = [ path("avg-moisture/", AvgSoilMoistureView.as_view(), name="soil-avg-moisture"), - path("sensor-radar-chart/", SensorRadarChartView.as_view(), name="soil-sensor-radar-chart"), - path("sensor-comparison-chart/", SensorComparisonChartView.as_view(), name="soil-sensor-comparison-chart"), path("anomalies/", SoilAnomalyDetectionView.as_view(), name="soil-anomalies"), path("moisture-heatmap/", SoilMoistureHeatmapView.as_view(), name="soil-moisture-heatmap"), path("summary/", SoilSummaryView.as_view(), name="soil-summary"), diff --git a/soil/views.py b/soil/views.py index 51b1ca0..f710ed5 100644 --- a/soil/views.py +++ b/soil/views.py @@ -1,27 +1,22 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, extend_schema +from drf_spectacular.utils import extend_schema -from config.swagger import status_response +from config.swagger import farm_uuid_query_param, status_response +from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub from .serializers import ( SoilAnomalyDetectionSerializer, - SoilComparisonChartSerializer, SoilKpiSerializer, SoilMoistureHeatmapSerializer, - SoilRadarChartSerializer, SoilSummarySerializer, ) from .services import ( get_anomaly_detection_card_data, get_avg_soil_moisture_data, - get_sensor_comparison_chart_data, - get_sensor_radar_chart_data, get_soil_moisture_heatmap_data, - get_soil_summary_data, ) @@ -35,18 +30,28 @@ def _get_farm_from_request(request): return None +def _extract_adapter_result(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + class AvgSoilMoistureView(APIView): @extend_schema( tags=["Soil"], parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=False, - description="UUID of the farm for average soil moisture.", - default="11111111-1111-1111-1111-111111111111", - ), + farm_uuid_query_param(required=False, description="UUID of the farm for average soil moisture."), ], responses={200: status_response("AvgSoilMoistureResponse", data=SoilKpiSerializer())}, ) @@ -57,47 +62,48 @@ class AvgSoilMoistureView(APIView): ) -class SensorRadarChartView(APIView): - @extend_schema( - tags=["Soil"], - parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), - ], - responses={200: status_response("SensorRadarChartResponse", data=SoilRadarChartSerializer())}, - ) - def get(self, request): - return Response( - {"status": "success", "data": get_sensor_radar_chart_data(_get_farm_from_request(request))}, - status=status.HTTP_200_OK, - ) - - -class SensorComparisonChartView(APIView): - @extend_schema( - tags=["Soil"], - parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), - ], - responses={200: status_response("SensorComparisonChartResponse", data=SoilComparisonChartSerializer())}, - ) - def get(self, request): - return Response( - {"status": "success", "data": get_sensor_comparison_chart_data(_get_farm_from_request(request))}, - status=status.HTTP_200_OK, - ) - - class SoilAnomalyDetectionView(APIView): @extend_schema( tags=["Soil"], parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + farm_uuid_query_param(required=True, description="UUID of the farm for soil anomaly detection."), ], responses={200: status_response("SoilAnomalyDetectionResponse", data=SoilAnomalyDetectionSerializer())}, ) def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + if not farm_uuid: + return Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + + farm = _get_farm_from_request(request) + if farm is None: + return Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + adapter_response = external_api_request( + "ai", + "/api/soile/anomaly-detection/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + return Response( - {"status": "success", "data": get_anomaly_detection_card_data(_get_farm_from_request(request))}, + {"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)}, status=status.HTTP_200_OK, ) @@ -106,13 +112,44 @@ class SoilMoistureHeatmapView(APIView): @extend_schema( tags=["Soil"], parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + farm_uuid_query_param(required=True, description="UUID of the farm for soil moisture heatmap."), ], responses={200: status_response("SoilMoistureHeatmapResponse", data=SoilMoistureHeatmapSerializer())}, ) def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + if not farm_uuid: + return Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + + farm = _get_farm_from_request(request) + if farm is None: + return Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + adapter_response = external_api_request( + "ai", + "/api/soile/moisture-heatmap/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + return Response( - {"status": "success", "data": get_soil_moisture_heatmap_data(_get_farm_from_request(request))}, + {"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)}, status=status.HTTP_200_OK, ) @@ -121,12 +158,43 @@ class SoilSummaryView(APIView): @extend_schema( tags=["Soil"], parameters=[ - OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + farm_uuid_query_param(required=True, description="UUID of the farm for soil health summary."), ], responses={200: status_response("SoilSummaryResponse", data=SoilSummarySerializer())}, ) def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + if not farm_uuid: + return Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + + farm = _get_farm_from_request(request) + if farm is None: + return Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + adapter_response = external_api_request( + "ai", + "/api/soile/health-summary/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + return Response( - {"status": "success", "data": get_soil_summary_data(_get_farm_from_request(request))}, + {"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)}, status=status.HTTP_200_OK, ) diff --git a/water/serializers.py b/water/serializers.py index 79e4cfb..ccbee5f 100644 --- a/water/serializers.py +++ b/water/serializers.py @@ -10,12 +10,12 @@ class WeatherChartDataSerializer(serializers.Serializer): class FarmWeatherCardSerializer(serializers.Serializer): - condition = serializers.CharField(required=False, allow_blank=True) - temperature = serializers.FloatField(required=False) - unit = serializers.CharField(required=False, allow_blank=True) - humidity = serializers.IntegerField(required=False) - windSpeed = serializers.FloatField(required=False) - windUnit = serializers.CharField(required=False, allow_blank=True) + condition = serializers.CharField(required=False, allow_blank=True, help_text="وضعیت فعلی آب‌وهوا.") + temperature = serializers.FloatField(required=False, help_text="دمای فعلی.") + unit = serializers.CharField(required=False, allow_blank=True, help_text="واحد دما.") + humidity = serializers.IntegerField(required=False, help_text="رطوبت نسبی.") + windSpeed = serializers.FloatField(required=False, help_text="سرعت باد.") + windUnit = serializers.CharField(required=False, allow_blank=True, help_text="واحد سرعت باد.") chartData = WeatherChartDataSerializer(required=False) @@ -25,21 +25,22 @@ class WaterNeedSeriesSerializer(serializers.Serializer): class WaterNeedPredictionSerializer(serializers.Serializer): - totalNext7Days = serializers.FloatField(required=False) - unit = serializers.CharField(required=False, allow_blank=True) - categories = serializers.ListField(child=serializers.CharField(), required=False) + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + totalNext7Days = serializers.FloatField(required=False, help_text="جمع نیاز آبی ۷ روز آینده.") + unit = serializers.CharField(required=False, allow_blank=True, help_text="واحد نیاز آبی.") + categories = serializers.ListField(child=serializers.CharField(), required=False, help_text="برچسب روزها یا تاریخ‌ها.") series = WaterNeedSeriesSerializer(many=True, required=False) + dailyBreakdown = serializers.ListField(child=serializers.DictField(), required=False, help_text="جزئیات روزانه پیش‌بینی.") + insight = serializers.DictField(required=False, help_text="جمع‌بندی و insight تحلیلی.") + knowledge_base = serializers.CharField(required=False, allow_blank=True, help_text="مرجع دانشی در صورت ارائه توسط upstream.") + raw_response = serializers.CharField(required=False, allow_blank=True, help_text="پاسخ خام upstream در صورت وجود.") class WaterStressIndexSerializer(serializers.Serializer): - id = serializers.CharField(required=False, allow_blank=True) - title = serializers.CharField(required=False, allow_blank=True) - subtitle = serializers.CharField(required=False, allow_blank=True) - stats = serializers.CharField(required=False, allow_blank=True) - avatarColor = serializers.CharField(required=False, allow_blank=True) - avatarIcon = serializers.CharField(required=False, allow_blank=True) - chipText = serializers.CharField(required=False, allow_blank=True) - chipColor = serializers.CharField(required=False, allow_blank=True) + farm_uuid = serializers.UUIDField(required=False, allow_null=True, help_text="UUID مزرعه.") + waterStressIndex = serializers.IntegerField(required=False, help_text="شاخص تنش آبی.") + level = serializers.CharField(required=False, allow_blank=True, help_text="سطح تنش آبی.") + sourceMetric = serializers.DictField(required=False, help_text="متریک یا منبع محاسبه تنش آبی.") class WaterSummarySerializer(serializers.Serializer): diff --git a/water/tests.py b/water/tests.py new file mode 100644 index 0000000..fb078f2 --- /dev/null +++ b/water/tests.py @@ -0,0 +1,108 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import Resolver404, resolve +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType + +from .views import WaterNeedPredictionView, WeatherFarmCardView + + +class WeatherViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + + @patch("water.views.external_api_request") + def test_farm_card_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"condition": "صاف", "temperature": 28.0}}}, + ) + + request = self.factory.post("/api/weather/farm-card/", {"farm_uuid": str(self.farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = WeatherFarmCardView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["condition"], "صاف") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/weather/farm-card/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + @patch("water.views.external_api_request") + def test_get_water_need_prediction_uses_same_ai_service_for_farm_uuid(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"totalNext7Days": 24.6, "unit": "mm"}}}, + ) + + request = self.factory.get(f"/api/water/need-prediction/?farm_uuid={self.farm.farm_uuid}") + + response = WaterNeedPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + self.assertEqual(response.data["data"]["totalNext7Days"], 24.6) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/weather/water-need-prediction/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_weather_view_rejects_foreign_farm_uuid(self): + request = self.factory.post("/api/weather/farm-card/", {"farm_uuid": str(self.other_farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = WeatherFarmCardView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + + def test_weather_post_routes_exist_only_under_weather_prefix(self): + self.assertIs(resolve("/api/weather/farm-card/").func.view_class, WeatherFarmCardView) + + with self.assertRaises(Resolver404): + resolve("/api/water/farm-card/") + + with self.assertRaises(Resolver404): + resolve("/api/water/water-need-prediction/") + + def test_water_get_routes_do_not_exist_under_weather_prefix(self): + with self.assertRaises(Resolver404): + resolve("/api/weather/card/") + + with self.assertRaises(Resolver404): + resolve("/api/weather/need-prediction/") + + with self.assertRaises(Resolver404): + resolve("/api/weather/water-need-prediction/") + + with self.assertRaises(Resolver404): + resolve("/api/weather/stress-index/") + + with self.assertRaises(Resolver404): + resolve("/api/weather/summary/") diff --git a/water/views.py b/water/views.py index 66a86d8..9c113cc 100644 --- a/water/views.py +++ b/water/views.py @@ -8,7 +8,7 @@ from rest_framework.views import APIView from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema -from config.swagger import status_response +from config.swagger import farm_uuid_query_param, sensor_uuid_query_param, status_response from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub from .models import WeatherForecastLog @@ -38,13 +38,7 @@ class FarmWeatherCardView(APIView): @extend_schema( tags=["WATER"], parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=False, - description="UUID of the farm to fetch weather data for.", - default="11111111-1111-1111-1111-111111111111"), + farm_uuid_query_param(required=False, description="UUID of the farm to fetch weather data for."), ], responses={200: status_response("FarmWeatherCardResponse", data=FarmWeatherCardSerializer())}, ) @@ -90,29 +84,118 @@ class FarmWeatherCardView(APIView): ) +class WeatherFarmBaseView(APIView): + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + @staticmethod + def _extract_result(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + @staticmethod + def _error_response(adapter_response): + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + @classmethod + def _fetch_water_need_prediction_data(cls, farm_uuid): + adapter_response = external_api_request( + "ai", + "/api/weather/water-need-prediction/", + method="POST", + payload={"farm_uuid": str(farm_uuid)}, + ) + if adapter_response.status_code >= 400: + return None, cls._error_response(adapter_response) + + prediction_data = cls._extract_result(adapter_response.data) + if isinstance(prediction_data, dict): + prediction_data.setdefault("farm_uuid", str(farm_uuid)) + return prediction_data, None + + +class WeatherFarmCardView(WeatherFarmBaseView): + @extend_schema( + tags=["WEATHER"], + request=serializers.Serializer, + responses={200: status_response("WeatherFarmCardResponse", data=FarmWeatherCardSerializer())}, + ) + def post(self, request): + farm, error_response = self._get_farm(request, request.data.get("farm_uuid")) + if error_response is not None: + return error_response + + adapter_response = external_api_request( + "ai", + "/api/weather/farm-card/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + card_data = self._extract_result(adapter_response.data) + FarmWeatherCardView._persist_log(farm.farm_uuid, card_data) + return Response({"code": 200, "msg": "success", "data": card_data}, status=status.HTTP_200_OK) + + class WaterNeedPredictionView(APIView): @extend_schema( tags=["WATER"], parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=False, - description="UUID of the farm to fetch water need prediction for.", - default="11111111-1111-1111-1111-111111111111", - ), + farm_uuid_query_param(required=False, description="UUID of the farm to fetch water need prediction for."), ], responses={200: status_response("WaterNeedPredictionResponse", data=WaterNeedPredictionSerializer())}, ) def get(self, request): - farm = None farm_uuid = request.query_params.get("farm_uuid") if farm_uuid: try: farm = FarmHub.objects.get(farm_uuid=farm_uuid) except (FarmHub.DoesNotExist, Exception): farm = None + else: + prediction_data, error_response = WeatherFarmBaseView._fetch_water_need_prediction_data(farm.farm_uuid) + if error_response is not None: + return error_response + return Response( + {"status": "success", "data": prediction_data}, + status=status.HTTP_200_OK, + ) + else: + farm = None return Response( {"status": "success", "data": get_water_need_prediction_data(farm)}, @@ -121,31 +204,73 @@ class WaterNeedPredictionView(APIView): class WaterStressIndexView(APIView): + @staticmethod + def _get_farm(farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + @staticmethod + def extract_stress_payload(adapter_data, farm_uuid): + if not isinstance(adapter_data, dict): + return { + "farm_uuid": str(farm_uuid), + "waterStressIndex": 0, + "level": "", + "sourceMetric": {}, + } + + data = adapter_data.get("data") if isinstance(adapter_data.get("data"), dict) else adapter_data + result = data.get("result") if isinstance(data, dict) and isinstance(data.get("result"), dict) else data + + return { + "farm_uuid": str(farm_uuid), + "waterStressIndex": int(result.get("waterStressIndex") or 0), + "level": str(result.get("level") or ""), + "sourceMetric": result.get("sourceMetric") if isinstance(result.get("sourceMetric"), dict) else {}, + } + @extend_schema( tags=["WATER"], parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=False, - description="UUID of the farm to fetch water stress index for.", - default="11111111-1111-1111-1111-111111111111", - ), + farm_uuid_query_param(required=True, description="UUID of the farm to fetch water stress index for."), + sensor_uuid_query_param(), ], responses={200: status_response("WaterStressIndexResponse", data=WaterStressIndexSerializer())}, ) def get(self, request): - farm = None farm_uuid = request.query_params.get("farm_uuid") - if farm_uuid: - try: - farm = FarmHub.objects.get(farm_uuid=farm_uuid) - except (FarmHub.DoesNotExist, Exception): - farm = None + sensor_uuid = request.query_params.get("sensor_uuid") + farm = self._get_farm(farm_uuid) + + query = {"farm_uuid": str(farm.farm_uuid)} + if sensor_uuid: + query["sensor_uuid"] = str(sensor_uuid) + + adapter_response = external_api_request( + "ai", + "/api/water/stress-index/", + method="GET", + query=query, + ) + + if adapter_response.status_code >= 400: + return Response( + { + "code": adapter_response.status_code, + "msg": "error", + "data": adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)}, + }, + status=adapter_response.status_code, + ) + + stress_payload = self.extract_stress_payload(adapter_response.data, farm.farm_uuid) return Response( - {"status": "success", "data": get_water_stress_index_data(farm)}, + {"code": 200, "msg": "success", "data": stress_payload}, status=status.HTTP_200_OK, ) @@ -154,14 +279,7 @@ class WaterSummaryView(APIView): @extend_schema( tags=["WATER"], parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=False, - description="UUID of the farm to fetch water summary for.", - default="11111111-1111-1111-1111-111111111111", - ), + farm_uuid_query_param(required=False, description="UUID of the farm to fetch water summary for."), ], responses={200: status_response("WaterSummaryResponse", data=WaterSummarySerializer())}, ) diff --git a/water/weather_urls.py b/water/weather_urls.py new file mode 100644 index 0000000..0851848 --- /dev/null +++ b/water/weather_urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import WeatherFarmCardView + +urlpatterns = [ + path("farm-card/", WeatherFarmCardView.as_view(), name="weather-farm-card"), +] diff --git a/yield_harvest/serializers.py b/yield_harvest/serializers.py index dd8e7c1..138e66c 100644 --- a/yield_harvest/serializers.py +++ b/yield_harvest/serializers.py @@ -1,40 +1,5 @@ from rest_framework import serializers -from .mock_data import CONFIG_SLIDERS_ONLY - - -START_ENVIRONMENT_KEYS = [ - item["key"] - for item in CONFIG_SLIDERS_ONLY["sliders"] - if item["key"] != "growth_speed" -] - - -def _defaults_from_sliders(): - return { - item["key"]: item["default_value"] - for item in CONFIG_SLIDERS_ONLY["sliders"] - } - - -START_REQUEST_EXAMPLE = { - "environment": { - key: value for key, value in _defaults_from_sliders().items() if key != "growth_speed" - }, - "growth_speed": _defaults_from_sliders().get("growth_speed", 1.5), -} - - -START_REQUEST_EXAMPLE_STATIC = { - "environment": { - "light": 75, - "water": 65, - "soil_ph": 6.5, - }, - "growth_speed": 1.5, -} - - def success_response(): return {"status": "success"} @@ -86,3 +51,114 @@ class YieldHarvestSummarySerializer(serializers.Serializer): yield_prediction_card = YieldPredictionCardSerializer(required=False) yield_prediction_chart = YieldPredictionChartSerializer(required=False) harvest_prediction_card = HarvestPredictionCardSerializer(required=False) + + +class CropSimulationRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای اجرای شبیه‌سازی.") + plant_name = serializers.CharField(required=False, allow_blank=True, default="", help_text="نام گیاه یا محصول.") + + +class GrowthSimulationRequestSerializer(serializers.Serializer): + plant_name = serializers.CharField(required=True, help_text="نام گیاه برای شروع شبیه‌سازی رشد.") + dynamic_parameters = serializers.ListField( + child=serializers.CharField(), + required=True, + allow_empty=False, + help_text="لیست پارامترهای دینامیک موردنیاز مانند DVS یا LAI.", + ) + farm_uuid = serializers.UUIDField(required=False, allow_null=True, help_text="UUID مزرعه؛ در صورت نبود باید weather ارسال شود.") + weather = serializers.JSONField(required=False, help_text="آب‌وهوا به‌صورت object یا array.") + soil_parameters = serializers.DictField(required=False, help_text="پارامترهای خاک.") + site_parameters = serializers.DictField(required=False, help_text="پارامترهای سایت.") + crop_parameters = serializers.DictField(required=False, help_text="پارامترهای محصول.") + agromanagement = serializers.DictField(required=False, help_text="تنظیمات مدیریت زراعی.") + page_size = serializers.IntegerField(required=False, min_value=1, max_value=50, help_text="اندازه صفحه بین 1 تا 50.") + + def validate(self, attrs): + if not attrs.get("farm_uuid") and attrs.get("weather") in (None, "", [], {}): + raise serializers.ValidationError("At least one of 'farm_uuid' or 'weather' must be provided.") + return attrs + + +class GrowthSimulationQueuedDataSerializer(serializers.Serializer): + task_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه تسک شبیه‌سازی رشد.") + status_url = serializers.CharField(required=False, allow_blank=True, help_text="آدرس بررسی وضعیت تسک.") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه شبیه‌سازی‌شده.") + + +class GrowthSimulationProgressSerializer(serializers.Serializer): + current = serializers.IntegerField(required=False, help_text="مرحله فعلی پیشرفت.") + total = serializers.IntegerField(required=False, help_text="تعداد کل مراحل.") + percent = serializers.FloatField(required=False, help_text="درصد پیشرفت.") + + +class GrowthSimulationPaginationSerializer(serializers.Serializer): + page = serializers.IntegerField(required=False, help_text="شماره صفحه فعلی.") + page_size = serializers.IntegerField(required=False, help_text="اندازه صفحه.") + total_items = serializers.IntegerField(required=False, help_text="تعداد کل آیتم‌ها.") + total_pages = serializers.IntegerField(required=False, help_text="تعداد کل صفحات.") + has_next = serializers.BooleanField(required=False, help_text="آیا صفحه بعدی وجود دارد.") + has_previous = serializers.BooleanField(required=False, help_text="آیا صفحه قبلی وجود دارد.") + + +class GrowthSimulationResultSerializer(serializers.Serializer): + plant_name = serializers.CharField(required=False, allow_blank=True) + dynamic_parameters = serializers.ListField(child=serializers.CharField(), required=False) + engine = serializers.CharField(required=False, allow_blank=True, allow_null=True) + model_name = serializers.CharField(required=False, allow_blank=True, allow_null=True) + scenario_id = serializers.IntegerField(required=False) + simulation_warning = serializers.CharField(required=False, allow_blank=True) + summary_metrics = serializers.DictField(required=False) + stage_timeline = serializers.ListField(child=serializers.DictField(), required=False) + stages_page = serializers.ListField(child=serializers.DictField(), required=False) + pagination = GrowthSimulationPaginationSerializer(required=False) + daily_records_count = serializers.IntegerField(required=False) + default_page_size = serializers.IntegerField(required=False) + + +class GrowthSimulationStatusDataSerializer(serializers.Serializer): + task_id = serializers.CharField(required=False, allow_blank=True) + status = serializers.CharField(required=False, allow_blank=True) + message = serializers.CharField(required=False, allow_blank=True) + progress = GrowthSimulationProgressSerializer(required=False) + result = GrowthSimulationResultSerializer(required=False) + error = serializers.CharField(required=False, allow_blank=True) + + +class CurrentFarmChartSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True, allow_null=True) + plant_name = serializers.CharField(required=False, allow_blank=True) + engine = serializers.CharField(required=False, allow_blank=True, allow_null=True) + model_name = serializers.CharField(required=False, allow_blank=True, allow_null=True) + scenario_id = serializers.IntegerField(required=False) + simulation_warning = serializers.CharField(required=False, allow_blank=True) + categories = serializers.ListField(child=serializers.CharField(), required=False) + series = serializers.DictField(required=False) + summary = serializers.DictField(required=False) + current_state = serializers.DictField(required=False) + metrics = serializers.DictField(required=False) + daily_output = serializers.DictField(required=False) + + +class HarvestPredictionSerializer(serializers.Serializer): + date = serializers.CharField(required=False, allow_blank=True) + dateFormatted = serializers.CharField(required=False, allow_blank=True) + daysUntil = serializers.IntegerField(required=False) + description = serializers.CharField(required=False, allow_blank=True) + optimalWindowStart = serializers.CharField(required=False, allow_blank=True) + optimalWindowEnd = serializers.CharField(required=False, allow_blank=True) + gddDetails = serializers.DictField(required=False) + + +class YieldPredictionSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True) + plant_name = serializers.CharField(required=False, allow_blank=True, allow_null=True) + predictedYieldTons = serializers.FloatField(required=False) + predictedYieldRaw = serializers.FloatField(required=False) + unit = serializers.CharField(required=False, allow_blank=True) + sourceUnit = serializers.CharField(required=False, allow_blank=True) + simulationEngine = serializers.CharField(required=False, allow_blank=True, allow_null=True) + simulationModel = serializers.CharField(required=False, allow_blank=True, allow_null=True) + scenarioId = serializers.IntegerField(required=False) + simulationWarning = serializers.CharField(required=False, allow_blank=True) + supportingMetrics = serializers.DictField(required=False) diff --git a/yield_harvest/tests.py b/yield_harvest/tests.py new file mode 100644 index 0000000..1843430 --- /dev/null +++ b/yield_harvest/tests.py @@ -0,0 +1,294 @@ +import json +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType + +from .views import ( + CurrentFarmChartView, + GrowthSimulationStatusView, + GrowthSimulationView, + HarvestPredictionView, + YieldPredictionView, +) + + +class CropSimulationViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + + @patch("yield_harvest.views.external_api_request") + def test_growth_queues_simulation_task(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=202, + data={ + "data": { + "task_id": "growth-task-123", + "status_url": "/api/crop-simulation/growth/growth-task-123/status/", + "plant_name": "گوجه‌فرنگی", + } + }, + ) + + request = self.factory.post( + "/api/yield-harvest/crop-simulation/growth/", + {"plant_name": "گوجه‌فرنگی", "dynamic_parameters": ["DVS", "LAI"], "farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + + response = GrowthSimulationView.as_view()(request) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.data["code"], 202) + self.assertEqual(response.data["data"]["task_id"], "growth-task-123") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/growth/", + method="POST", + payload={ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "farm_uuid": str(self.farm.farm_uuid), + }, + ) + + @patch("yield_harvest.views.external_api_request") + def test_growth_top_level_route_queues_simulation_task(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=202, + data={ + "data": { + "task_id": "growth-task-123", + "status_url": "/api/crop-simulation/growth/growth-task-123/status/", + "plant_name": "گوجه‌فرنگی", + } + }, + ) + + response = self.client.post( + "/api/crop-simulation/growth/", + data=json.dumps( + { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "farm_uuid": str(self.farm.farm_uuid), + } + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.json()["data"]["task_id"], "growth-task-123") + + def test_growth_requires_farm_uuid_or_weather(self): + request = self.factory.post( + "/api/yield-harvest/crop-simulation/growth/", + {"plant_name": "گوجه‌فرنگی", "dynamic_parameters": ["DVS"]}, + format="json", + ) + + response = GrowthSimulationView.as_view()(request) + + self.assertEqual(response.status_code, 400) + + @patch("yield_harvest.views.external_api_request") + def test_growth_status_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "task_id": "growth-task-123", + "status": "SUCCESS", + "message": "done", + "progress": {}, + "result": { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "scenario_id": 1, + }, + "error": "", + } + }, + ) + + request = self.factory.get("/api/yield-harvest/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10") + response = GrowthSimulationStatusView.as_view()(request, task_id="growth-task-123") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["status"], "SUCCESS") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/growth/growth-task-123/status/", + method="GET", + query={"page": "1", "page_size": "10"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_growth_status_top_level_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "task_id": "growth-task-123", + "status": "SUCCESS", + "message": "done", + "progress": {}, + "result": { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "scenario_id": 1, + }, + "error": "", + } + }, + ) + + response = self.client.get("/api/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["status"], "SUCCESS") + + def test_legacy_plant_simulator_routes_are_unavailable(self): + legacy_paths = [ + "/api/yield-harvest/plant-simulator/config/", + "/api/yield-harvest/plant-simulator/environment/", + "/api/yield-harvest/plant-simulator/reset/", + "/api/yield-harvest/plant-simulator/start/", + "/api/yield-harvest/plant-simulator/state/", + "/api/yield-harvest/plant-simulator/stop/", + ] + + for path in legacy_paths: + response = self.client.get(path) + self.assertEqual(response.status_code, 404, path) + + @patch("yield_harvest.views.external_api_request") + def test_current_farm_chart_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "wheat", + "scenario_id": 1, + "categories": ["day1"], + "series": {"biomass": [1.2]}, + } + } + }, + ) + + request = self.factory.post( + "/api/yield-harvest/crop-simulation/current-farm-chart/", + {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = CurrentFarmChartView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["scenario_id"], 1) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/current-farm-chart/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_harvest_prediction_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "date": "2026-07-15", + "dateFormatted": "15 Jul 2026", + "daysUntil": 96, + "gddDetails": {"current": 800}, + } + } + }, + ) + + request = self.factory.post( + "/api/yield-harvest/crop-simulation/harvest-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = HarvestPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["daysUntil"], 96) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/harvest-prediction/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": ""}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "farm_uuid": str(self.farm.farm_uuid), + "predictedYieldTons": 8.4, + "scenarioId": 1, + } + } + }, + ) + + request = self.factory.post( + "/api/yield-harvest/crop-simulation/yield-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = YieldPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["predictedYieldTons"], 8.4) + + def test_crop_simulation_rejects_foreign_farm_uuid(self): + request = self.factory.post( + "/api/yield-harvest/crop-simulation/yield-prediction/", + {"farm_uuid": str(self.other_farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = YieldPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") diff --git a/yield_harvest/urls.py b/yield_harvest/urls.py index 955c324..99f8486 100644 --- a/yield_harvest/urls.py +++ b/yield_harvest/urls.py @@ -1,30 +1,6 @@ from django.urls import path -from .views import ( - ConfigView, - EnvironmentView, - ResetView, - StartView, - StateView, - StopView, - YieldHarvestSummaryView, -) - -ConfigView.__module__ = "plant_simulator.views" -EnvironmentView.__module__ = "plant_simulator.views" -ResetView.__module__ = "plant_simulator.views" -StartView.__module__ = "plant_simulator.views" -StateView.__module__ = "plant_simulator.views" -StopView.__module__ = "plant_simulator.views" - -plant_simulator_urlpatterns = [ - path("config/", ConfigView.as_view(), name="plant-simulator-config"), - path("state/", StateView.as_view(), name="plant-simulator-state"), - path("start/", StartView.as_view(), name="plant-simulator-start"), - path("stop/", StopView.as_view(), name="plant-simulator-stop"), - path("reset/", ResetView.as_view(), name="plant-simulator-reset"), - path("environment/", EnvironmentView.as_view(), name="plant-simulator-environment"), -] +from .views import YieldHarvestSummaryView urlpatterns = [ path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"), diff --git a/yield_harvest/views.py b/yield_harvest/views.py index b1af04f..281eaa6 100644 --- a/yield_harvest/views.py +++ b/yield_harvest/views.py @@ -1,6 +1,4 @@ -""" -Yield & Harvest Prediction and Plant Simulator API views. -""" +"""Yield & Harvest Prediction and Crop Simulation API views.""" from rest_framework import serializers, status from rest_framework.response import Response @@ -8,70 +6,20 @@ from rest_framework.views import APIView from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema -from config.swagger import status_response +from config.swagger import farm_uuid_query_param, status_response from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub -from .mock_data import CONFIG_SLIDERS_ONLY, START_RESPONSE_DATA, STATE_RESPONSE_DATA from .models import YieldHarvestPredictionLog -from .serializers import YieldHarvestSummarySerializer, success_response, success_with_data - - -class ConfigView(APIView): - @extend_schema( - tags=["Plant Simulator"], - responses={200: status_response("PlantSimulatorConfigResponse", data=serializers.JSONField())}, - ) - def get(self, request): - return Response(success_with_data(CONFIG_SLIDERS_ONLY), status=status.HTTP_200_OK) - - -class StateView(APIView): - @extend_schema( - tags=["Plant Simulator"], - responses={200: status_response("PlantSimulatorStateResponse", data=serializers.JSONField())}, - ) - def get(self, request): - return Response(success_with_data(STATE_RESPONSE_DATA), status=status.HTTP_200_OK) - - -class StartView(APIView): - @extend_schema( - tags=["Plant Simulator"], - request=OpenApiTypes.OBJECT, - responses={200: status_response("PlantSimulatorStartResponse", data=serializers.JSONField())}, - ) - def post(self, request): - return Response(success_with_data(START_RESPONSE_DATA), status=status.HTTP_200_OK) - - -class StopView(APIView): - @extend_schema( - tags=["Plant Simulator"], - request=OpenApiTypes.OBJECT, - responses={200: status_response("PlantSimulatorStopResponse")}, - ) - def post(self, request): - return Response(success_response(), status=status.HTTP_200_OK) - - -class ResetView(APIView): - @extend_schema( - tags=["Plant Simulator"], - request=OpenApiTypes.OBJECT, - responses={200: status_response("PlantSimulatorResetResponse")}, - ) - def post(self, request): - return Response(success_response(), status=status.HTTP_200_OK) - - -class EnvironmentView(APIView): - @extend_schema( - tags=["Plant Simulator"], - request=OpenApiTypes.OBJECT, - responses={200: status_response("PlantSimulatorEnvironmentResponse")}, - ) - def patch(self, request): - return Response(success_response(), status=status.HTTP_200_OK) +from .serializers import ( + CropSimulationRequestSerializer, + CurrentFarmChartSerializer, + GrowthSimulationQueuedDataSerializer, + GrowthSimulationRequestSerializer, + GrowthSimulationStatusDataSerializer, + HarvestPredictionSerializer, + YieldHarvestSummarySerializer, + YieldPredictionSerializer, +) class YieldHarvestSummaryView(APIView): @@ -98,13 +46,7 @@ class YieldHarvestSummaryView(APIView): @extend_schema( tags=["Yield & Harvest Prediction"], parameters=[ - OpenApiParameter( - name="farm_uuid", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - required=False, - description="UUID of the farm for yield and harvest prediction.", - default="11111111-1111-1111-1111-111111111111"), + farm_uuid_query_param(required=False, description="UUID of the farm for yield and harvest prediction."), ], responses={200: status_response("YieldHarvestSummaryResponse", data=YieldHarvestSummarySerializer())}, ) @@ -151,3 +93,220 @@ class YieldHarvestSummaryView(APIView): optimal_window_end=harvest_card.get("optimalWindowEnd") or None, chart_data=summary.get("yield_prediction_chart", {}), ) + + +class CropSimulationBaseView(APIView): + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + @staticmethod + def _extract_result(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + @staticmethod + def _error_response(adapter_response): + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + +class CurrentFarmChartView(CropSimulationBaseView): + ai_path = "/api/crop-simulation/current-farm-chart/" + + @extend_schema( + tags=["Crop Simulation"], + request=CropSimulationRequestSerializer, + responses={200: status_response("CurrentFarmChartResponse", data=CurrentFarmChartSerializer())}, + ) + def post(self, request): + serializer = CropSimulationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + ai_payload = {"farm_uuid": str(farm.farm_uuid), "plant_name": payload.get("plant_name", "")} + adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_result(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class HarvestPredictionView(CropSimulationBaseView): + ai_path = "/api/crop-simulation/harvest-prediction/" + + @extend_schema( + tags=["Crop Simulation"], + request=CropSimulationRequestSerializer, + responses={200: status_response("CropSimulationHarvestPredictionResponse", data=HarvestPredictionSerializer())}, + ) + def post(self, request): + serializer = CropSimulationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + ai_payload = {"farm_uuid": str(farm.farm_uuid), "plant_name": payload.get("plant_name", "")} + adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_result(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class YieldPredictionView(CropSimulationBaseView): + ai_path = "/api/crop-simulation/yield-prediction/" + + @extend_schema( + tags=["Crop Simulation"], + request=CropSimulationRequestSerializer, + responses={200: status_response("CropSimulationYieldPredictionResponse", data=YieldPredictionSerializer())}, + ) + def post(self, request): + serializer = CropSimulationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + ai_payload = {"farm_uuid": str(farm.farm_uuid), "plant_name": payload.get("plant_name", "")} + adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_result(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class GrowthSimulationView(APIView): + @extend_schema( + tags=["Crop Simulation"], + request=GrowthSimulationRequestSerializer, + responses={202: status_response("GrowthSimulationQueuedResponse", data=GrowthSimulationQueuedDataSerializer())}, + ) + def post(self, request): + serializer = GrowthSimulationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + payload = serializer.validated_data.copy() + if payload.get("farm_uuid") is not None: + payload["farm_uuid"] = str(payload["farm_uuid"]) + + adapter_response = external_api_request( + "ai", + "/api/crop-simulation/growth/", + method="POST", + payload=payload, + ) + + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + return Response( + {"code": 202, "msg": "تسک شبیه سازی رشد در صف قرار گرفت.", "data": CropSimulationBaseView._extract_result(adapter_response.data)}, + status=status.HTTP_202_ACCEPTED, + ) + + +class GrowthSimulationStatusView(APIView): + @extend_schema( + tags=["Crop Simulation"], + parameters=[ + OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="شماره صفحه."), + OpenApiParameter( + name="page_size", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + required=False, + description="اندازه صفحه بین 1 تا 50.", + ), + ], + responses={200: status_response("GrowthSimulationStatusResponse", data=GrowthSimulationStatusDataSerializer())}, + ) + def get(self, request, task_id): + query = {} + if request.query_params.get("page"): + query["page"] = request.query_params.get("page") + if request.query_params.get("page_size"): + query["page_size"] = request.query_params.get("page_size") + + adapter_response = external_api_request( + "ai", + f"/api/crop-simulation/growth/{task_id}/status/", + method="GET", + query=query or None, + ) + + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + return Response( + {"code": 200, "msg": "success", "data": CropSimulationBaseView._extract_result(adapter_response.data)}, + status=status.HTTP_200_OK, + )