UPDATE
This commit is contained in:
@@ -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` مورد
|
||||||
@@ -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 داشته باشد.
|
||||||
@@ -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/*` است
|
||||||
@@ -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/`
|
||||||
|
|
||||||
## وضعیت فعلی کارتها
|
## وضعیت فعلی کارتها
|
||||||
|
|
||||||
|
|||||||
@@ -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/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 مزرعه برای دریافت نمای اقتصادی.")
|
||||||
|
|||||||
@@ -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/")
|
||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
|
||||||
]
|
]
|
||||||
|
|||||||
+61
-66
@@ -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,
|
|
||||||
RECOMMENDATIONS_LIST,
|
|
||||||
)
|
|
||||||
from .serializers import (
|
|
||||||
AlertTimelineSerializer,
|
|
||||||
AlertTrackerSerializer,
|
|
||||||
AnomalyDetectionSerializer,
|
|
||||||
CreateAlertSerializer,
|
|
||||||
RecommendationsListSerializer,
|
|
||||||
)
|
|
||||||
from .services import AlertService
|
|
||||||
|
|
||||||
|
|
||||||
class AlertTrackerView(APIView):
|
class FarmAlertsBaseView(APIView):
|
||||||
def get(self, request):
|
@staticmethod
|
||||||
serializer = AlertTrackerSerializer(ARM_ALERTS_TRACKER)
|
def _extract_result(adapter_data):
|
||||||
return Response({"status": "success", "result": serializer.data})
|
if not isinstance(adapter_data, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
data = adapter_data.get("data")
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("result"), dict):
|
||||||
|
return data["result"]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
|
||||||
class AlertTimelineView(APIView):
|
result = adapter_data.get("result")
|
||||||
def get(self, request):
|
if isinstance(result, dict):
|
||||||
serializer = AlertTimelineSerializer(FARM_ALERTS_TIMELINE)
|
return result
|
||||||
return Response({"status": "success", "result": serializer.data})
|
|
||||||
|
|
||||||
|
return adapter_data
|
||||||
|
|
||||||
class AnomalyDetectionView(APIView):
|
@staticmethod
|
||||||
def get(self, request):
|
def _error_response(adapter_response):
|
||||||
serializer = AnomalyDetectionSerializer(ANOMALY_DETECTION_CARD)
|
response_data = (
|
||||||
return Response({"status": "success", "result": serializer.data})
|
adapter_response.data
|
||||||
|
if isinstance(adapter_response.data, dict)
|
||||||
|
else {"message": str(adapter_response.data)}
|
||||||
class RecommendationsListView(APIView):
|
|
||||||
def get(self, request):
|
|
||||||
serializer = RecommendationsListSerializer(RECOMMENDATIONS_LIST)
|
|
||||||
return Response({"status": "success", "result": serializer.data})
|
|
||||||
|
|
||||||
|
|
||||||
class CreateAlertView(APIView):
|
|
||||||
def post(self, request):
|
|
||||||
serializer = CreateAlertSerializer(data=request.data)
|
|
||||||
if not serializer.is_valid():
|
|
||||||
return Response(
|
|
||||||
{"status": "error", "errors": serializer.errors},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = serializer.validated_data
|
|
||||||
farm = None
|
|
||||||
farm_uuid = data.get("farm_uuid")
|
|
||||||
if farm_uuid:
|
|
||||||
try:
|
|
||||||
farm = FarmHub.objects.get(uuid=farm_uuid)
|
|
||||||
except FarmHub.DoesNotExist:
|
|
||||||
return Response(
|
|
||||||
{"status": "error", "message": "farm not found"},
|
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
|
|
||||||
alert = AlertService.create_alert(
|
|
||||||
title=data["title"],
|
|
||||||
description=data.get("description", ""),
|
|
||||||
color=data.get("color", "info"),
|
|
||||||
avatar_icon=data.get("avatar_icon", ""),
|
|
||||||
avatar_color=data.get("avatar_color", ""),
|
|
||||||
farm=farm,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"status": "success", "result": {"uuid": str(alert.uuid), "title": alert.title}},
|
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
|
||||||
status=status.HTTP_201_CREATED,
|
status=adapter_response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertTrackerView(FarmAlertsBaseView):
|
||||||
|
def post(self, request):
|
||||||
|
adapter_response = external_api_request(
|
||||||
|
"ai",
|
||||||
|
"/api/farm-alerts/tracker/",
|
||||||
|
method="POST",
|
||||||
|
payload=request.data,
|
||||||
|
)
|
||||||
|
if adapter_response.status_code >= 400:
|
||||||
|
return self._error_response(adapter_response)
|
||||||
|
|
||||||
|
payload = self._extract_result(adapter_response.data)
|
||||||
|
serializer = AlertTrackerSerializer(data=payload)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return Response({"code": 200, "msg": "success", "data": serializer.validated_data}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertTimelineView(FarmAlertsBaseView):
|
||||||
|
def post(self, request):
|
||||||
|
adapter_response = external_api_request(
|
||||||
|
"ai",
|
||||||
|
"/api/farm-alerts/timeline/",
|
||||||
|
method="POST",
|
||||||
|
payload=request.data,
|
||||||
|
)
|
||||||
|
if adapter_response.status_code >= 400:
|
||||||
|
return self._error_response(adapter_response)
|
||||||
|
|
||||||
|
payload = self._extract_result(adapter_response.data)
|
||||||
|
serializer = AlertTimelineSerializer(data=payload)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return Response(
|
||||||
|
{"code": 200, "msg": "success", "data": serializer.validated_data},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -180,20 +180,39 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"area_geojson": {
|
"farm_boundary": {
|
||||||
"type": "Feature",
|
"corners": [
|
||||||
"properties": {},
|
{"lat": 35.70, "lon": 51.39},
|
||||||
"geometry": {
|
{"lat": 35.70, "lon": 51.41},
|
||||||
"type": "Polygon",
|
{"lat": 35.72, "lon": 51.41},
|
||||||
"coordinates": [
|
{"lat": 35.72, "lon": 51.39}
|
||||||
[
|
]
|
||||||
[51.418934, 35.706815],
|
},
|
||||||
[51.423054, 35.691062],
|
"sensor_key": "sensor-7-1",
|
||||||
[51.384258, 35.689389],
|
"sensor_payload": {
|
||||||
[51.418934, 35.706815]
|
"soil_moisture": 45.2,
|
||||||
]
|
"soil_temperature": 22.5
|
||||||
|
},
|
||||||
|
"irrigation_method_id": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
برای `farm_boundary` هر دو فرم زیر پشتیبانی میشوند:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {},
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[
|
||||||
|
[51.418934, 35.706815],
|
||||||
|
[51.423054, 35.691062],
|
||||||
|
[51.384258, 35.689389],
|
||||||
|
[51.418934, 35.706815]
|
||||||
]
|
]
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -207,7 +226,11 @@ Content-Type: application/json
|
|||||||
| `farm_type_uuid` | uuid | بله | UUID نوع مزرعه |
|
| `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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+25
-2
@@ -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)
|
||||||
serializer.save()
|
area_feature = serializer.validated_data.get("area_geojson", None)
|
||||||
|
sensor_key = serializer.validated_data.get("sensor_key", "sensor-7-1")
|
||||||
|
sensor_payload = serializer.validated_data.get("sensor_payload", None)
|
||||||
|
irrigation_method_id = serializer.validated_data.get("irrigation_method_id", None)
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
serializer.save()
|
||||||
|
if area_feature is not None:
|
||||||
|
crop_area, _zoning_payload = dispatch_farm_zoning(area_feature, serializer.instance)
|
||||||
|
serializer.instance.current_crop_area = crop_area
|
||||||
|
serializer.instance.save(update_fields=["current_crop_area", "updated_at"])
|
||||||
|
sync_farm_data(
|
||||||
|
farm=serializer.instance,
|
||||||
|
area_feature=area_feature,
|
||||||
|
sensor_key=sensor_key,
|
||||||
|
sensor_payload=sensor_payload,
|
||||||
|
plant_ids=[product.id for product in serializer.instance.products.all()],
|
||||||
|
irrigation_method_id=irrigation_method_id,
|
||||||
|
)
|
||||||
|
except FarmDataSyncError as exc:
|
||||||
|
return Response({"code": 502, "msg": str(exc)}, status=status.HTTP_502_BAD_GATEWAY)
|
||||||
farm.refresh_from_db()
|
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)
|
||||||
|
|||||||
@@ -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,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",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
)
|
||||||
@@ -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",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 +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"),
|
|
||||||
]
|
|
||||||
|
|||||||
+223
-70
@@ -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
|
||||||
|
|
||||||
@extend_schema(
|
files = []
|
||||||
tags=["Pest Detection"],
|
for uploaded_image in uploaded_images:
|
||||||
request=OpenApiTypes.OBJECT,
|
files.append(
|
||||||
responses={200: status_response("PestDetectionAnalyzeResponse", data=serializers.JSONField())},
|
(
|
||||||
)
|
"images",
|
||||||
def post(self, request):
|
(
|
||||||
|
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(
|
return Response(
|
||||||
{"status": "success", "data": ANALYZE_RESPONSE_DATA},
|
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
|
||||||
status=status.HTTP_200_OK,
|
status=adapter_response.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RiskSummaryView(APIView):
|
class AnalyzeView(PestDetectionFarmMixin, APIView):
|
||||||
"""
|
|
||||||
GET endpoint for combined pest and disease risk summary.
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
Returns disease_risk and pest_risk card data for the farm dashboard.
|
|
||||||
Calls the AI external adapter for live/mock risk assessment results.
|
|
||||||
|
|
||||||
Input parameters:
|
|
||||||
- farm_uuid (query, optional): UUID of the farm to assess.
|
|
||||||
|
|
||||||
Response structure:
|
|
||||||
- status: string, always "success".
|
|
||||||
- data: object with keys disease_risk and pest_risk,
|
|
||||||
each containing card display fields (id, title, subtitle, stats,
|
|
||||||
avatarColor, avatarIcon, chipText, chipColor) and a details object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Pest Detection"],
|
tags=["Pest Detection"],
|
||||||
parameters=[
|
request=PestDetectionAnalyzeRequestSerializer,
|
||||||
OpenApiParameter(
|
responses={200: status_response("PestDetectionAnalyzeResponse", data=PestDetectionAnalyzeResponseSerializer())},
|
||||||
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):
|
def post(self, request):
|
||||||
farm_uuid = request.query_params.get("farm_uuid")
|
serializer = PestDetectionAnalyzeRequestSerializer(data=request.data)
|
||||||
query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {}
|
serializer.is_valid(raise_exception=True)
|
||||||
|
payload = serializer.validated_data.copy()
|
||||||
|
|
||||||
|
farm, error_response = self._get_farm(request, payload.get("farm_uuid"))
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
image_urls = self._prepare_image_urls(request)
|
||||||
|
uploaded_images = self._collect_uploaded_images(request)
|
||||||
|
if not image_urls and not uploaded_images:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"images": ["At least one image must be provided via image_urls, image, or images."],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_payload = {
|
||||||
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
|
"plant_name": payload.get("plant_name", ""),
|
||||||
|
"query": payload.get("query", ""),
|
||||||
|
"image_urls": image_urls,
|
||||||
|
}
|
||||||
|
sensor_uuid = payload.get("sensor_uuid")
|
||||||
|
if sensor_uuid:
|
||||||
|
ai_payload["sensor_uuid"] = str(sensor_uuid)
|
||||||
|
|
||||||
|
ai_payload = self._attach_uploaded_files(ai_payload, uploaded_images)
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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('/')}"
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.")
|
||||||
@@ -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
@@ -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(
|
||||||
|
{"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(
|
return Response(
|
||||||
{"status": "success", "data": get_anomaly_detection_card_data(_get_farm_from_request(request))},
|
{"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)},
|
||||||
status=status.HTTP_200_OK,
|
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(
|
||||||
|
{"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(
|
return Response(
|
||||||
{"status": "success", "data": get_soil_moisture_heatmap_data(_get_farm_from_request(request))},
|
{"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)},
|
||||||
status=status.HTTP_200_OK,
|
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(
|
||||||
|
{"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(
|
return Response(
|
||||||
{"status": "success", "data": get_soil_summary_data(_get_farm_from_request(request))},
|
{"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|||||||
+18
-17
@@ -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
@@ -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
@@ -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())},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
|
|||||||
+230
-71
@@ -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,
|
||||||
def get(self, request):
|
YieldPredictionSerializer,
|
||||||
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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user