This commit is contained in:
2026-04-27 00:40:59 +03:30
parent 2cd96ceec6
commit 64e67c282c
56 changed files with 3912 additions and 745 deletions
+78
View File
@@ -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/<task_id>/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/<task_id>/status/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `GET /api/farm-data/<farm_uuid>/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/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `PUT /api/plants/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `PATCH /api/plants/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
| `DELETE /api/plants/<pk>/` | متصل نیست | فقط 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/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
| `PUT /api/irrigation/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
| `PATCH /api/irrigation/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
| `DELETE /api/irrigation/<pk>/` | متصل نیست | 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` مورد
+328
View File
@@ -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 داشته باشد.
+84
View File
@@ -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/<task_id>/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/<task_id>/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/<farm_uuid>/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/<pk>/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `PUT /api/plants/<pk>/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `PATCH /api/plants/<pk>/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
| `DELETE /api/plants/<pk>/` | استفاده نمی‌شود | فقط در 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/<pk>/` | استفاده نمی‌شود | route detail پیدا نشد | `irrigation_recommendation/urls.py` |
| `PUT /api/irrigation/<pk>/` | استفاده نمی‌شود | route detail/update پیدا نشد | `irrigation_recommendation/urls.py` |
| `PATCH /api/irrigation/<pk>/` | استفاده نمی‌شود | route detail/update پیدا نشد | `irrigation_recommendation/urls.py` |
| `DELETE /api/irrigation/<pk>/` | استفاده نمی‌شود | 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/*` است
+6 -6
View File
@@ -40,8 +40,8 @@
| Card ID | عنوان | منبع اصلی | service | endpoint | | Card ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---| |---|---|---|---|---|
| `sensorRadarChart` | نمودار راداری سنسورها | `soil` | `get_sensor_radar_chart_data` | `GET /api/soil/sensor-radar-chart/` | | `sensorRadarChart` | نمودار راداری سنسورها | `sensor_7_in_1` | `get_sensor_radar_chart_data` | `GET /api/sensor-7-in-1/sensor-radar-chart/` |
| `sensorComparisonChart` | مقایسه با هفته قبل | `soil` | `get_sensor_comparison_chart_data` | `GET /api/soil/sensor-comparison-chart/` | | `sensorComparisonChart` | مقایسه با هفته قبل | `sensor_7_in_1` | `get_sensor_comparison_chart_data` | `GET /api/sensor-7-in-1/sensor-comparison-chart/` |
### `alertsWater` ### `alertsWater`
@@ -82,7 +82,7 @@
| Card ID | عنوان | منبع اصلی | service | endpoint | | 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 ها ## endpoint های summary جدید برای app ها
@@ -97,15 +97,15 @@
- `GET /api/water/summary/` - `GET /api/water/summary/`
- `soil` - `soil`
- `GET /api/soil/avg-moisture/` - `GET /api/soil/avg-moisture/`
- `GET /api/soil/sensor-radar-chart/` - `GET /api/sensor-7-in-1/sensor-radar-chart/`
- `GET /api/soil/sensor-comparison-chart/` - `GET /api/sensor-7-in-1/sensor-comparison-chart/`
- `GET /api/soil/anomalies/` - `GET /api/soil/anomalies/`
- `GET /api/soil/moisture-heatmap/` - `GET /api/soil/moisture-heatmap/`
- `GET /api/soil/summary/` - `GET /api/soil/summary/`
- `yield_harvest` - `yield_harvest`
- `GET /api/yield-harvest/summary/` - `GET /api/yield-harvest/summary/`
- `economic_overview` - `economic_overview`
- `GET /api/economic-overview/summary/` - `POST /api/economy/overview/`
## وضعیت فعلی کارت‌ها ## وضعیت فعلی کارت‌ها
+9 -9
View File
@@ -158,8 +158,8 @@ app جدید برای:
## Soil APIs ## Soil APIs
- `GET /api/soil/avg-moisture/` - `GET /api/soil/avg-moisture/`
- `GET /api/soil/sensor-radar-chart/` - `GET /api/sensor-7-in-1/sensor-radar-chart/`
- `GET /api/soil/sensor-comparison-chart/` - `GET /api/sensor-7-in-1/sensor-comparison-chart/`
- `GET /api/soil/anomalies/` - `GET /api/soil/anomalies/`
- `GET /api/soil/moisture-heatmap/` - `GET /api/soil/moisture-heatmap/`
- `GET /api/soil/summary/` - `GET /api/soil/summary/`
@@ -198,7 +198,7 @@ Plant Simulator routes که الان implementationشان زیر `yield_harvest`
## Economic Overview APIs ## 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 | | `farmWeatherCard` | `weatherAlerts` | `WATER` | `GET /api/water/card/` | Water Page |
| `farmAlertsTracker` | `weatherAlerts` | `farm_alerts` | `GET /api/farm-alerts/tracker/` | Alerts Page | | `farmAlertsTracker` | `weatherAlerts` | `farm_alerts` | `GET /api/farm-alerts/tracker/` | Alerts Page |
| `sensorValuesList` | `sensorMonitoring` | فعلا `dashboard` | فعلا فقط dashboard | Sensor Page در آینده | | `sensorValuesList` | `sensorMonitoring` | فعلا `dashboard` | فعلا فقط dashboard | Sensor Page در آینده |
| `sensorRadarChart` | `sensorCharts` | `soil` | `GET /api/soil/sensor-radar-chart/` | Soil Page | | `sensorRadarChart` | `sensorCharts` | `sensor_7_in_1` | `GET /api/sensor-7-in-1/sensor-radar-chart/` | Soil Page |
| `sensorComparisonChart` | `sensorCharts` | `soil` | `GET /api/soil/sensor-comparison-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 | | `anomalyDetectionCard` | `alertsWater` | `soil` | `GET /api/soil/anomalies/` | Soil Page |
| `farmAlertsTimeline` | `alertsWater` | `farm_alerts` | `GET /api/farm-alerts/timeline/` | Alerts Page | | `farmAlertsTimeline` | `alertsWater` | `farm_alerts` | `GET /api/farm-alerts/timeline/` | Alerts Page |
| `waterNeedPrediction` | `alertsWater` | `WATER` | `GET /api/water/need-prediction/` | Water 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 | | `soilMoistureHeatmap` | `soilHeatmap` | `soil` | `GET /api/soil/moisture-heatmap/` | Soil Page |
| `ndviHealthCard` | `ndviRecommendations` | `crop_health` | `GET /api/crop-health/summary/` | Crop Health Page | | `ndviHealthCard` | `ndviRecommendations` | `crop_health` | `GET /api/crop-health/summary/` | Crop Health Page |
| `recommendationsList` | `ndviRecommendations` | ترکیبی | dashboard aggregate | Recommendations / Alerts / Domain Pages | | `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 ها: endpoint ها:
- `GET /api/soil/avg-moisture/` - `GET /api/soil/avg-moisture/`
- `GET /api/soil/sensor-radar-chart/` - `GET /api/sensor-7-in-1/sensor-radar-chart/`
- `GET /api/soil/sensor-comparison-chart/` - `GET /api/sensor-7-in-1/sensor-comparison-chart/`
- `GET /api/soil/anomalies/` - `GET /api/soil/anomalies/`
- `GET /api/soil/moisture-heatmap/` - `GET /api/soil/moisture-heatmap/`
- یا یکجا `GET /api/soil/summary/` - یا یکجا `GET /api/soil/summary/`
@@ -418,7 +418,7 @@ endpoint:
endpoint: endpoint:
- `GET /api/economic-overview/summary/` - `POST /api/economy/overview/`
--- ---
+38
View File
@@ -7,6 +7,8 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(exist_ok=True)
def _get_csv_env(name, default=""): 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_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_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" 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,
},
},
}
+26 -1
View File
@@ -1,6 +1,10 @@
from rest_framework import serializers 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): class AuthTokenSerializer(serializers.Serializer):
@@ -28,3 +32,24 @@ def status_response(name, data=None):
if data is not None: if data is not None:
fields["data"] = data fields["data"] = data
return inline_serializer(name=name, fields=fields) 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,
)
+13 -8
View File
@@ -1,7 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from yield_harvest.urls import plant_simulator_urlpatterns
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
@@ -17,17 +16,23 @@ urlpatterns = [
path("api/farm-dashboard/", include("dashboard.urls")), path("api/farm-dashboard/", include("dashboard.urls")),
path("api/crop-health/", include("crop_health.urls")), path("api/crop-health/", include("crop_health.urls")),
path("api/soil/", include("soil.urls")), path("api/soil/", include("soil.urls")),
path("api/crop-zoning/", include("crop_zoning.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/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/farm-ai-assistant/", include("farm_ai_assistant.urls")),
path("api/notifications/", include("notifications.urls")), path("api/notifications/", include("notifications.urls")),
path("api/farm-alerts/", include("farm_alerts.urls")), path("api/farm-alerts/", include("farm_alerts.urls")),
path("api/sensor-external-api/", include("sensor_external_api.urls")), path("api/sensor-external-api/", include("sensor_external_api.urls")),
] ]
+8
View File
@@ -12,6 +12,14 @@ FARM_HEALTH_SCORE = {
NDVI_HEALTH_CARD = { NDVI_HEALTH_CARD = {
"ndviIndex": 0.78, "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": [ "healthData": [
{"title": "تنش نیتروژن", "value": "پایین", "color": "success", "icon": "tabler-leaf"}, {"title": "تنش نیتروژن", "value": "پایین", "color": "success", "icon": "tabler-leaf"},
{"title": "سلامت محصول", "value": "خوب", "color": "success", "icon": "tabler-plant"}, {"title": "سلامت محصول", "value": "خوب", "color": "success", "icon": "tabler-plant"},
+14 -5
View File
@@ -1,15 +1,24 @@
from rest_framework import serializers from rest_framework import serializers
class CropHealthRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="UUID مزرعه برای دریافت تحلیل سلامت گیاه.")
class HealthDataItemSerializer(serializers.Serializer): class HealthDataItemSerializer(serializers.Serializer):
title = serializers.CharField(required=False, allow_blank=True) title = serializers.CharField(required=False, allow_blank=True, help_text="عنوان آیتم سلامت.")
value = serializers.CharField(required=False, allow_blank=True) value = serializers.JSONField(required=False, help_text="مقدار آیتم سلامت؛ می‌تواند عدد، متن یا ساختار JSON باشد.")
color = serializers.CharField(required=False, allow_blank=True) color = serializers.CharField(required=False, allow_blank=True, help_text="رنگ نمایشی آیتم سلامت.")
icon = serializers.CharField(required=False, allow_blank=True) icon = serializers.CharField(required=False, allow_blank=True, help_text="آیکون نمایشی آیتم سلامت.")
class NdviHealthCardSerializer(serializers.Serializer): 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) healthData = HealthDataItemSerializer(many=True, required=False)
+110
View File
@@ -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/")
+2 -1
View File
@@ -1,7 +1,8 @@
from django.urls import path from django.urls import path
from .views import CropHealthSummaryView from .views import CropHealthSummaryView, NdviHealthView
urlpatterns = [ urlpatterns = [
path("ndvi-health/", NdviHealthView.as_view(), name="crop-health-ndvi-health"),
path("summary/", CropHealthSummaryView.as_view(), name="crop-health-summary"), path("summary/", CropHealthSummaryView.as_view(), name="crop-health-summary"),
] ]
+63 -12
View File
@@ -1,11 +1,14 @@
from rest_framework import status from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema
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 .serializers import CropHealthSummarySerializer 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 from .services import get_crop_health_summary_data
@@ -13,14 +16,7 @@ class CropHealthSummaryView(APIView):
@extend_schema( @extend_schema(
tags=["Crop Health"], tags=["Crop Health"],
parameters=[ parameters=[
OpenApiParameter( farm_uuid_query_param(required=False, description="UUID of the farm for crop health data."),
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",
),
], ],
responses={200: status_response("CropHealthSummaryResponse", data=CropHealthSummarySerializer())}, responses={200: status_response("CropHealthSummaryResponse", data=CropHealthSummarySerializer())},
) )
@@ -29,3 +25,58 @@ class CropHealthSummaryView(APIView):
{"status": "success", "data": get_crop_health_summary_data()}, {"status": "success", "data": get_crop_health_summary_data()},
status=status.HTTP_200_OK, 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)
+12 -6
View File
@@ -2,11 +2,11 @@ from rest_framework import serializers
class EconomicDataItemSerializer(serializers.Serializer): class EconomicDataItemSerializer(serializers.Serializer):
title = serializers.CharField() title = serializers.CharField(help_text="عنوان شاخص اقتصادی.")
value = serializers.CharField() value = serializers.CharField(help_text="مقدار شاخص اقتصادی.")
subtitle = serializers.CharField() subtitle = serializers.CharField(help_text="توضیح تکمیلی شاخص.")
avatarIcon = serializers.CharField() avatarIcon = serializers.CharField(help_text="آیکون نمایشی شاخص.")
avatarColor = serializers.CharField() avatarColor = serializers.CharField(help_text="رنگ نمایشی شاخص.")
class ChartSeriesSerializer(serializers.Serializer): class ChartSeriesSerializer(serializers.Serializer):
@@ -15,6 +15,12 @@ class ChartSeriesSerializer(serializers.Serializer):
class EconomicOverviewSerializer(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) economicData = EconomicDataItemSerializer(many=True)
chartSeries = ChartSeriesSerializer(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 مزرعه برای دریافت نمای اقتصادی.")
+81
View File
@@ -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/")
+2 -2
View File
@@ -1,7 +1,7 @@
from django.urls import path from django.urls import path
from .views import EconomicOverviewView from .views import EconomyOverviewView
urlpatterns = [ urlpatterns = [
path("summary/", EconomicOverviewView.as_view(), name="economic-overview-summary"), path("overview/", EconomyOverviewView.as_view(), name="economy-overview"),
] ]
+88 -6
View File
@@ -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.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from .mock_data import ECONOMIC_OVERVIEW from config.swagger import status_response
from .serializers import EconomicOverviewSerializer 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): class EconomyOverviewView(APIView):
def get(self, request): @staticmethod
serializer = EconomicOverviewSerializer(ECONOMIC_OVERVIEW) def _get_farm(request, farm_uuid):
return Response({"status": "success", "result": serializer.data}) 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)
+35
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging
import requests import requests
from django.conf import settings from django.conf import settings
@@ -9,6 +10,9 @@ from .mock_loader import MockLoader
from .services import ServiceRegistry from .services import ServiceRegistry
logger = logging.getLogger(__name__)
@dataclass @dataclass
class AdapterResponse: class AdapterResponse:
status_code: int status_code: int
@@ -26,6 +30,16 @@ class ExternalAPIAdapter:
request_method = method.upper() request_method = method.upper()
self._validate_method(request_method) self._validate_method(request_method)
service = self.service_registry.get(service_name) 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" use_mock = getattr(settings, "USE_EXTERNAL_API_MOCK", False) and service_name != "ai"
if use_mock: if use_mock:
@@ -91,6 +105,16 @@ class ExternalAPIAdapter:
else: else:
request_kwargs["json"] = request_payload 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( response = requests.request(
**request_kwargs, **request_kwargs,
) )
@@ -102,6 +126,17 @@ class ExternalAPIAdapter:
except ValueError: except ValueError:
response_data = response.text 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( return AdapterResponse(
status_code=response.status_code, status_code=response.status_code,
data=response_data, data=response_data,
+56
View File
@@ -1,6 +1,7 @@
"""Farm AI Assistant API views.""" """Farm AI Assistant API views."""
import json import json
import logging
from copy import deepcopy from copy import deepcopy
from django.db.models import Count from django.db.models import Count
@@ -29,6 +30,9 @@ from .serializers import (
) )
logger = logging.getLogger(__name__)
class FarmAccessMixin: class FarmAccessMixin:
@staticmethod @staticmethod
def _get_farm(request, farm_uuid): 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): if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
payload_source = adapter_data["data"] 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 = "" content = ""
sections = [] sections = []
if isinstance(payload_source, dict): if isinstance(payload_source, dict):
content = payload_source.get("content") or "" content = payload_source.get("content") or ""
sections = self._normalize_sections(payload_source.get("sections")) 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): if not sections and isinstance(adapter_data, dict):
sections = self._normalize_sections(adapter_data.get("sections")) 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): if not content and isinstance(adapter_data, dict):
content = adapter_data.get("body") or adapter_data.get("content") or "" 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 { return {
"message_id": "", "message_id": "",
@@ -463,6 +512,13 @@ class ChatView(ConversationAccessMixin, APIView):
method="POST", method="POST",
payload=adapter_payload, 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: if adapter_response.status_code >= 400:
return Response( return Response(
{ {
+6 -6
View File
@@ -49,9 +49,9 @@ class RecommendationsListSerializer(serializers.Serializer):
class CreateAlertSerializer(serializers.Serializer): class CreateAlertSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=False, allow_null=True) farm_uuid = serializers.UUIDField(required=False, allow_null=True, help_text="UUID مزرعه برای اتصال alert به مزرعه.")
title = serializers.CharField(max_length=255) title = serializers.CharField(max_length=255, help_text="عنوان هشدار.")
description = serializers.CharField(required=False, default="", allow_blank=True) description = serializers.CharField(required=False, default="", allow_blank=True, help_text="توضیح هشدار.")
color = serializers.ChoiceField(choices=["info", "warning", "error", "success"], default="info") color = serializers.ChoiceField(choices=["info", "warning", "error", "success"], default="info", help_text="سطح یا رنگ هشدار.")
avatar_icon = serializers.CharField(required=False, default="", allow_blank=True) avatar_icon = serializers.CharField(required=False, default="", allow_blank=True, help_text="آیکون هشدار.")
avatar_color = serializers.CharField(required=False, default="", allow_blank=True) avatar_color = serializers.CharField(required=False, default="", allow_blank=True, help_text="رنگ آواتار هشدار.")
+1 -10
View File
@@ -1,17 +1,8 @@
from django.urls import path from django.urls import path
from .views import ( from .views import AlertTimelineView, AlertTrackerView
AlertTrackerView,
AlertTimelineView,
AnomalyDetectionView,
RecommendationsListView,
CreateAlertView,
)
urlpatterns = [ urlpatterns = [
path("tracker/", AlertTrackerView.as_view(), name="farm-alerts-tracker"), path("tracker/", AlertTrackerView.as_view(), name="farm-alerts-tracker"),
path("timeline/", AlertTimelineView.as_view(), name="farm-alerts-timeline"), 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"),
] ]
+60 -65
View File
@@ -2,79 +2,74 @@ from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView 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 ( from .serializers import AlertTimelineSerializer, AlertTrackerSerializer
ANOMALY_DETECTION_CARD,
ARM_ALERTS_TRACKER,
FARM_ALERTS_TIMELINE, class FarmAlertsBaseView(APIView):
RECOMMENDATIONS_LIST, @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)}
) )
from .serializers import ( return Response(
AlertTimelineSerializer, {"code": adapter_response.status_code, "msg": "error", "data": response_data},
AlertTrackerSerializer, status=adapter_response.status_code,
AnomalyDetectionSerializer,
CreateAlertSerializer,
RecommendationsListSerializer,
) )
from .services import AlertService
class AlertTrackerView(APIView): class AlertTrackerView(FarmAlertsBaseView):
def get(self, request):
serializer = AlertTrackerSerializer(ARM_ALERTS_TRACKER)
return Response({"status": "success", "result": serializer.data})
class AlertTimelineView(APIView):
def get(self, request):
serializer = AlertTimelineSerializer(FARM_ALERTS_TIMELINE)
return Response({"status": "success", "result": serializer.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): def post(self, request):
serializer = CreateAlertSerializer(data=request.data) adapter_response = external_api_request(
if not serializer.is_valid(): "ai",
return Response( "/api/farm-alerts/tracker/",
{"status": "error", "errors": serializer.errors}, method="POST",
status=status.HTTP_400_BAD_REQUEST, payload=request.data,
) )
if adapter_response.status_code >= 400:
return self._error_response(adapter_response)
data = serializer.validated_data payload = self._extract_result(adapter_response.data)
farm = None serializer = AlertTrackerSerializer(data=payload)
farm_uuid = data.get("farm_uuid") serializer.is_valid(raise_exception=True)
if farm_uuid: return Response({"code": 200, "msg": "success", "data": serializer.validated_data}, status=status.HTTP_200_OK)
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,
)
return Response( class AlertTimelineView(FarmAlertsBaseView):
{"status": "success", "result": {"uuid": str(alert.uuid), "title": alert.title}}, def post(self, request):
status=status.HTTP_201_CREATED, 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,
) )
+57 -4
View File
@@ -180,7 +180,27 @@ Content-Type: application/json
} }
} }
], ],
"area_geojson": { "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", "type": "Feature",
"properties": {}, "properties": {},
"geometry": { "geometry": {
@@ -195,7 +215,6 @@ Content-Type: application/json
] ]
} }
} }
}
``` ```
### فیلدهای ورودی ### فیلدهای ورودی
@@ -207,7 +226,11 @@ Content-Type: application/json
| `farm_type_uuid` | uuid | بله | UUID نوع مزرعه | | `farm_type_uuid` | uuid | بله | UUID نوع مزرعه |
| `product_uuids` | array[uuid] | بله | لیست UUID محصولات؛ خالی بودن مجاز نیست | | `product_uuids` | array[uuid] | بله | لیست UUID محصولات؛ خالی بودن مجاز نیست |
| `sensors` | array | خیر | لیست سنسورهای مزرعه | | `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` ### فیلدهای هر سنسور در `sensors`
@@ -224,6 +247,8 @@ Content-Type: application/json
### اعتبارسنجی‌ها ### اعتبارسنجی‌ها
- `farm_uuid` اگر از سمت کلاینت ارسال شود نادیده گرفته می‌شود. - `farm_uuid` اگر از سمت کلاینت ارسال شود نادیده گرفته می‌شود.
- اگر `farm_boundary` به فرم `corners` ارسال شود، به Polygon تبدیل می‌شود.
- `sensor_payload` باید object باشد، وگرنه خطای validation برمی‌گردد.
- `farm_type_uuid` باید معتبر باشد، وگرنه: - `farm_type_uuid` باید معتبر باشد، وگرنه:
```json ```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` باید object معتبر باشد.
- اگر `area_geojson.type == "Feature"` باشد، مقدار `geometry` بررسی می‌شود. - اگر `area_geojson.type == "Feature"` باشد، مقدار `geometry` بررسی می‌شود.
- `geometry.type` فقط باید `Polygon` باشد. - `geometry.type` فقط باید `Polygon` باشد.
@@ -551,7 +581,19 @@ Content-Type: application/json
"type": "solar" "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` ارسال شود، نوع مزرعه به‌روزرسانی می‌شود. - اگر `farm_type_uuid` ارسال شود، نوع مزرعه به‌روزرسانی می‌شود.
- اگر `product_uuids` ارسال شود، همه محصولات مزرعه با لیست جدید جایگزین می‌شوند. - اگر `product_uuids` ارسال شود، همه محصولات مزرعه با لیست جدید جایگزین می‌شوند.
- اگر `sensors` ارسال شود، همه سنسورهای قبلی حذف و سپس سنسورهای جدید از نو ساخته می‌شوند. - اگر `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، اگر `farm_type_uuid` ارسال نشود، از `farm_type` فعلی استفاده می‌شود.
- در update، اگر `product_uuids` ارسال نشود، محصولات فعلی حفظ می‌شوند. - در update، اگر `product_uuids` ارسال نشود، محصولات فعلی حفظ می‌شوند.
- در update، اگر `sensors` ارسال نشود، سنسورهای فعلی حفظ می‌شوند. - در update، اگر `sensors` ارسال نشود، سنسورهای فعلی حفظ می‌شوند.
- در update نیز `sensor_payload` باید object باشد.
### Response 200 ### Response 200
@@ -604,6 +648,15 @@ Content-Type: application/json
} }
``` ```
### Response 502
```json
{
"code": 502,
"msg": "Farm data API returned status 400: ..."
}
```
--- ---
## 7) حذف مزرعه ## 7) حذف مزرعه
+32 -13
View File
@@ -5,6 +5,7 @@ from access_control.catalog import GOLD_PLAN_CODE
from access_control.services import get_effective_subscription_plan from access_control.services import get_effective_subscription_plan
from .models import FarmHub, FarmSensor, FarmType, Product from .models import FarmHub, FarmSensor, FarmType, Product
from .services import normalize_farm_boundary_input
from sensor_catalog.models import SensorCatalog from sensor_catalog.models import SensorCatalog
@@ -116,6 +117,7 @@ class FarmSensorWriteSerializer(serializers.ModelSerializer):
class FarmHubCreateSerializer(serializers.ModelSerializer): class FarmHubCreateSerializer(serializers.ModelSerializer):
area_geojson = serializers.JSONField(write_only=True, required=False) 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) farm_type_uuid = serializers.UUIDField(write_only=True)
subscription_plan_uuid = serializers.UUIDField(write_only=True, required=False, allow_null=True) subscription_plan_uuid = serializers.UUIDField(write_only=True, required=False, allow_null=True)
product_uuids = serializers.ListField( product_uuids = serializers.ListField(
@@ -124,6 +126,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
allow_empty=False, allow_empty=False,
) )
sensors = FarmSensorWriteSerializer(many=True, required=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: class Meta:
model = FarmHub model = FarmHub
@@ -135,6 +140,10 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
"product_uuids", "product_uuids",
"sensors", "sensors",
"area_geojson", "area_geojson",
"farm_boundary",
"sensor_key",
"sensor_payload",
"irrigation_method_id",
] ]
def to_internal_value(self, data): def to_internal_value(self, data):
@@ -144,23 +153,27 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
return super().to_internal_value(data) return super().to_internal_value(data)
def validate_area_geojson(self, value): 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): if not isinstance(value, dict):
raise serializers.ValidationError("`area_geojson` must be a GeoJSON object.") raise serializers.ValidationError("`sensor_payload` must be an 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.")
return value return value
def validate(self, attrs): 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") farm_type_uuid = attrs.get("farm_type_uuid")
subscription_plan_uuid = attrs.get("subscription_plan_uuid", serializers.empty) subscription_plan_uuid = attrs.get("subscription_plan_uuid", serializers.empty)
product_uuids = attrs.get("product_uuids") product_uuids = attrs.get("product_uuids")
@@ -208,6 +221,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
validated_data.pop("area_geojson", None) 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", []) sensors_data = validated_data.pop("sensors", [])
products = validated_data.pop("products", []) products = validated_data.pop("products", [])
validated_data["farm_type"] = validated_data.pop("farm_type") validated_data["farm_type"] = validated_data.pop("farm_type")
@@ -225,6 +241,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
validated_data.pop("area_geojson", None) 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) sensors_data = validated_data.pop("sensors", None)
products = validated_data.pop("products", None) products = validated_data.pop("products", None)
farm_type = validated_data.pop("farm_type", None) farm_type = validated_data.pop("farm_type", None)
+141
View File
@@ -1,3 +1,4 @@
from django.conf import settings
from django.db import transaction from django.db import transaction
from crop_zoning.services import ( from crop_zoning.services import (
@@ -6,6 +7,12 @@ from crop_zoning.services import (
get_initial_zones_payload, get_initial_zones_payload,
normalize_area_feature, 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): 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) 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): def create_farm_with_zoning(serializer, owner):
area_feature = serializer.validated_data.pop("area_geojson", None) or get_default_area_feature() 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(): with transaction.atomic():
farm = serializer.save(owner=owner) farm = serializer.save(owner=owner)
crop_area, zoning_payload = dispatch_farm_zoning(area_feature, farm) crop_area, zoning_payload = dispatch_farm_zoning(area_feature, farm)
farm.current_crop_area = crop_area farm.current_crop_area = crop_area
farm.save(update_fields=["current_crop_area", "updated_at"]) 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 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/")
+117 -5
View File
@@ -1,15 +1,17 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory, force_authenticate 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.models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
from access_control.services import build_farm_access_profile from access_control.services import build_farm_access_profile
from access_control.views import FarmAccessProfileView from access_control.views import FarmAccessProfileView
from crop_zoning.models import CropArea from crop_zoning.models import CropArea
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType, Product from farm_hub.models import FarmHub, FarmType, Product
from farm_hub.serializers import FarmHubSerializer from farm_hub.serializers import FarmHubSerializer
from farm_hub.seeds import seed_admin_farm 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 from sensor_catalog.models import SensorCatalog
@@ -33,6 +35,7 @@ AREA_GEOJSON = {
@override_settings( @override_settings(
USE_EXTERNAL_API_MOCK=True, USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000, CROP_ZONE_CHUNK_AREA_SQM=200000,
FARM_DATA_API_KEY="farm-data-key",
) )
class FarmListCreateViewTests(TestCase): class FarmListCreateViewTests(TestCase):
def setUp(self): def setUp(self):
@@ -52,7 +55,9 @@ class FarmListCreateViewTests(TestCase):
defaults={"supported_power_sources": ["solar", "direct_power"]}, 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" physical_device_uuid = "33333333-3333-3333-3333-333333333333"
request = self.factory.post( request = self.factory.post(
"/api/farm-hub/", "/api/farm-hub/",
@@ -94,8 +99,26 @@ class FarmListCreateViewTests(TestCase):
CropArea.objects.get().zone_count, CropArea.objects.get().zone_count,
) )
self.assertEqual(CropArea.objects.count(), 1) 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( request = self.factory.post(
"/api/farm-hub/", "/api/farm-hub/",
{ {
@@ -114,7 +137,9 @@ class FarmListCreateViewTests(TestCase):
self.assertNotEqual(response.data["data"]["farm_uuid"], "11111111-1111-1111-1111-111111111111") self.assertNotEqual(response.data["data"]["farm_uuid"], "11111111-1111-1111-1111-111111111111")
self.assertIsNotNone(response.data["data"]["area_uuid"]) 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( request = self.factory.post(
"/api/farm-hub/", "/api/farm-hub/",
{ {
@@ -137,7 +162,9 @@ class FarmListCreateViewTests(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn("sensor_catalog_uuid", response.data["sensors"][0]) 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( request = self.factory.post(
"/api/farm-hub/", "/api/farm-hub/",
{ {
@@ -154,6 +181,91 @@ class FarmListCreateViewTests(TestCase):
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["data"]["subscription_plan"]["code"], "gold") 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( @override_settings(
USE_EXTERNAL_API_MOCK=True, USE_EXTERNAL_API_MOCK=True,
+24 -1
View File
@@ -1,3 +1,4 @@
from django.db import transaction
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@@ -14,7 +15,7 @@ from .serializers import (
FarmTypeSerializer, FarmTypeSerializer,
ProductSerializer, 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): class FarmHubBaseView(APIView):
@@ -64,6 +65,8 @@ class FarmListCreateView(FarmHubBaseView):
farm, zoning_payload = create_farm_with_zoning(serializer, owner=request.user) farm, zoning_payload = create_farm_with_zoning(serializer, owner=request.user)
except ValueError as exc: except ValueError as exc:
raise serializers.ValidationError({"area_geojson": [str(exc)]}) from 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: except ImproperlyConfigured as exc:
return Response({"code": 500, "msg": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({"code": 500, "msg": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
data = FarmHubSerializer(farm).data 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) return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
serializer = FarmHubCreateSerializer(farm, data=request.data, partial=True) serializer = FarmHubCreateSerializer(farm, data=request.data, partial=True)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
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() 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() farm.refresh_from_db()
data = FarmHubSerializer(farm).data data = FarmHubSerializer(farm).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
+15 -29
View File
@@ -8,38 +8,24 @@ class FertilizationFarmDataSerializer(serializers.Serializer):
class FertilizationRecommendRequestSerializer(serializers.Serializer): class FertilizationRecommendRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True) farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه کودی.")
crop_id = serializers.CharField(required=False, allow_blank=True) plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.")
growth_stage = serializers.CharField(required=False, allow_blank=True) growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.")
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)
class FertilizationPlanSerializer(serializers.Serializer): class FertilizationSectionSerializer(serializers.Serializer):
npkRatio = serializers.CharField(required=False, allow_blank=True) type = serializers.ChoiceField(choices=["text", "list", "recommendation", "warning"])
amountPerHectare = serializers.CharField(required=False, allow_blank=True) 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) applicationMethod = serializers.CharField(required=False, allow_blank=True)
applicationInterval = serializers.CharField(required=False, allow_blank=True) timing = serializers.CharField(required=False, allow_blank=True)
reasoning = 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): class FertilizationRecommendResponseDataSerializer(serializers.Serializer):
plan = FertilizationPlanSerializer(required=False) sections = FertilizationSectionSerializer(many=True, read_only=True)
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)
+1 -6
View File
@@ -1,13 +1,8 @@
from django.urls import path from django.urls import path
from .views import ConfigView, RecommendTaskStatusView, RecommendView from .views import ConfigView, RecommendView
urlpatterns = [ urlpatterns = [
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"), path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"), path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"),
path(
"recommend/status/<str:task_id>/",
RecommendTaskStatusView.as_view(),
name="fertilization-recommendation-task-status",
),
] ]
+96 -35
View File
@@ -2,11 +2,12 @@
Fertilization Recommendation API views. Fertilization Recommendation API views.
""" """
import logging
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response from config.swagger import status_response
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
@@ -16,11 +17,12 @@ from .models import FertilizationRecommendationRequest
from .serializers import ( from .serializers import (
FertilizationRecommendRequestSerializer, FertilizationRecommendRequestSerializer,
FertilizationRecommendResponseDataSerializer, FertilizationRecommendResponseDataSerializer,
FertilizationTaskStatusDataSerializer,
FertilizationTaskSubmitDataSerializer,
) )
logger = logging.getLogger(__name__)
class FarmAccessMixin: class FarmAccessMixin:
@staticmethod @staticmethod
def _get_farm(request, farm_uuid): def _get_farm(request, farm_uuid):
@@ -35,9 +37,6 @@ class FarmAccessMixin:
class ConfigView(FarmAccessMixin, APIView): class ConfigView(FarmAccessMixin, APIView):
@extend_schema( @extend_schema(
tags=["Fertilization Recommendation"], 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())}, responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())},
) )
def get(self, request): def get(self, request):
@@ -48,6 +47,62 @@ class ConfigView(FarmAccessMixin, APIView):
class RecommendView(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( @extend_schema(
tags=["Fertilization Recommendation"], tags=["Fertilization Recommendation"],
request=FertilizationRecommendRequestSerializer, request=FertilizationRecommendRequestSerializer,
@@ -59,47 +114,53 @@ class RecommendView(FarmAccessMixin, APIView):
payload = serializer.validated_data.copy() payload = serializer.validated_data.copy()
farm = self._get_farm(request, payload.get("farm_uuid")) farm = self._get_farm(request, payload.get("farm_uuid"))
payload["farm_uuid"] = str(farm.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( adapter_response = external_api_request(
"ai", "ai",
"/fertilization/recommend", "/api/fertilization/recommend/",
method="POST", method="POST",
payload=payload, payload=payload,
) )
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} 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( FertilizationRecommendationRequest.objects.create(
farm=farm, farm=farm,
crop_id=payload.get("crop_id", ""), crop_id=payload.get("plant_name", ""),
growth_stage=payload.get("growth_stage", ""), growth_stage=payload.get("growth_stage", ""),
task_id=str(response_data.get("data", {}).get("task_id") or response_data.get("task_id") or ""), task_id="",
status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), status="success" if adapter_response.status_code < 400 else "error",
request_payload=payload, request_payload=payload,
response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, 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(
class RecommendTaskStatusView(FarmAccessMixin, APIView): {
@extend_schema( "code": 200,
tags=["Fertilization Recommendation"], "msg": "success",
parameters=[ "data": {
OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH), "sections": public_sections,
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())}, status=status.HTTP_200_OK,
) )
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)},
)
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)
+21 -46
View File
@@ -8,56 +8,31 @@ class IrrigationFarmDataSerializer(serializers.Serializer):
class IrrigationRecommendRequestSerializer(serializers.Serializer): class IrrigationRecommendRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True) farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه آبیاری.")
crop_id = serializers.CharField(required=False, allow_blank=True) plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.")
farm_data = IrrigationFarmDataSerializer(required=False) growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.")
soilType = serializers.CharField(required=False, allow_blank=True) irrigation_method_name = serializers.CharField(required=False, allow_blank=True, help_text="نام روش آبیاری انتخابی.")
waterQuality = serializers.CharField(required=False, allow_blank=True)
climateZone = serializers.CharField(required=False, allow_blank=True)
class IrrigationPlanSerializer(serializers.Serializer): class WaterStressRequestSerializer(serializers.Serializer):
frequencyPerWeek = serializers.CharField(required=False, allow_blank=True) farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای محاسبه تنش آبی.")
durationMinutes = serializers.CharField(required=False, allow_blank=True) sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور برای فیلتر اختیاری.")
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 IrrigationWaterBalanceDaySerializer(serializers.Serializer): class IrrigationMethodSerializer(serializers.Serializer):
forecast_date = serializers.CharField(required=False, allow_blank=True) id = serializers.IntegerField(required=False)
et0_mm = serializers.FloatField(required=False) name = serializers.CharField(required=False, allow_blank=True)
etc_mm = serializers.FloatField(required=False) category = serializers.CharField(required=False, allow_blank=True)
effective_rainfall_mm = serializers.FloatField(required=False) description = serializers.CharField(required=False, allow_blank=True)
gross_irrigation_mm = serializers.FloatField(required=False) water_efficiency_percent = serializers.FloatField(required=False)
irrigation_timing = serializers.CharField(required=False, allow_blank=True) 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)
class IrrigationCropProfileSerializer(serializers.Serializer): soil_type = serializers.CharField(required=False, allow_blank=True)
kc_initial = serializers.FloatField(required=False) climate_suitability = serializers.CharField(required=False, allow_blank=True)
kc_mid = serializers.FloatField(required=False) created_at = serializers.DateTimeField(required=False)
kc_end = serializers.FloatField(required=False) updated_at = serializers.DateTimeField(required=False)
class IrrigationWaterBalanceSerializer(serializers.Serializer):
daily = IrrigationWaterBalanceDaySerializer(many=True, required=False)
crop_profile = IrrigationCropProfileSerializer(required=False)
active_kc = serializers.FloatField(required=False)
class IrrigationRecommendResponseDataSerializer(serializers.Serializer): class IrrigationRecommendResponseDataSerializer(serializers.Serializer):
plan = IrrigationPlanSerializer(required=False) sections = serializers.ListField(child=serializers.DictField(), read_only=True)
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)
+141
View File
@@ -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"},
)
+3 -6
View File
@@ -1,13 +1,10 @@
from django.urls import path from django.urls import path
from .views import ConfigView, RecommendTaskStatusView, RecommendView from .views import ConfigView, IrrigationMethodListView, RecommendView, WaterStressView
urlpatterns = [ urlpatterns = [
path("", IrrigationMethodListView.as_view(), name="irrigation-method-list"),
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"), path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"), path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"),
path( path("water-stress/", WaterStressView.as_view(), name="irrigation-water-stress"),
"recommend/status/<str:task_id>/",
RecommendTaskStatusView.as_view(),
name="irrigation-recommendation-task-status",
),
] ]
+209 -52
View File
@@ -2,25 +2,31 @@
Irrigation Recommendation API views. Irrigation Recommendation API views.
""" """
import logging
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response from config.swagger import status_response
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub from farm_hub.models import FarmHub
from water.serializers import WaterStressIndexSerializer
from water.views import WaterStressIndexView
from .mock_data import CONFIG_RESPONSE_DATA from .mock_data import CONFIG_RESPONSE_DATA
from .models import IrrigationRecommendationRequest from .models import IrrigationRecommendationRequest
from .serializers import ( from .serializers import (
IrrigationMethodSerializer,
IrrigationRecommendRequestSerializer, IrrigationRecommendRequestSerializer,
IrrigationRecommendResponseDataSerializer, IrrigationRecommendResponseDataSerializer,
IrrigationTaskStatusDataSerializer, WaterStressRequestSerializer,
IrrigationTaskSubmitDataSerializer,
) )
logger = logging.getLogger(__name__)
class FarmAccessMixin: class FarmAccessMixin:
@staticmethod @staticmethod
def _get_farm(request, farm_uuid): def _get_farm(request, farm_uuid):
@@ -35,9 +41,6 @@ class FarmAccessMixin:
class ConfigView(FarmAccessMixin, APIView): class ConfigView(FarmAccessMixin, APIView):
@extend_schema( @extend_schema(
tags=["Irrigation Recommendation"], 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())}, responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())},
) )
def get(self, request): def get(self, request):
@@ -47,7 +50,133 @@ class ConfigView(FarmAccessMixin, APIView):
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK) 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): 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( @extend_schema(
tags=["Irrigation Recommendation"], tags=["Irrigation Recommendation"],
request=IrrigationRecommendRequestSerializer, request=IrrigationRecommendRequestSerializer,
@@ -62,75 +191,103 @@ class RecommendView(FarmAccessMixin, APIView):
adapter_response = external_api_request( adapter_response = external_api_request(
"ai", "ai",
"/irrigation/recommend", "/api/irrigation/recommend/",
method="POST", method="POST",
payload=payload, payload=payload,
) )
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} 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( IrrigationRecommendationRequest.objects.create(
farm=farm, farm=farm,
crop_id=payload.get("crop_id", ""), crop_id=payload.get("plant_name", ""),
task_id=str(response_data.get("data", {}).get("task_id") or response_data.get("task_id") or ""), task_id="",
status=str(response_data.get("data", {}).get("status") or response_data.get("status") or ""), status="success" if adapter_response.status_code < 400 else "error",
request_payload=payload, request_payload=payload,
response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, 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( @extend_schema(
tags=["Irrigation Recommendation"], tags=["Irrigation Recommendation"],
request=IrrigationRecommendRequestSerializer, request=WaterStressRequestSerializer,
responses={200: status_response("IrrigationRecommendTaskCreateResponse", data=IrrigationTaskSubmitDataSerializer())}, responses={200: status_response("WaterStressResponse", data=WaterStressIndexSerializer())},
) )
def post(self, request): def post(self, request):
serializer = IrrigationRecommendRequestSerializer(data=request.data) serializer = WaterStressRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
payload = serializer.validated_data.copy() 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( adapter_response = external_api_request(
"ai", "ai",
"/irrigation/recommend", "/api/irrigation/water-stress/",
method="POST", method="POST",
payload=payload, payload=query,
) )
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} if adapter_response.status_code >= 400:
IrrigationRecommendationRequest.objects.create( response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)}
farm=farm, return Response(
crop_id=payload.get("crop_id", ""), {"code": adapter_response.status_code, "msg": "error", "data": response_data},
task_id=str(response_data.get("data", {}).get("task_id") or response_data.get("task_id") or ""), status=adapter_response.status_code,
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)
stress_payload = WaterStressIndexView.extract_stress_payload(adapter_response.data, farm.farm_uuid)
class RecommendTaskStatusView(FarmAccessMixin, APIView): return Response(
@extend_schema( {"code": 200, "msg": "success", "data": stress_payload},
tags=["Irrigation Recommendation"], status=status.HTTP_200_OK,
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)},
)
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)
+9
View File
@@ -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"),
]
+64 -11
View File
@@ -1,13 +1,64 @@
from rest_framework import serializers from rest_framework import serializers
class RiskDetailsSerializer(serializers.Serializer): class PestDetectionAnalyzeRequestSerializer(serializers.Serializer):
risk_level = serializers.CharField(required=False, allow_blank=True) farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای تحلیل آفت/بیماری.")
risk_percentage = serializers.IntegerField(required=False) sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور مرتبط در صورت وجود.")
detected_diseases = serializers.ListField(child=serializers.DictField(), required=False) plant_name = serializers.CharField(required=False, allow_blank=True, default="", help_text="نام گیاه یا محصول.")
detected_pests = serializers.ListField(child=serializers.DictField(), required=False) query = serializers.CharField(required=False, allow_blank=True, default="", help_text="پرسش یا توضیح متنی کاربر.")
last_assessed_at = serializers.CharField(required=False, allow_blank=True) image_urls = serializers.ListField(
recommendation = serializers.CharField(required=False, allow_blank=True) 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): class RiskCardSerializer(serializers.Serializer):
@@ -19,9 +70,11 @@ class RiskCardSerializer(serializers.Serializer):
avatarIcon = serializers.CharField(required=False, allow_blank=True) avatarIcon = serializers.CharField(required=False, allow_blank=True)
chipText = serializers.CharField(required=False, allow_blank=True) chipText = serializers.CharField(required=False, allow_blank=True)
chipColor = 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): class PestDetectionRiskSummaryResponseSerializer(serializers.Serializer):
disease_risk = RiskCardSerializer(required=False) farm_uuid = serializers.UUIDField(required=False, allow_null=True)
pest_risk = RiskCardSerializer(required=False) diseaseRisk = RiskCardSerializer(required=False)
pestRisk = RiskCardSerializer(required=False)
drivers = serializers.DictField(required=False)
+229
View File
@@ -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)
+1 -8
View File
@@ -1,8 +1 @@
from django.urls import path urlpatterns = []
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"),
]
+222 -69
View File
@@ -1,101 +1,254 @@
""" """
Pest Detection API views. Pest detection API views.
No database. All responses are static mock data.
Response format: {"status": "success", "data": <payload>}. HTTP 200 only.
No processing, validation, or use of input parameters in responses.
""" """
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.response import Response
from rest_framework.views import APIView 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 status_response
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
from .mock_data import ANALYZE_RESPONSE_DATA from farm_hub.models import FarmHub
from .serializers import RiskSummaryDataSerializer from .serializers import (
PestDetectionAnalyzeRequestSerializer,
PestDetectionAnalyzeResponseSerializer,
PestDetectionRiskRequestSerializer,
PestDetectionRiskResponseSerializer,
PestDetectionRiskSummaryResponseSerializer,
)
class AnalyzeView(APIView): class PestDetectionFarmMixin:
""" @staticmethod
POST endpoint for pest detection analysis. 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: @staticmethod
Returns a static pest detection result (pest name, confidence, def _parse_json_array(value):
description, treatment). Used when the user uploads a plant image if not isinstance(value, str):
and requests analysis. No processing is performed on the request. return None
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, list) else None
Input parameters: def _collect_uploaded_images(self, request):
- body (optional): JSON or form-data; may contain image or file. uploaded_images = []
Data type: object. Location: body. Not read or validated; not used in response. 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: def _prepare_image_urls(self, request):
- status: string, always "success". image_urls = request.data.get("image_urls", [])
- data: object with keys pest (string), confidence (number), if isinstance(image_urls, str):
description (string), treatment (string). 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
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(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
class AnalyzeView(PestDetectionFarmMixin, APIView):
@extend_schema( @extend_schema(
tags=["Pest Detection"], tags=["Pest Detection"],
request=OpenApiTypes.OBJECT, request=PestDetectionAnalyzeRequestSerializer,
responses={200: status_response("PestDetectionAnalyzeResponse", data=serializers.JSONField())}, responses={200: status_response("PestDetectionAnalyzeResponse", data=PestDetectionAnalyzeResponseSerializer())},
) )
def post(self, request): 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( return Response(
{"status": "success", "data": ANALYZE_RESPONSE_DATA}, {
status=status.HTTP_200_OK, "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)
class RiskSummaryView(APIView): ai_payload = self._attach_uploaded_files(ai_payload, uploaded_images)
"""
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.
"""
@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())},
)
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {}
adapter_response = external_api_request( adapter_response = external_api_request(
"ai", "ai",
"/pest-detection/risk-summary", "/api/pest-disease/detect/",
method="GET", method="POST",
query=query, payload=ai_payload,
) )
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} if adapter_response.status_code >= 400:
result = response_data.get("result", response_data.get("data", response_data)) return self._error_response(adapter_response)
return 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, status=status.HTTP_200_OK,
) )
+21 -1
View File
@@ -9,7 +9,7 @@ from sensor_external_api.models import SensorExternalRequestLog
from dashboard.services import get_farm_dashboard_cards from dashboard.services import get_farm_dashboard_cards
from .services import get_sensor_7_in_1_summary_data from .services import get_sensor_7_in_1_summary_data
from .views import Sensor7In1SummaryView from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
class Sensor7In1BaseTestCase(TestCase): class Sensor7In1BaseTestCase(TestCase):
@@ -118,3 +118,23 @@ class Sensor7In1ViewTests(Sensor7In1BaseTestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "This field is required.") 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)
+7 -2
View File
@@ -1,9 +1,14 @@
from django.urls import path from django.urls import path
from .views import Sensor7In1SummaryView from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
urlpatterns = [ urlpatterns = [
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"), 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",
),
] ]
+40 -11
View File
@@ -2,14 +2,18 @@ from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, 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 farm_hub.models import FarmHub
from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer
from .serializers import Sensor7In1SummarySerializer 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): class Sensor7In1SummaryView(APIView):
@@ -29,13 +33,7 @@ class Sensor7In1SummaryView(APIView):
@extend_schema( @extend_schema(
tags=["Sensor 7 in 1"], tags=["Sensor 7 in 1"],
parameters=[ parameters=[
OpenApiParameter( farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.")
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=True,
default="11111111-1111-1111-1111-111111111111",
)
], ],
responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())}, responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())},
) )
@@ -46,3 +44,34 @@ class Sensor7In1SummaryView(APIView):
status=status.HTTP_200_OK, 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,
)
+11 -19
View File
@@ -1,7 +1,8 @@
import requests
from django.conf import settings from django.conf import settings
from django.db import OperationalError, ProgrammingError, transaction 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 farm_hub.models import FarmSensor
from notifications.services import create_notification_for_farm_uuid 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.") raise ValueError("Physical device not found.")
farm_boundary = _get_farm_boundary(sensor=sensor) farm_boundary = _get_farm_boundary(sensor=sensor)
url = _build_farm_data_url()
api_key = getattr(settings, "FARM_DATA_API_KEY", "") api_key = getattr(settings, "FARM_DATA_API_KEY", "")
if not api_key: if not api_key:
raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.") 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: try:
response = requests.post( response = external_api_request(
url, "ai",
json=request_payload, _get_farm_data_path(),
method="POST",
payload=request_payload,
headers={ headers={
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"X-API-Key": api_key, "X-API-Key": api_key,
"Authorization": f"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 raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc
if response.status_code >= 400: if response.status_code >= 400:
try: response_body = response.data
response_body = response.json()
except ValueError:
response_body = response.text
raise FarmDataForwardError( raise FarmDataForwardError(
f"Farm data API returned status {response.status_code}: {response_body}" f"Farm data API returned status {response.status_code}: {response_body}"
) )
@@ -163,11 +161,5 @@ def _get_farm_boundary(*, sensor):
return geometry return geometry
def _build_farm_data_url(): def _get_farm_data_path():
base_url = getattr(settings, "AI_SERVICE_BASE_URL", "").rstrip("/") return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/")
path = "/api/farm-data/"
if not base_url:
raise FarmDataForwardError("FARM_DATA_API_HOST is not configured.")
return f"{base_url}/{path.lstrip('/')}"
+14 -14
View File
@@ -1,9 +1,10 @@
import requests
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory 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 crop_zoning.models import CropArea
from farm_hub.models import FarmHub, FarmSensor, FarmType from farm_hub.models import FarmHub, FarmSensor, FarmType
from notifications.models import FarmNotification from notifications.models import FarmNotification
@@ -16,8 +17,6 @@ from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView
@override_settings( @override_settings(
SENSOR_EXTERNAL_API_KEY="12345", SENSOR_EXTERNAL_API_KEY="12345",
FARM_DATA_API_HOST="http://localhost",
FARM_DATA_API_PORT="8020",
FARM_DATA_API_KEY="farm-data-key", FARM_DATA_API_KEY="farm-data-key",
) )
class SensorExternalAPIViewTests(TestCase): class SensorExternalAPIViewTests(TestCase):
@@ -85,9 +84,9 @@ class SensorExternalAPIViewTests(TestCase):
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
@patch("sensor_external_api.services.requests.post") @patch("sensor_external_api.services.external_api_request")
def test_creates_notification_and_request_log_for_device_uuid(self, mock_post): def test_creates_notification_and_request_log_for_device_uuid(self, mock_external_api_request):
mock_post.return_value = Mock(status_code=201) mock_external_api_request.return_value = AdapterResponse(status_code=201, data={})
request = self.factory.post( request = self.factory.post(
"/api/sensor-external-api/", "/api/sensor-external-api/",
{"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}}, {"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}},
@@ -112,9 +111,11 @@ class SensorExternalAPIViewTests(TestCase):
payload={"temp": 12}, payload={"temp": 12},
).exists() ).exists()
) )
mock_post.assert_called_once_with( mock_external_api_request.assert_called_once_with(
"http://localhost:8020/api/farm-data/", "ai",
json={ "/api/farm-data/",
method="POST",
payload={
"farm_uuid": str(self.farm.farm_uuid), "farm_uuid": str(self.farm.farm_uuid),
"farm_boundary": self.crop_area.geometry, "farm_boundary": self.crop_area.geometry,
"sensor_payload": { "sensor_payload": {
@@ -127,7 +128,6 @@ class SensorExternalAPIViewTests(TestCase):
"X-API-Key": "farm-data-key", "X-API-Key": "farm-data-key",
"Authorization": "Api-Key farm-data-key", "Authorization": "Api-Key farm-data-key",
}, },
timeout=30,
) )
def test_returns_404_for_unknown_device_uuid(self): def test_returns_404_for_unknown_device_uuid(self):
@@ -142,9 +142,9 @@ class SensorExternalAPIViewTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@patch("sensor_external_api.services.requests.post") @patch("sensor_external_api.services.external_api_request")
def test_returns_503_when_farm_data_api_is_unavailable(self, mock_post): def test_returns_503_when_farm_data_api_is_unavailable(self, mock_external_api_request):
mock_post.side_effect = requests.RequestException("connection error") mock_external_api_request.side_effect = ExternalAPIRequestError("connection error")
request = self.factory.post( request = self.factory.post(
"/api/sensor-external-api/", "/api/sensor-external-api/",
+44 -16
View File
@@ -2,14 +2,14 @@ from rest_framework import serializers
class SoilKpiSerializer(serializers.Serializer): class SoilKpiSerializer(serializers.Serializer):
id = 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) title = serializers.CharField(required=False, allow_blank=True, help_text="عنوان کارت KPI.")
subtitle = serializers.CharField(required=False, allow_blank=True) subtitle = serializers.CharField(required=False, allow_blank=True, help_text="زیرعنوان کارت KPI.")
stats = serializers.CharField(required=False, allow_blank=True) stats = serializers.CharField(required=False, allow_blank=True, help_text="مقدار اصلی KPI.")
avatarColor = serializers.CharField(required=False, allow_blank=True) avatarColor = serializers.CharField(required=False, allow_blank=True, help_text="رنگ آواتار کارت.")
avatarIcon = serializers.CharField(required=False, allow_blank=True) avatarIcon = serializers.CharField(required=False, allow_blank=True, help_text="آیکون کارت.")
chipText = serializers.CharField(required=False, allow_blank=True) chipText = serializers.CharField(required=False, allow_blank=True, help_text="متن وضعیت KPI.")
chipColor = serializers.CharField(required=False, allow_blank=True) chipColor = serializers.CharField(required=False, allow_blank=True, help_text="رنگ وضعیت KPI.")
class SoilRadarSeriesSerializer(serializers.Serializer): class SoilRadarSeriesSerializer(serializers.Serializer):
@@ -39,7 +39,18 @@ class SoilAnomalyItemSerializer(serializers.Serializer):
class SoilAnomalyDetectionSerializer(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) 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): class SoilHeatmapPointSerializer(serializers.Serializer):
@@ -52,15 +63,32 @@ class SoilHeatmapSeriesSerializer(serializers.Serializer):
data = SoilHeatmapPointSerializer(many=True, required=False) data = SoilHeatmapPointSerializer(many=True, required=False)
class SoilGenericDictSerializer(serializers.Serializer):
class Meta:
ref_name = "SoilGenericDict"
class SoilMoistureHeatmapSerializer(serializers.Serializer): class SoilMoistureHeatmapSerializer(serializers.Serializer):
zones = serializers.ListField(child=serializers.CharField(), required=False) farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.")
hours = serializers.ListField(child=serializers.CharField(), required=False) location = serializers.DictField(required=False, help_text="اطلاعات مکانی مزرعه یا ناحیه تحلیل.")
series = SoilHeatmapSeriesSerializer(many=True, required=False) 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): class SoilSummarySerializer(serializers.Serializer):
avgSoilMoisture = SoilKpiSerializer(required=False) farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.")
sensorRadarChart = SoilRadarChartSerializer(required=False) healthScore = serializers.IntegerField(required=False, help_text="امتیاز سلامت کلی خاک.")
sensorComparisonChart = SoilComparisonChartSerializer(required=False) profileSource = serializers.CharField(required=False, allow_blank=True, help_text="منبع پروفایل مرجع یا محصول هدف.")
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False) healthScoreDetails = serializers.DictField(required=False, help_text="جزئیات تشکیل‌دهنده health score.")
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False) 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="وضعیت متنی رطوبت خاک.")
+210
View File
@@ -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.")
-4
View File
@@ -2,8 +2,6 @@ from django.urls import path
from .views import ( from .views import (
AvgSoilMoistureView, AvgSoilMoistureView,
SensorComparisonChartView,
SensorRadarChartView,
SoilAnomalyDetectionView, SoilAnomalyDetectionView,
SoilMoistureHeatmapView, SoilMoistureHeatmapView,
SoilSummaryView, SoilSummaryView,
@@ -11,8 +9,6 @@ from .views import (
urlpatterns = [ urlpatterns = [
path("avg-moisture/", AvgSoilMoistureView.as_view(), name="soil-avg-moisture"), 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("anomalies/", SoilAnomalyDetectionView.as_view(), name="soil-anomalies"),
path("moisture-heatmap/", SoilMoistureHeatmapView.as_view(), name="soil-moisture-heatmap"), path("moisture-heatmap/", SoilMoistureHeatmapView.as_view(), name="soil-moisture-heatmap"),
path("summary/", SoilSummaryView.as_view(), name="soil-summary"), path("summary/", SoilSummaryView.as_view(), name="soil-summary"),
+120 -52
View File
@@ -1,27 +1,22 @@
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema
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 farm_hub.models import FarmHub
from .serializers import ( from .serializers import (
SoilAnomalyDetectionSerializer, SoilAnomalyDetectionSerializer,
SoilComparisonChartSerializer,
SoilKpiSerializer, SoilKpiSerializer,
SoilMoistureHeatmapSerializer, SoilMoistureHeatmapSerializer,
SoilRadarChartSerializer,
SoilSummarySerializer, SoilSummarySerializer,
) )
from .services import ( from .services import (
get_anomaly_detection_card_data, get_anomaly_detection_card_data,
get_avg_soil_moisture_data, get_avg_soil_moisture_data,
get_sensor_comparison_chart_data,
get_sensor_radar_chart_data,
get_soil_moisture_heatmap_data, get_soil_moisture_heatmap_data,
get_soil_summary_data,
) )
@@ -35,18 +30,28 @@ def _get_farm_from_request(request):
return None 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): class AvgSoilMoistureView(APIView):
@extend_schema( @extend_schema(
tags=["Soil"], tags=["Soil"],
parameters=[ parameters=[
OpenApiParameter( farm_uuid_query_param(required=False, description="UUID of the farm for average soil moisture."),
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",
),
], ],
responses={200: status_response("AvgSoilMoistureResponse", data=SoilKpiSerializer())}, 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): class SoilAnomalyDetectionView(APIView):
@extend_schema( @extend_schema(
tags=["Soil"], tags=["Soil"],
parameters=[ 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())}, responses={200: status_response("SoilAnomalyDetectionResponse", data=SoilAnomalyDetectionSerializer())},
) )
def get(self, request): def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
return Response( return Response(
{"status": "success", "data": get_anomaly_detection_card_data(_get_farm_from_request(request))}, {"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(
{"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -106,13 +112,44 @@ class SoilMoistureHeatmapView(APIView):
@extend_schema( @extend_schema(
tags=["Soil"], tags=["Soil"],
parameters=[ 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())}, responses={200: status_response("SoilMoistureHeatmapResponse", data=SoilMoistureHeatmapSerializer())},
) )
def get(self, request): def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
return Response( return Response(
{"status": "success", "data": get_soil_moisture_heatmap_data(_get_farm_from_request(request))}, {"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(
{"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -121,12 +158,43 @@ class SoilSummaryView(APIView):
@extend_schema( @extend_schema(
tags=["Soil"], tags=["Soil"],
parameters=[ 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())}, responses={200: status_response("SoilSummaryResponse", data=SoilSummarySerializer())},
) )
def get(self, request): def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
return Response( return Response(
{"status": "success", "data": get_soil_summary_data(_get_farm_from_request(request))}, {"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(
{"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
+18 -17
View File
@@ -10,12 +10,12 @@ class WeatherChartDataSerializer(serializers.Serializer):
class FarmWeatherCardSerializer(serializers.Serializer): class FarmWeatherCardSerializer(serializers.Serializer):
condition = serializers.CharField(required=False, allow_blank=True) condition = serializers.CharField(required=False, allow_blank=True, help_text="وضعیت فعلی آب‌وهوا.")
temperature = serializers.FloatField(required=False) temperature = serializers.FloatField(required=False, help_text="دمای فعلی.")
unit = serializers.CharField(required=False, allow_blank=True) unit = serializers.CharField(required=False, allow_blank=True, help_text="واحد دما.")
humidity = serializers.IntegerField(required=False) humidity = serializers.IntegerField(required=False, help_text="رطوبت نسبی.")
windSpeed = serializers.FloatField(required=False) windSpeed = serializers.FloatField(required=False, help_text="سرعت باد.")
windUnit = serializers.CharField(required=False, allow_blank=True) windUnit = serializers.CharField(required=False, allow_blank=True, help_text="واحد سرعت باد.")
chartData = WeatherChartDataSerializer(required=False) chartData = WeatherChartDataSerializer(required=False)
@@ -25,21 +25,22 @@ class WaterNeedSeriesSerializer(serializers.Serializer):
class WaterNeedPredictionSerializer(serializers.Serializer): class WaterNeedPredictionSerializer(serializers.Serializer):
totalNext7Days = serializers.FloatField(required=False) farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.")
unit = serializers.CharField(required=False, allow_blank=True) totalNext7Days = serializers.FloatField(required=False, help_text="جمع نیاز آبی ۷ روز آینده.")
categories = serializers.ListField(child=serializers.CharField(), required=False) 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) 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): class WaterStressIndexSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True) farm_uuid = serializers.UUIDField(required=False, allow_null=True, help_text="UUID مزرعه.")
title = serializers.CharField(required=False, allow_blank=True) waterStressIndex = serializers.IntegerField(required=False, help_text="شاخص تنش آبی.")
subtitle = serializers.CharField(required=False, allow_blank=True) level = serializers.CharField(required=False, allow_blank=True, help_text="سطح تنش آبی.")
stats = serializers.CharField(required=False, allow_blank=True) sourceMetric = serializers.DictField(required=False, help_text="متریک یا منبع محاسبه تنش آبی.")
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)
class WaterSummarySerializer(serializers.Serializer): class WaterSummarySerializer(serializers.Serializer):
+108
View File
@@ -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/")
+158 -40
View File
@@ -8,7 +8,7 @@ from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema 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 external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub from farm_hub.models import FarmHub
from .models import WeatherForecastLog from .models import WeatherForecastLog
@@ -38,13 +38,7 @@ class FarmWeatherCardView(APIView):
@extend_schema( @extend_schema(
tags=["WATER"], tags=["WATER"],
parameters=[ parameters=[
OpenApiParameter( farm_uuid_query_param(required=False, description="UUID of the farm to fetch weather data for."),
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"),
], ],
responses={200: status_response("FarmWeatherCardResponse", data=FarmWeatherCardSerializer())}, 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): class WaterNeedPredictionView(APIView):
@extend_schema( @extend_schema(
tags=["WATER"], tags=["WATER"],
parameters=[ parameters=[
OpenApiParameter( farm_uuid_query_param(required=False, description="UUID of the farm to fetch water need prediction for."),
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",
),
], ],
responses={200: status_response("WaterNeedPredictionResponse", data=WaterNeedPredictionSerializer())}, responses={200: status_response("WaterNeedPredictionResponse", data=WaterNeedPredictionSerializer())},
) )
def get(self, request): def get(self, request):
farm = None
farm_uuid = request.query_params.get("farm_uuid") farm_uuid = request.query_params.get("farm_uuid")
if farm_uuid: if farm_uuid:
try: try:
farm = FarmHub.objects.get(farm_uuid=farm_uuid) farm = FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception): except (FarmHub.DoesNotExist, Exception):
farm = None 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( return Response(
{"status": "success", "data": get_water_need_prediction_data(farm)}, {"status": "success", "data": get_water_need_prediction_data(farm)},
@@ -121,31 +204,73 @@ class WaterNeedPredictionView(APIView):
class WaterStressIndexView(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( @extend_schema(
tags=["WATER"], tags=["WATER"],
parameters=[ parameters=[
OpenApiParameter( farm_uuid_query_param(required=True, description="UUID of the farm to fetch water stress index for."),
name="farm_uuid", sensor_uuid_query_param(),
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",
),
], ],
responses={200: status_response("WaterStressIndexResponse", data=WaterStressIndexSerializer())}, responses={200: status_response("WaterStressIndexResponse", data=WaterStressIndexSerializer())},
) )
def get(self, request): def get(self, request):
farm = None
farm_uuid = request.query_params.get("farm_uuid") farm_uuid = request.query_params.get("farm_uuid")
if farm_uuid: sensor_uuid = request.query_params.get("sensor_uuid")
try: farm = self._get_farm(farm_uuid)
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception): query = {"farm_uuid": str(farm.farm_uuid)}
farm = None 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( return Response(
{"status": "success", "data": get_water_stress_index_data(farm)}, {"code": 200, "msg": "success", "data": stress_payload},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -154,14 +279,7 @@ class WaterSummaryView(APIView):
@extend_schema( @extend_schema(
tags=["WATER"], tags=["WATER"],
parameters=[ parameters=[
OpenApiParameter( farm_uuid_query_param(required=False, description="UUID of the farm to fetch water summary for."),
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",
),
], ],
responses={200: status_response("WaterSummaryResponse", data=WaterSummarySerializer())}, responses={200: status_response("WaterSummaryResponse", data=WaterSummarySerializer())},
) )
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import WeatherFarmCardView
urlpatterns = [
path("farm-card/", WeatherFarmCardView.as_view(), name="weather-farm-card"),
]
+111 -35
View File
@@ -1,40 +1,5 @@
from rest_framework import serializers 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(): def success_response():
return {"status": "success"} return {"status": "success"}
@@ -86,3 +51,114 @@ class YieldHarvestSummarySerializer(serializers.Serializer):
yield_prediction_card = YieldPredictionCardSerializer(required=False) yield_prediction_card = YieldPredictionCardSerializer(required=False)
yield_prediction_chart = YieldPredictionChartSerializer(required=False) yield_prediction_chart = YieldPredictionChartSerializer(required=False)
harvest_prediction_card = HarvestPredictionCardSerializer(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)
+294
View File
@@ -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.")
+1 -25
View File
@@ -1,30 +1,6 @@
from django.urls import path from django.urls import path
from .views import ( from .views import YieldHarvestSummaryView
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"),
]
urlpatterns = [ urlpatterns = [
path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"), path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
+229 -70
View File
@@ -1,6 +1,4 @@
""" """Yield & Harvest Prediction and Crop Simulation API views."""
Yield & Harvest Prediction and Plant Simulator API views.
"""
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.response import Response 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.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema 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 external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub from farm_hub.models import FarmHub
from .mock_data import CONFIG_SLIDERS_ONLY, START_RESPONSE_DATA, STATE_RESPONSE_DATA
from .models import YieldHarvestPredictionLog from .models import YieldHarvestPredictionLog
from .serializers import YieldHarvestSummarySerializer, success_response, success_with_data from .serializers import (
CropSimulationRequestSerializer,
CurrentFarmChartSerializer,
class ConfigView(APIView): GrowthSimulationQueuedDataSerializer,
@extend_schema( GrowthSimulationRequestSerializer,
tags=["Plant Simulator"], GrowthSimulationStatusDataSerializer,
responses={200: status_response("PlantSimulatorConfigResponse", data=serializers.JSONField())}, HarvestPredictionSerializer,
YieldHarvestSummarySerializer,
YieldPredictionSerializer,
) )
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)
class YieldHarvestSummaryView(APIView): class YieldHarvestSummaryView(APIView):
@@ -98,13 +46,7 @@ class YieldHarvestSummaryView(APIView):
@extend_schema( @extend_schema(
tags=["Yield & Harvest Prediction"], tags=["Yield & Harvest Prediction"],
parameters=[ parameters=[
OpenApiParameter( farm_uuid_query_param(required=False, description="UUID of the farm for yield and harvest prediction."),
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"),
], ],
responses={200: status_response("YieldHarvestSummaryResponse", data=YieldHarvestSummarySerializer())}, responses={200: status_response("YieldHarvestSummaryResponse", data=YieldHarvestSummarySerializer())},
) )
@@ -151,3 +93,220 @@ class YieldHarvestSummaryView(APIView):
optimal_window_end=harvest_card.get("optimalWindowEnd") or None, optimal_window_end=harvest_card.get("optimalWindowEnd") or None,
chart_data=summary.get("yield_prediction_chart", {}), 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,
)