This commit is contained in:
2026-04-11 03:54:15 +03:30
parent 883573004c
commit 36d6b05a7f
68 changed files with 3487 additions and 841 deletions
+144
View File
@@ -0,0 +1,144 @@
# Dashboard Card Sources
این فایل مشخص می‌کند هر کارت داشبورد در بک‌اند از کدام app، service و endpoint تغذیه می‌شود.
## مسیر اصلی داشبورد
- `GET /api/farm-dashboard/?farm_uuid=...`
- تجمیع نهایی کارت‌ها در `dashboard/services.py` انجام می‌شود.
- این سرویس از app های مختلف داده را جمع می‌کند و response نهایی داشبورد را می‌سازد.
## مپ ردیف‌ها و کارت‌ها
### `overviewKpis`
این ردیف در نهایت در `dashboard/services.py` ساخته می‌شود، ولی هر KPI از app خودش می‌آید:
| Card / KPI ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---|
| `farm_health_score` | امتیاز سلامت مزرعه | `crop_health` | `get_crop_health_summary_data` | `GET /api/crop-health/summary/` |
| `water_stress_index` | شاخص تنش آبی | `WATER` | `get_water_stress_index_data` | `GET /api/water/stress-index/` |
| `avg_soil_moisture` | میانگین رطوبت خاک | `soil` | `get_avg_soil_moisture_data` | `GET /api/soil/avg-moisture/` |
| `disease_risk` | ریسک بیماری | `pest_detection` | `get_risk_summary_data` | `GET /api/pest-detection/risk-summary/` |
| `yield_prediction` | پیش‌بینی عملکرد | `yield_harvest` | `get_yield_harvest_summary_data` | `GET /api/yield-harvest/summary/` |
| `pest_risk` | ریسک آفات | `pest_detection` | `get_risk_summary_data` | `GET /api/pest-detection/risk-summary/` |
### `weatherAlerts`
| Card ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---|
| `farmWeatherCard` | کارت آب‌وهوا | `WATER` | `get_farm_weather_card_data` | `GET /api/water/card/` |
| `farmAlertsTracker` | خلاصه هشدارها | `farm_alerts` | `get_alert_tracker_data` | هنوز endpoint مستقل service-based ندارد؛ در داشبورد از service داخلی استفاده می‌شود |
### `sensorMonitoring`
| Card ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---|
| `sensorValuesList` | لیست مقادیر سنسورها | فعلا `dashboard` | فعلا مستقیم از `dashboard/mock_data.py` | endpoint مستقل ندارد |
### `sensorCharts`
| Card ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---|
| `sensorRadarChart` | نمودار راداری سنسورها | `soil` | `get_sensor_radar_chart_data` | `GET /api/soil/sensor-radar-chart/` |
| `sensorComparisonChart` | مقایسه با هفته قبل | `soil` | `get_sensor_comparison_chart_data` | `GET /api/soil/sensor-comparison-chart/` |
### `alertsWater`
| Card ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---|
| `anomalyDetectionCard` | ناهنجاری سنسورها/خاک | `soil` | `get_anomaly_detection_card_data` | `GET /api/soil/anomalies/` |
| `farmAlertsTimeline` | تایم‌لاین هشدارها | `farm_alerts` | `get_alert_timeline_data` | هنوز endpoint مستقل service-based ندارد؛ در داشبورد از service داخلی استفاده می‌شود |
| `waterNeedPrediction` | نیاز آبی 7 روز آینده | `WATER` | `get_water_need_prediction_data` | `GET /api/water/need-prediction/` |
### `predictions`
| Card ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---|
| `harvestPredictionCard` | پیش‌بینی برداشت | `yield_harvest` | `get_yield_harvest_summary_data` | `GET /api/yield-harvest/summary/` |
| `yieldPredictionChart` | نمودار پیش‌بینی عملکرد | `yield_harvest` | `get_yield_harvest_summary_data` | `GET /api/yield-harvest/summary/` |
### `soilHeatmap`
| Card ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---|
| `soilMoistureHeatmap` | نقشه حرارتی رطوبت خاک | `soil` | `get_soil_moisture_heatmap_data` | `GET /api/soil/moisture-heatmap/` |
### `ndviRecommendations`
| Card ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---|
| `ndviHealthCard` | کارت سلامت NDVI | `crop_health` | `get_crop_health_summary_data` | `GET /api/crop-health/summary/` |
| `recommendationsList` | لیست پیشنهادها | ترکیبی | ترکیب در `dashboard/services.py` | endpoint مستقل نهایی ندارد |
منابع `recommendationsList`:
- `farm_alerts` از `get_recommendations_list_data`
- `irrigation_recommendation` از `get_irrigation_dashboard_recommendation`
- `fertilization_recommendation` از `get_fertilization_dashboard_recommendation`
- `yield_harvest` برای آیتم بازه برداشت
### `economic`
| Card ID | عنوان | منبع اصلی | service | endpoint |
|---|---|---|---|---|
| `economicOverview` | نمای اقتصادی | `economic_overview` | `get_economic_overview_data` | `GET /api/economic-overview/summary/` |
## endpoint های summary جدید برای app ها
برای استفاده راحت‌تر در فرانت، این app ها الان منبع واضح برای کارت‌های داشبورد دارند:
- `crop_health`
- `GET /api/crop-health/summary/`
- `WATER`
- `GET /api/water/card/`
- `GET /api/water/need-prediction/`
- `GET /api/water/stress-index/`
- `GET /api/water/summary/`
- `soil`
- `GET /api/soil/avg-moisture/`
- `GET /api/soil/sensor-radar-chart/`
- `GET /api/soil/sensor-comparison-chart/`
- `GET /api/soil/anomalies/`
- `GET /api/soil/moisture-heatmap/`
- `GET /api/soil/summary/`
- `yield_harvest`
- `GET /api/yield-harvest/summary/`
- `economic_overview`
- `GET /api/economic-overview/summary/`
## وضعیت فعلی کارت‌ها
### کاملا service-based
- `farmOverviewKpis` به صورت ترکیبی
- `farmWeatherCard`
- `farmAlertsTracker`
- `sensorRadarChart`
- `sensorComparisonChart`
- `anomalyDetectionCard`
- `farmAlertsTimeline`
- `waterNeedPrediction`
- `harvestPredictionCard`
- `yieldPredictionChart`
- `soilMoistureHeatmap`
- `ndviHealthCard`
- `recommendationsList`
- `economicOverview`
### هنوز مستقیم از mock داشبورد
- `sensorValuesList`
## پیشنهاد برای فرانت
- اگر صفحه مستقل برای هر domain دارید، برای هر صفحه از endpoint خود همان app استفاده کنید.
- اگر صفحه داشبورد اصلی دارید، فقط از `GET /api/farm-dashboard/?farm_uuid=...` استفاده کنید.
- اگر لازم است هر کارت به صفحه جزئیات خودش لینک شود، بهترین mapping فعلی این است:
- Weather + Water cards -> `WATER`
- NDVI + Farm Health -> `crop_health`
- Soil cards -> `soil`
- Yield + Harvest -> `yield_harvest`
- Alerts -> `farm_alerts`
- Economy -> `economic_overview`
- Pest/Disease risk -> `pest_detection`
+517
View File
@@ -0,0 +1,517 @@
# Frontend Pages & APIs Guide
این فایل برای تیم فرانت نوشته شده تا بدانند:
- چه app های جدیدی به بک‌اند اضافه شده‌اند
- چه API های جدیدی اضافه شده‌اند
- چه تغییراتی نسبت به commit قبلی و تغییرات فعلی انجام شده
- سیستم شبیه‌سازی گیاه چگونه به ساختار `yield_harvest` منتقل شده
- هر card داشبورد باید به کدام app و endpoint وصل شود
- هر card علاوه بر داشبورد باید در صفحه domain خودش هم نمایش داده شود
---
## خلاصه تغییرات مهم
### تغییرات مهم commit قبلی و فعلی
در تغییرات اخیر، backend از حالت monolithic dashboard mock به سمت app-based domain APIs رفته است.
یعنی به جای اینکه همه چیز فقط در `dashboard` یا فقط از AI بیاید:
- هر بخش domain اصلی app خودش را دارد
- برای چند domain جدید، API مستقل ساخته شده
- داشبورد از service های app های مختلف داده را assemble می‌کند
### مهم‌ترین تغییر معماری
سیستم `plant_simulator` دیگر app مستقل نیست و منطق آن به `yield_harvest` منتقل شده است.
این یعنی:
- فولدر `plant_simulator` حذف شده
- route های شبیه‌ساز هنوز در `api/plant-simulator/` موجودند
- اما implementation آن‌ها داخل `yield_harvest` نگهداری می‌شود
پس برای فرانت:
- صفحه Plant Simulator هنوز می‌تواند باقی بماند
- ولی از نظر backend ownership، این بخش زیرمجموعه `yield_harvest` محسوب می‌شود
- بهتر است در فرانت هم Plant Simulator به عنوان صفحه/تب وابسته به `Yield & Harvest` دیده شود
---
## app های جدید یا تغییر یافته
### 1) `crop_health`
app جدید برای:
- `ndviHealthCard`
- `farm_health_score`
هدف:
- داشتن صفحه مستقل برای سلامت محصول / NDVI
- جدا کردن health-related KPIها از `dashboard`
### 2) `WATER`
این app جایگزین ساختار قبلی `weather_forecast` شده است.
الان app `WATER` مسئول این بخش‌هاست:
- `farmWeatherCard`
- `waterNeedPrediction`
- `water_stress_index`
هدف:
- ادغام weather + water نیاز + water stress در یک domain
- ساخت صفحه مستقل برای آب و هوا و وضعیت آب
### 3) `soil`
app جدید برای:
- `avg_soil_moisture`
- `sensorRadarChart`
- `sensorComparisonChart`
- `anomalyDetectionCard`
- `soilMoistureHeatmap`
هدف:
- جدا کردن card های مرتبط با خاک و سنسور از `dashboard`
- فراهم کردن API مستقل برای صفحه Soil / Sensor Insights
### 4) `yield_harvest`
این app قبلا وجود داشت، اما نقش آن گسترده‌تر شده:
- `yieldPredictionChart`
- `harvestPredictionCard`
- `yield_prediction` KPI
- منطق `plant_simulator` هم به این app منتقل شده
### 5) `dashboard`
`dashboard` الان دیگر منبع اصلی data نیست.
وظیفه جدید آن:
- گرفتن data از service های app های مختلف
- ساخت response نهایی برای صفحه داشبورد
---
## API های جدید و فعال
## Dashboard
- `GET /api/farm-dashboard/?farm_uuid=...`
- `GET /api/farm-dashboard-config/?farm_uuid=...`
- `PATCH /api/farm-dashboard-config/`
کاربرد:
- فقط برای صفحه Dashboard اصلی
- در فرانت برای داشبورد overview از این endpoint استفاده شود
---
## Crop Health APIs
- `GET /api/crop-health/summary/`
کاربرد:
- صفحه مستقل Crop Health
- کارت‌های:
- `farm_health_score`
- `ndviHealthCard`
---
## WATER APIs
- `GET /api/water/card/`
- `GET /api/water/need-prediction/`
- `GET /api/water/stress-index/`
- `GET /api/water/summary/`
کاربرد:
- صفحه مستقل Water / Water & Weather
- کارت‌های:
- `farmWeatherCard`
- `waterNeedPrediction`
- `water_stress_index`
نکته:
- route قدیمی `weather_forecast` دیگر برای فرانت target اصلی نیست
- فرانت باید به `api/water/...` مهاجرت کند
---
## Soil APIs
- `GET /api/soil/avg-moisture/`
- `GET /api/soil/sensor-radar-chart/`
- `GET /api/soil/sensor-comparison-chart/`
- `GET /api/soil/anomalies/`
- `GET /api/soil/moisture-heatmap/`
- `GET /api/soil/summary/`
کاربرد:
- صفحه مستقل Soil / Soil Analytics / Sensor Insights
- کارت‌های:
- `avg_soil_moisture`
- `sensorRadarChart`
- `sensorComparisonChart`
- `anomalyDetectionCard`
- `soilMoistureHeatmap`
---
## Yield & Harvest APIs
- `GET /api/yield-harvest/summary/`
Plant Simulator routes که الان implementationشان زیر `yield_harvest` است:
- `GET /api/plant-simulator/config/`
- `GET /api/plant-simulator/state/`
- `POST /api/plant-simulator/start/`
- `POST /api/plant-simulator/stop/`
- `POST /api/plant-simulator/reset/`
- `PATCH /api/plant-simulator/environment/`
کاربرد:
- صفحه مستقل Yield & Harvest
- صفحه یا تب Plant Simulator
---
## Economic Overview APIs
- `GET /api/economic-overview/summary/`
کاربرد:
- صفحه مستقل Economic Overview
- کارت `economicOverview`
---
## Pest Detection APIs
- `GET /api/pest-detection/risk-summary/`
کاربرد:
- صفحه Pest / Disease Risk
- KPIهای:
- `disease_risk`
- `pest_risk`
---
## Farm Alerts APIs
در backend برای dashboard service استفاده می‌شوند:
- tracker
- timeline
- recommendations
مسیرهای موجود:
- `GET /api/farm-alerts/tracker/`
- `GET /api/farm-alerts/timeline/`
- `GET /api/farm-alerts/anomalies/`
- `GET /api/farm-alerts/recommendations/`
کاربرد:
- صفحه Alerts
- برخی recommendationها
نکته:
- در ساختار جدید، `anomalyDetectionCard` برای dashboard از `soil` می‌آید
- ولی `farm_alerts` هنوز برای timeline/tracker/recommendations مهم است
---
## تغییر سیستم Plant Simulator
### قبل
- app جداگانه‌ای به اسم `plant_simulator` وجود داشت
- config, view, route و mock data همگی در همان app بودند
### الان
- app `plant_simulator` حذف شده
- route های plant simulator حفظ شده‌اند
- implementation آن‌ها به `yield_harvest` منتقل شده
### نتیجه برای فرانت
- صفحه Plant Simulator حذف نشود
- اما در navigation و ownership بهتر است به عنوان بخشی از `Yield & Harvest` دیده شود
- اگر صفحه Yield & Harvest دارید، Plant Simulator بهتر است زیر همین domain قرار بگیرد
### پیشنهاد UI/Navigation
- `Yield & Harvest`
- Overview
- Yield Prediction
- Harvest Prediction
- Plant Simulator
---
## مپ کامل card ها به app ها
| Card | Dashboard Row | Source App | Preferred Endpoint | Frontend Page |
|---|---|---|---|---|
| `farm_health_score` | `overviewKpis` | `crop_health` | `GET /api/crop-health/summary/` | Crop Health Page |
| `water_stress_index` | `overviewKpis` | `WATER` | `GET /api/water/stress-index/` | Water Page |
| `avg_soil_moisture` | `overviewKpis` | `soil` | `GET /api/soil/avg-moisture/` | Soil Page |
| `disease_risk` | `overviewKpis` | `pest_detection` | `GET /api/pest-detection/risk-summary/` | Pest Risk Page |
| `yield_prediction` | `overviewKpis` | `yield_harvest` | `GET /api/yield-harvest/summary/` | Yield & Harvest Page |
| `pest_risk` | `overviewKpis` | `pest_detection` | `GET /api/pest-detection/risk-summary/` | Pest Risk Page |
| `farmWeatherCard` | `weatherAlerts` | `WATER` | `GET /api/water/card/` | Water Page |
| `farmAlertsTracker` | `weatherAlerts` | `farm_alerts` | `GET /api/farm-alerts/tracker/` | Alerts Page |
| `sensorValuesList` | `sensorMonitoring` | فعلا `dashboard` | فعلا فقط dashboard | Sensor Page در آینده |
| `sensorRadarChart` | `sensorCharts` | `soil` | `GET /api/soil/sensor-radar-chart/` | Soil Page |
| `sensorComparisonChart` | `sensorCharts` | `soil` | `GET /api/soil/sensor-comparison-chart/` | Soil Page |
| `anomalyDetectionCard` | `alertsWater` | `soil` | `GET /api/soil/anomalies/` | Soil Page |
| `farmAlertsTimeline` | `alertsWater` | `farm_alerts` | `GET /api/farm-alerts/timeline/` | Alerts Page |
| `waterNeedPrediction` | `alertsWater` | `WATER` | `GET /api/water/need-prediction/` | Water Page |
| `harvestPredictionCard` | `predictions` | `yield_harvest` | `GET /api/yield-harvest/summary/` | Yield & Harvest Page |
| `yieldPredictionChart` | `predictions` | `yield_harvest` | `GET /api/yield-harvest/summary/` | Yield & Harvest Page |
| `soilMoistureHeatmap` | `soilHeatmap` | `soil` | `GET /api/soil/moisture-heatmap/` | Soil Page |
| `ndviHealthCard` | `ndviRecommendations` | `crop_health` | `GET /api/crop-health/summary/` | Crop Health Page |
| `recommendationsList` | `ndviRecommendations` | ترکیبی | dashboard aggregate | Recommendations / Alerts / Domain Pages |
| `economicOverview` | `economic` | `economic_overview` | `GET /api/economic-overview/summary/` | Economic Overview Page |
---
## صفحه‌هایی که تیم فرانت باید بسازد
با توجه به backend فعلی، پیشنهاد می‌شود این صفحه‌ها حتما در فرانت ساخته شوند:
### 1) Dashboard Page
برای overview کلی مزرعه
endpoint اصلی:
- `GET /api/farm-dashboard/?farm_uuid=...`
### 2) Crop Health Page
نمایش:
- `farm_health_score`
- `ndviHealthCard`
endpoint:
- `GET /api/crop-health/summary/`
### 3) Water Page
نمایش:
- `farmWeatherCard`
- `waterNeedPrediction`
- `water_stress_index`
endpoint ها:
- `GET /api/water/card/`
- `GET /api/water/need-prediction/`
- `GET /api/water/stress-index/`
- یا یکجا `GET /api/water/summary/`
### 4) Soil Page
نمایش:
- `avg_soil_moisture`
- `sensorRadarChart`
- `sensorComparisonChart`
- `anomalyDetectionCard`
- `soilMoistureHeatmap`
endpoint ها:
- `GET /api/soil/avg-moisture/`
- `GET /api/soil/sensor-radar-chart/`
- `GET /api/soil/sensor-comparison-chart/`
- `GET /api/soil/anomalies/`
- `GET /api/soil/moisture-heatmap/`
- یا یکجا `GET /api/soil/summary/`
### 5) Yield & Harvest Page
نمایش:
- `yield_prediction`
- `yieldPredictionChart`
- `harvestPredictionCard`
endpoint:
- `GET /api/yield-harvest/summary/`
### 6) Plant Simulator Page / Tab
این صفحه باید باقی بماند، اما domain آن الان زیر `yield_harvest` است.
endpoint ها:
- `GET /api/plant-simulator/config/`
- `GET /api/plant-simulator/state/`
- `POST /api/plant-simulator/start/`
- `POST /api/plant-simulator/stop/`
- `POST /api/plant-simulator/reset/`
- `PATCH /api/plant-simulator/environment/`
### 7) Alerts Page
نمایش:
- `farmAlertsTracker`
- `farmAlertsTimeline`
- recommendationهای alert-based
endpoint ها:
- `GET /api/farm-alerts/tracker/`
- `GET /api/farm-alerts/timeline/`
- `GET /api/farm-alerts/recommendations/`
### 8) Pest / Disease Risk Page
نمایش:
- `disease_risk`
- `pest_risk`
endpoint:
- `GET /api/pest-detection/risk-summary/`
### 9) Economic Overview Page
نمایش:
- `economicOverview`
endpoint:
- `GET /api/economic-overview/summary/`
---
## قانون مهم برای تیم فرانت
هر card باید در دو جا قابل استفاده باشد:
### 1) داخل Dashboard
برای overview سریع مزرعه
### 2) داخل صفحه domain خودش
برای نمایش کامل‌تر و جزئی‌تر
یعنی:
- `ndviHealthCard` هم در Dashboard باشد هم در Crop Health Page
- `waterNeedPrediction` هم در Dashboard باشد هم در Water Page
- `soilMoistureHeatmap` هم در Dashboard باشد هم در Soil Page
- `yieldPredictionChart` هم در Dashboard باشد هم در Yield & Harvest Page
- `economicOverview` هم در Dashboard باشد هم در Economic Page
این الگو باید برای همه card های domain-based رعایت شود.
---
## پیشنهاد ساختار صفحات در فرانت
### پیشنهادی برای منوی اصلی
- Dashboard
- Crop Health
- Water
- Soil
- Yield & Harvest
- Plant Simulator
- Alerts
- Pest & Disease Risk
- Economic Overview
### پیشنهادی برای ارتباط صفحه و API
- Dashboard -> فقط dashboard aggregate
- Domain pages -> endpoint اختصاصی همان app
این روش باعث می‌شود:
- dashboard سریع‌تر و ساده‌تر بماند
- صفحات domain مستقل توسعه‌پذیر باشند
- coupling بین frontend pages و dashboard کم شود
---
## نکته مهم درباره sensorValuesList
در حال حاضر:
- `sensorValuesList` هنوز app اختصاصی ندارد
- هنوز از `dashboard/mock_data.py` می‌آید
برای فرانت:
- موقتا می‌تواند فقط در Dashboard نشان داده شود
- یا یک placeholder page برای Sensor Details ساخته شود
اما از نظر backend، این card هنوز به app اختصاصی مهاجرت نکرده است.
---
## جمع‌بندی نهایی
### app های جدید یا تغییر یافته برای فرانت
- `crop_health`
- `WATER`
- `soil`
- `yield_harvest` با نقش گسترده‌تر
- حذف `plant_simulator` به عنوان app مستقل و انتقال آن به `yield_harvest`
### چیزی که فرانت باید بداند
- dashboard دیگر منبع domain data نیست؛ فقط aggregator است
- هر card مهم الان باید app/domain خودش را داشته باشد
- هر card باید هم در dashboard باشد هم در صفحه مربوط به خودش
- Plant Simulator هنوز endpoint دارد، اما از نظر معماری بخشی از `yield_harvest` است
### بهترین rule برای تیم فرانت
اگر صفحه overview می‌خواهید:
- از `GET /api/farm-dashboard/` استفاده کنید
اگر صفحه domain می‌خواهید:
- از endpoint اختصاصی همان app استفاده کنید
+381
View File
@@ -0,0 +1,381 @@
# راهنمای API فرانت برای سنسورها
این فایل برای تحویل به فرانت نوشته شده و دو app را به‌صورت جداگانه توضیح می‌دهد:
1. `sensor_external_api`
2. `sensor_7_in_1`
---
# صفحه 1 - `sensor_external_api`
## اطلاعات app
- فایل app: `sensor_external_api/apps.py`
- AppConfig: `SensorExternalApiConfig`
- Django app name: `sensor_external_api`
- Base URL: `/api/sensor-external-api/`
## کاربرد این app
این app برای دریافت payload خام از سنسور خارجی و همچنین مشاهده لاگ‌های ثبت‌شده آن استفاده می‌شود.
نکته مهم:
- endpoint ثبت payload بیشتر برای **خود سنسور / gateway / backend integration** است.
- endpoint لاگ‌ها برای **فرانت** مناسب است تا آخرین داده‌های خام دریافت‌شده را ببیند.
## API 1 - ثبت payload از سنسور خارجی
- Method: `POST`
- URL: `/api/sensor-external-api/`
- Auth: هدر `X-API-Key`
- Permission: عمومی است، ولی فقط با API key معتبر
### Header
```http
X-API-Key: 12345
Content-Type: application/json
```
### Body
```json
{
"uuid": "33333333-3333-3333-3333-333333333333",
"payload": {
"soil_moisture": 48.5,
"soil_temperature": 23.2,
"soil_ph": 6.8,
"electrical_conductivity": 1.4,
"nitrogen": 31,
"phosphorus": 16,
"potassium": 24
}
}
```
### توضیح فیلدها
- `uuid`: شناسه فیزیکی دستگاه سنسور
- `payload`: داده خام ارسال‌شده توسط دستگاه
### پاسخ موفق
```json
{
"code": 201,
"msg": "success",
"data": {
"id": 1,
"title": "Sensor external API request",
"message": "Payload received from device 33333333-3333-3333-3333-333333333333."
}
}
```
### خطاهای مهم
- `401`: API key نامعتبر یا ارسال نشده
- `404`: سنسور با این `uuid` پیدا نشده
- `503`: سرویس مقصد یا جداول موردنیاز آماده نیستند
## API 2 - دریافت لاگ‌های سنسور خارجی
- Method: `GET`
- URL: `/api/sensor-external-api/logs/?farm_uuid=<uuid>`
- Auth: JWT کاربر
- Permission: `IsAuthenticated`
### Query Params
- `farm_uuid` اجباری
- `page` اختیاری
- `page_size` اختیاری
### نمونه درخواست
```http
GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=20
Authorization: Bearer <token>
```
### پاسخ موفق
```json
{
"code": 200,
"msg": "success",
"count": 2,
"next": null,
"previous": null,
"data": [
{
"id": 10,
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222",
"physical_device_uuid": "33333333-3333-3333-3333-333333333333",
"farm_sensor": {
"uuid": "aaaa1111-1111-1111-1111-111111111111",
"sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222",
"physical_device_uuid": "33333333-3333-3333-3333-333333333333",
"name": "Soil Sensor 7-in-1",
"sensor_type": "soil_7_in_1",
"is_active": true,
"specifications": {},
"power_source": {},
"created_at": "2025-03-24T10:00:00Z",
"updated_at": "2025-03-24T10:00:00Z"
},
"sensor_catalog": {
"uuid": "22222222-2222-2222-2222-222222222222",
"code": "sensor-7-in-1",
"name": "7 in 1 Soil Sensor",
"description": "",
"customizable_fields": [],
"supported_power_sources": [],
"returned_data_fields": [
"soil_moisture",
"soil_temperature",
"soil_ph",
"electrical_conductivity",
"nitrogen",
"phosphorus",
"potassium"
],
"sample_payload": {},
"is_active": true,
"created_at": "2025-03-24T10:00:00Z",
"updated_at": "2025-03-24T10:00:00Z"
},
"payload": {
"soil_moisture": 48.5,
"soil_temperature": 23.2,
"soil_ph": 6.8,
"electrical_conductivity": 1.4,
"nitrogen": 31,
"phosphorus": 16,
"potassium": 24
},
"created_at": "2025-03-24T10:00:00Z"
}
]
}
```
## کاربرد فرانت
- برای صفحه history / logs سنسور
- برای نمایش raw payload دریافتی از دستگاه
- برای debug و بررسی آخرین داده‌های ثبت‌شده
## نکته فرانت
برای نمایش کارت‌های نهایی و تمیزشده سنسور 7 در 1 بهتر است از app بعدی یعنی `sensor_7_in_1` استفاده شود، نه از payload خام این app.
---
# صفحه 2 - `sensor_7_in_1`
## اطلاعات app
- فایل app: `sensor_7_in_1/apps.py`
- AppConfig: `Sensor7In1Config`
- Django app name: `sensor_7_in_1`
- Base URL: `/api/sensor-7-in-1/`
- Feature code: `sensor-7-in-1`
## کاربرد این app
این app داده‌های خام ثبت‌شده در `sensor_external_api` را می‌خواند و فقط اطلاعات مربوط به **یک سنسور خاص** یعنی سنسور خاک 7-in-1 را به فرم قابل‌استفاده برای UI برمی‌گرداند.
به‌جای اینکه فرانت خودش از payload خام `soil_moisture`, `soil_ph`, `nitrogen` و ... کارت بسازد، این app خروجی نهایی UI-ready می‌دهد.
## API اصلی
- Method: `GET`
- URL: `/api/sensor-7-in-1/summary/?farm_uuid=<uuid>`
- Auth: JWT کاربر
- Permission: `IsAuthenticated`
### Query Params
- `farm_uuid` اجباری
### نمونه درخواست
```http
GET /api/sensor-7-in-1/summary/?farm_uuid=11111111-1111-1111-1111-111111111111
Authorization: Bearer <token>
```
### پاسخ موفق
```json
{
"code": 200,
"msg": "OK",
"data": {
"sensor": {
"name": "Soil Sensor 7-in-1",
"physicalDeviceUuid": "33333333-3333-3333-3333-333333333333",
"sensorCatalogCode": "sensor-7-in-1",
"updatedAt": "2025-03-24T10:00:00Z"
},
"sensorValuesList": {
"sensor": {
"name": "Soil Sensor 7-in-1",
"physicalDeviceUuid": "33333333-3333-3333-3333-333333333333",
"sensorCatalogCode": "sensor-7-in-1",
"updatedAt": "2025-03-24T10:00:00Z"
},
"sensors": [
{
"id": "soil_moisture",
"title": "48.5%",
"subtitle": "رطوبت خاک",
"trendNumber": 7.5,
"trend": "positive",
"unit": "%"
},
{
"id": "soil_temperature",
"title": "23.2°C",
"subtitle": "دمای خاک",
"trendNumber": 2.2,
"trend": "positive",
"unit": "°C"
},
{
"id": "soil_ph",
"title": "6.8",
"subtitle": "pH خاک",
"trendNumber": 0.3,
"trend": "positive",
"unit": "pH"
}
]
},
"avgSoilMoisture": {
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "سنسور 7 در 1 خاک",
"stats": "48.5%",
"avatarColor": "warning",
"avatarIcon": "tabler-droplet",
"chipText": "متوسط",
"chipColor": "warning"
},
"sensorRadarChart": {
"labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"],
"series": [
{
"name": "اکنون",
"data": [87, 90, 95, 88, 90, 85, 92]
},
{
"name": "هدف",
"data": [100, 100, 100, 100, 100, 100, 100]
}
]
},
"sensorComparisonChart": {
"currentValue": 48.5,
"vsLastWeek": "+18.3%",
"vsLastWeekValue": 18.3,
"categories": ["03/24 09:00", "03/24 10:00"],
"series": [
{
"name": "رطوبت خاک",
"data": [41, 48.5]
},
{
"name": "بازه هدف",
"data": [55, 55]
}
]
},
"anomalyDetectionCard": {
"anomalies": [
{
"sensor": "هدایت الکتریکی",
"value": "1.4 dS/m",
"expected": "0.8-1.8 dS/m",
"deviation": "0",
"severity": "success"
}
]
},
"soilMoistureHeatmap": {
"zones": ["Soil Sensor 7-in-1"],
"hours": ["09:00", "10:00"],
"series": [
{
"name": "Soil Sensor 7-in-1",
"data": [
{ "x": "09:00", "y": 41 },
{ "x": "10:00", "y": 48.5 }
]
}
]
}
}
}
```
## فیلدهای مهم برای فرانت
### `sensor`
اطلاعات شناسایی سنسور:
- `name`
- `physicalDeviceUuid`
- `sensorCatalogCode`
- `updatedAt`
### `sensorValuesList`
برای لیست کارت‌های عددی سنسور:
- رطوبت خاک
- دمای خاک
- pH خاک
- هدایت الکتریکی
- نیتروژن
- فسفر
- پتاسیم
### `avgSoilMoisture`
برای KPI رطوبت خاک در dashboard
### `sensorRadarChart`
برای نمودار radar وضعیت شاخص‌های سنسور
### `sensorComparisonChart`
برای نمودار روند رطوبت خاک نسبت به داده‌های قبلی
### `anomalyDetectionCard`
برای نمایش مقادیر خارج از بازه نرمال
### `soilMoistureHeatmap`
برای heatmap یا نمودار تاریخچه رطوبت خاک
## رفتار این app
- فقط داده سنسوری را که مشخصات 7-in-1 داشته باشد برمی‌گرداند
- داده‌ها را از لاگ‌های `sensor_external_api` می‌خواند
- اگر داده واقعی پیدا نشود، fallback mock برمی‌گرداند
- این app همان منبعی است که dashboard برای کارت‌های سنسوری از آن استفاده می‌کند
## پیشنهاد استفاده در فرانت
- اگر هدف نمایش **کارت‌های نهایی سنسور** است، از `sensor_7_in_1` استفاده کنید
- اگر هدف نمایش **لاگ خام payload** یا history ورودی دستگاه است، از `sensor_external_api/logs/` استفاده کنید
+1
View File
@@ -8,6 +8,7 @@
"crop_zoning": "crop_zoning", "crop_zoning": "crop_zoning",
"plant_simulator": "plant_simulator", "plant_simulator": "plant_simulator",
"pest_detection": "pest_detection", "pest_detection": "pest_detection",
"sensor_7_in_1": "sensor-7-in-1",
"irrigation_recommendation": "irrigation_recommendation", "irrigation_recommendation": "irrigation_recommendation",
"fertilization_recommendation": "fertilization_recommendation", "fertilization_recommendation": "fertilization_recommendation",
"farm_ai_assistant": "farm_ai_assistant", "farm_ai_assistant": "farm_ai_assistant",
+4 -2
View File
@@ -31,10 +31,12 @@ INSTALLED_APPS = [
"access_control.apps.AccessControlConfig", "access_control.apps.AccessControlConfig",
"sensor_catalog.apps.SensorCatalogConfig", "sensor_catalog.apps.SensorCatalogConfig",
"dashboard", "dashboard",
"crop_health.apps.CropHealthConfig",
"soil.apps.SoilConfig",
"crop_zoning", "crop_zoning",
"plant_simulator",
"pest_detection", "pest_detection",
"weather_forecast.apps.WeatherForecastConfig", "sensor_7_in_1.apps.Sensor7In1Config",
"water.apps.WaterConfig",
"irrigation_recommendation", "irrigation_recommendation",
"yield_harvest.apps.YieldHarvestConfig", "yield_harvest.apps.YieldHarvestConfig",
"economic_overview.apps.EconomicOverviewConfig", "economic_overview.apps.EconomicOverviewConfig",
+6 -2
View File
@@ -1,6 +1,7 @@
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),
@@ -14,11 +15,14 @@ urlpatterns = [
path("api/sensor-catalog/", include("sensor_catalog.urls")), path("api/sensor-catalog/", include("sensor_catalog.urls")),
path("api/farm-dashboard-config/", include("dashboard.urls_config")), path("api/farm-dashboard-config/", include("dashboard.urls_config")),
path("api/farm-dashboard/", include("dashboard.urls")), path("api/farm-dashboard/", include("dashboard.urls")),
path("api/crop-health/", include("crop_health.urls")),
path("api/soil/", include("soil.urls")),
path("api/crop-zoning/", include("crop_zoning.urls")), path("api/crop-zoning/", include("crop_zoning.urls")),
path("api/plant-simulator/", include("plant_simulator.urls")), path("api/plant-simulator/", include(plant_simulator_urlpatterns)),
path("api/pest-detection/", include("pest_detection.urls")), 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/irrigation-recommendation/", include("irrigation_recommendation.urls")),
path("api/weather-forecast/", include("weather_forecast.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/economic-overview/", include("economic_overview.urls")),
path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")), path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")),
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class CropHealthConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "crop_health"
verbose_name = "Crop Health"
+19
View File
@@ -0,0 +1,19 @@
FARM_HEALTH_SCORE = {
"id": "farm_health_score",
"title": "امتیاز سلامت مزرعه",
"subtitle": "تحلیل هوشمند",
"stats": "87%",
"avatarColor": "success",
"avatarIcon": "tabler-heartbeat",
"chipText": "خوب",
"chipColor": "success",
}
NDVI_HEALTH_CARD = {
"ndviIndex": 0.78,
"healthData": [
{"title": "تنش نیتروژن", "value": "پایین", "color": "success", "icon": "tabler-leaf"},
{"title": "سلامت محصول", "value": "خوب", "color": "success", "icon": "tabler-plant"},
],
}
+2
View File
@@ -0,0 +1,2 @@
from django.db import models
+29
View File
@@ -0,0 +1,29 @@
from rest_framework import serializers
class HealthDataItemSerializer(serializers.Serializer):
title = serializers.CharField(required=False, allow_blank=True)
value = serializers.CharField(required=False, allow_blank=True)
color = serializers.CharField(required=False, allow_blank=True)
icon = serializers.CharField(required=False, allow_blank=True)
class NdviHealthCardSerializer(serializers.Serializer):
ndviIndex = serializers.FloatField(required=False)
healthData = HealthDataItemSerializer(many=True, required=False)
class FarmHealthScoreSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True)
title = serializers.CharField(required=False, allow_blank=True)
subtitle = serializers.CharField(required=False, allow_blank=True)
stats = serializers.CharField(required=False, allow_blank=True)
avatarColor = serializers.CharField(required=False, allow_blank=True)
avatarIcon = serializers.CharField(required=False, allow_blank=True)
chipText = serializers.CharField(required=False, allow_blank=True)
chipColor = serializers.CharField(required=False, allow_blank=True)
class CropHealthSummarySerializer(serializers.Serializer):
ndviHealthCard = NdviHealthCardSerializer(required=False)
farmHealthScore = FarmHealthScoreSerializer(required=False)
+10
View File
@@ -0,0 +1,10 @@
from copy import deepcopy
from .mock_data import FARM_HEALTH_SCORE, NDVI_HEALTH_CARD
def get_crop_health_summary_data(farm=None):
return {
"ndviHealthCard": deepcopy(NDVI_HEALTH_CARD),
"farmHealthScore": deepcopy(FARM_HEALTH_SCORE),
}
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import CropHealthSummaryView
urlpatterns = [
path("summary/", CropHealthSummaryView.as_view(), name="crop-health-summary"),
]
+31
View File
@@ -0,0 +1,31 @@
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from .serializers import CropHealthSummarySerializer
from .services import get_crop_health_summary_data
class CropHealthSummaryView(APIView):
@extend_schema(
tags=["Crop Health"],
parameters=[
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=False,
description="UUID of the farm for crop health data.",
default="11111111-1111-1111-1111-111111111111",
),
],
responses={200: status_response("CropHealthSummaryResponse", data=CropHealthSummarySerializer())},
)
def get(self, request):
return Response(
{"status": "success", "data": get_crop_health_summary_data()},
status=status.HTTP_200_OK,
)
+5 -117
View File
@@ -66,26 +66,6 @@ def reset_config():
# 4.1 farmOverviewKpis # 4.1 farmOverviewKpis
FARM_OVERVIEW_KPIS = { FARM_OVERVIEW_KPIS = {
"kpis": [ "kpis": [
{
"id": "farm_health_score",
"title": "امتیاز سلامت مزرعه",
"subtitle": "تحلیل هوشمند",
"stats": "87%",
"avatarColor": "success",
"avatarIcon": "tabler-heartbeat",
"chipText": "خوب",
"chipColor": "success",
},
{
"id": "water_stress_index",
"title": "شاخص تنش آبی",
"subtitle": "فعلی",
"stats": "12%",
"avatarColor": "info",
"avatarIcon": "tabler-droplet",
"chipText": "پایین",
"chipColor": "success",
},
{ {
"id": "disease_risk", "id": "disease_risk",
"title": "ریسک بیماری", "title": "ریسک بیماری",
@@ -96,16 +76,6 @@ FARM_OVERVIEW_KPIS = {
"chipText": "5%", "chipText": "5%",
"chipColor": "success", "chipColor": "success",
}, },
{
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "کل مزرعه",
"stats": "65%",
"avatarColor": "primary",
"avatarIcon": "tabler-plant-2",
"chipText": "بهینه",
"chipColor": "success",
},
{ {
"id": "yield_prediction", "id": "yield_prediction",
"title": "پیش‌بینی عملکرد", "title": "پیش‌بینی عملکرد",
@@ -231,47 +201,6 @@ SENSOR_VALUES_LIST = {
] ]
} }
# 4.5 sensorRadarChart
SENSOR_RADAR_CHART = {
"labels": ["دما", "رطوبت", "pH", "هدایت الکتریکی", "نور", "باد"],
"series": [
{"name": "امروز", "data": [75, 65, 80, 70, 85, 60]},
{"name": "ایده‌آل", "data": [80, 70, 75, 75, 90, 50]},
],
}
# 4.6 sensorComparisonChart
SENSOR_COMPARISON_CHART = {
"currentValue": 48,
"vsLastWeek": "+5%",
"vsLastWeekValue": 5,
"categories": ["دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه", "شنبه", "یکشنبه"],
"series": [
{"name": "امروز", "data": [42, 45, 48, 52, 50, 48, 46]},
{"name": "هفته قبل", "data": [38, 40, 42, 45, 43, 40, 38]},
],
}
# 4.7 anomalyDetectionCard
ANOMALY_DETECTION_CARD = {
"anomalies": [
{
"sensor": "رطوبت خاک زون ۳",
"value": "38%",
"expected": "45-65%",
"deviation": "-12%",
"severity": "warning",
},
{
"sensor": "pH بخش ۲",
"value": "5.2",
"expected": "6.0-7.0",
"deviation": "-0.8",
"severity": "error",
},
]
}
# 4.8 farmAlertsTimeline # 4.8 farmAlertsTimeline
FARM_ALERTS_TIMELINE = { FARM_ALERTS_TIMELINE = {
"alerts": [ "alerts": [
@@ -345,47 +274,6 @@ YIELD_PREDICTION_CHART = {
], ],
} }
# 4.12 soilMoistureHeatmap
SOIL_MOISTURE_HEATMAP = {
"zones": ["زون ۱", "زون ۲", "زون ۳", "زون ۴", "زون ۵", "زون ۶", "زون ۷"],
"hours": ["۶ ص", "۸ ص", "۱۰ ص", "۱۲ ظ", "۱۴ ع", "۱۶ ع", "۱۸ ع"],
"series": [
{
"name": "زون ۱",
"data": [
{"x": "۶ ص", "y": 52},
{"x": "۸ ص", "y": 48},
{"x": "۱۰ ص", "y": 55},
{"x": "۱۲ ظ", "y": 60},
{"x": "۱۴ ع", "y": 58},
{"x": "۱۶ ع", "y": 54},
{"x": "۱۸ ع", "y": 50},
],
},
{
"name": "زون ۲",
"data": [
{"x": "۶ ص", "y": 45},
{"x": "۸ ص", "y": 42},
{"x": "۱۰ ص", "y": 48},
{"x": "۱۲ ظ", "y": 52},
{"x": "۱۴ ع", "y": 50},
{"x": "۱۶ ع", "y": 47},
{"x": "۱۸ ع", "y": 44},
],
},
],
}
# 4.13 ndviHealthCard
NDVI_HEALTH_CARD = {
"ndviIndex": 0.78,
"healthData": [
{"title": "تنش نیتروژن", "value": "پایین", "color": "success", "icon": "tabler-leaf"},
{"title": "سلامت محصول", "value": "خوب", "color": "success", "icon": "tabler-plant"},
],
}
# 4.14 recommendationsList # 4.14 recommendationsList
RECOMMENDATIONS_LIST = { RECOMMENDATIONS_LIST = {
"recommendations": [ "recommendations": [
@@ -461,15 +349,15 @@ ALL_CARDS = {
"farmWeatherCard": FARM_WEATHER_CARD, # هروز "farmWeatherCard": FARM_WEATHER_CARD, # هروز
"farmAlertsTracker": FARM_ALERTS_TRACKER, #هروز "farmAlertsTracker": FARM_ALERTS_TRACKER, #هروز
"sensorValuesList": SENSOR_VALUES_LIST,#هروز "sensorValuesList": SENSOR_VALUES_LIST,#هروز
"sensorRadarChart": SENSOR_RADAR_CHART, "sensorRadarChart": {},
"sensorComparisonChart": SENSOR_COMPARISON_CHART, "sensorComparisonChart": {},
"anomalyDetectionCard": ANOMALY_DETECTION_CARD, "anomalyDetectionCard": {},
"farmAlertsTimeline": FARM_ALERTS_TIMELINE, "farmAlertsTimeline": FARM_ALERTS_TIMELINE,
"waterNeedPrediction": WATER_NEED_PREDICTION, "waterNeedPrediction": WATER_NEED_PREDICTION,
"harvestPredictionCard": HARVEST_PREDICTION_CARD, "harvestPredictionCard": HARVEST_PREDICTION_CARD,
"yieldPredictionChart": YIELD_PREDICTION_CHART, "yieldPredictionChart": YIELD_PREDICTION_CHART,
"soilMoistureHeatmap": SOIL_MOISTURE_HEATMAP, "soilMoistureHeatmap": {},
"ndviHealthCard": NDVI_HEALTH_CARD, "ndviHealthCard": {},
"recommendationsList": RECOMMENDATIONS_LIST, # این باید حتما از recommendetion ها گرفته بشه "recommendationsList": RECOMMENDATIONS_LIST, # این باید حتما از recommendetion ها گرفته بشه
"economicOverview": ECONOMIC_OVERVIEW, "economicOverview": ECONOMIC_OVERVIEW,
} }
+123
View File
@@ -0,0 +1,123 @@
from copy import deepcopy
from water.services import (
get_farm_weather_card_data,
get_water_need_prediction_data,
get_water_stress_index_data,
)
from crop_health.services import get_crop_health_summary_data
from economic_overview.services import get_economic_overview_data
from farm_alerts.services import (
get_alert_timeline_data,
get_alert_tracker_data,
get_recommendations_list_data,
)
from fertilization_recommendation.services import get_fertilization_dashboard_recommendation
from irrigation_recommendation.services import get_irrigation_dashboard_recommendation
from pest_detection.services import get_risk_summary_data
from sensor_7_in_1.services import (
get_sensor_7_in_1_summary_data,
)
from yield_harvest.services import get_yield_harvest_summary_data
from .mock_data import ALL_CARDS
def _update_kpi(card_lookup, card_data):
if not card_data:
return
card_id = card_data.get("id")
if not card_id or card_id not in card_lookup:
return
details = card_data.get("details")
clean_data = {key: value for key, value in card_data.items() if key != "details"}
card_lookup[card_id].update(clean_data)
if details is not None:
card_lookup[card_id]["details"] = details
def _build_overview_kpis(base_cards, crop_health_summary, water_stress_index, avg_soil_moisture, risk_summary, yield_summary):
kpis = [crop_health_summary["farmHealthScore"], water_stress_index, avg_soil_moisture, *deepcopy(base_cards["kpis"])]
card_lookup = {item["id"]: item for item in kpis}
_update_kpi(card_lookup, water_stress_index)
_update_kpi(card_lookup, avg_soil_moisture)
_update_kpi(card_lookup, risk_summary.get("disease_risk", {}))
_update_kpi(card_lookup, risk_summary.get("pest_risk", {}))
_update_kpi(card_lookup, yield_summary.get("yield_prediction_card", {}))
return {"kpis": kpis}
def _build_recommendations_list(farm, fallback_data, harvest_card):
recommendations = []
recommendations.extend(get_recommendations_list_data(farm).get("recommendations", []))
recommendations.append(get_irrigation_dashboard_recommendation(farm))
recommendations.append(get_fertilization_dashboard_recommendation(farm))
if harvest_card:
recommendations.append(
{
"title": f"بازه برداشت: {harvest_card.get('optimalWindowStart', '')} تا {harvest_card.get('optimalWindowEnd', '')}",
"subtitle": harvest_card.get("description", ""),
"avatarIcon": "tabler-calendar-event",
"avatarColor": "info",
}
)
deduped = []
seen_titles = set()
for item in recommendations:
title = item.get("title")
if not title or title in seen_titles:
continue
seen_titles.add(title)
deduped.append(item)
if deduped:
return {"recommendations": deduped[:4]}
return deepcopy(fallback_data)
def get_farm_dashboard_cards(farm):
cards = deepcopy(ALL_CARDS)
weather_card = get_farm_weather_card_data(farm)
crop_health_summary = get_crop_health_summary_data(farm)
risk_summary = get_risk_summary_data(farm)
yield_summary = get_yield_harvest_summary_data(farm)
water_stress_index = get_water_stress_index_data(farm)
sensor_summary = get_sensor_7_in_1_summary_data(farm)
avg_soil_moisture = sensor_summary["avgSoilMoisture"]
cards["farmWeatherCard"] = weather_card
cards["farmAlertsTracker"] = get_alert_tracker_data(farm)
cards["farmAlertsTimeline"] = get_alert_timeline_data(farm)
cards["sensorValuesList"] = sensor_summary["sensorValuesList"]
cards["anomalyDetectionCard"] = sensor_summary["anomalyDetectionCard"]
cards["waterNeedPrediction"] = get_water_need_prediction_data(farm)
cards["harvestPredictionCard"] = yield_summary["harvest_prediction_card"]
cards["yieldPredictionChart"] = yield_summary["yield_prediction_chart"]
cards["sensorRadarChart"] = sensor_summary["sensorRadarChart"]
cards["sensorComparisonChart"] = sensor_summary["sensorComparisonChart"]
cards["soilMoistureHeatmap"] = sensor_summary["soilMoistureHeatmap"]
cards["ndviHealthCard"] = crop_health_summary["ndviHealthCard"]
cards["economicOverview"] = get_economic_overview_data(farm)
cards["farmOverviewKpis"] = _build_overview_kpis(
cards["farmOverviewKpis"],
crop_health_summary,
water_stress_index,
avg_soil_moisture,
risk_summary,
yield_summary,
)
cards["recommendationsList"] = _build_recommendations_list(
farm,
cards["recommendationsList"],
yield_summary.get("harvest_prediction_card", {}),
)
return cards
+12 -12
View File
@@ -1,5 +1,4 @@
from copy import deepcopy from copy import deepcopy
from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
@@ -136,23 +135,24 @@ class FarmDashboardConfigViewTests(DashboardBaseTestCase):
class FarmDashboardCardsViewTests(DashboardBaseTestCase): class FarmDashboardCardsViewTests(DashboardBaseTestCase):
@patch("dashboard.views.external_api_request") def test_get_returns_locally_aggregated_cards(self):
def test_get_forwards_farm_uuid_to_external_api(self, mock_external_api_request):
mock_external_api_request.return_value.data = {"status": "success", "data": {}}
mock_external_api_request.return_value.status_code = 200
request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}") request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user) force_authenticate(request, user=self.user)
response = FarmDashboardCardsView.as_view()(request) response = FarmDashboardCardsView.as_view()(request)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with( self.assertEqual(response.data["code"], 200)
"ai", self.assertEqual(response.data["msg"], "OK")
"/dashboard-data/status", self.assertIn("farmWeatherCard", response.data["data"])
method="GET", self.assertIn("farmAlertsTracker", response.data["data"])
query={"farm_uuid": str(self.farm.farm_uuid)}, self.assertIn("yieldPredictionChart", response.data["data"])
) self.assertIn("ndviHealthCard", response.data["data"])
self.assertIn("sensorRadarChart", response.data["data"])
self.assertIn("soilMoistureHeatmap", response.data["data"])
self.assertIn("economicOverview", response.data["data"])
self.assertEqual(response.data["data"]["farmOverviewKpis"]["kpis"][0]["id"], "farm_health_score")
self.assertEqual(response.data["data"]["farmOverviewKpis"]["kpis"][2]["id"], "avg_soil_moisture")
def test_get_requires_farm_uuid(self): def test_get_requires_farm_uuid(self):
request = self.factory.get("/api/farm-dashboard/") request = self.factory.get("/api/farm-dashboard/")
+5 -8
View File
@@ -10,8 +10,8 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from config.swagger import code_response from config.swagger import code_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub from farm_hub.models import FarmHub
from .services import get_farm_dashboard_cards
from .mock_data import DEFAULT_CONFIG from .mock_data import DEFAULT_CONFIG
from .models import FarmDashboardConfig from .models import FarmDashboardConfig
from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer
@@ -117,7 +117,7 @@ class FarmDashboardConfigView(FarmAccessMixin, APIView):
class FarmDashboardCardsView(FarmAccessMixin, APIView): class FarmDashboardCardsView(FarmAccessMixin, APIView):
""" """
Farm dashboard cards endpoint: GET. Farm dashboard cards endpoint: GET.
Requires farm_uuid and forwards it to the external AI service. Requires farm_uuid and assembles local dashboard services.
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@@ -125,10 +125,7 @@ class FarmDashboardCardsView(FarmAccessMixin, APIView):
def get(self, request): def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid")) farm = self._get_farm(request, request.query_params.get("farm_uuid"))
adapter_response = external_api_request( return Response(
"ai", {"code": 200, "msg": "OK", "data": get_farm_dashboard_cards(farm)},
"/dashboard-data/status", status=status.HTTP_200_OK,
method="GET",
query={"farm_uuid": str(farm.farm_uuid)},
) )
return Response(adapter_response.data, status=adapter_response.status_code)
+24
View File
@@ -0,0 +1,24 @@
from copy import deepcopy
from .mock_data import ECONOMIC_OVERVIEW
from .models import EconomicOverviewLog
def get_economic_overview_data(farm=None):
data = deepcopy(ECONOMIC_OVERVIEW)
if farm is None:
return data
log = EconomicOverviewLog.objects.filter(farm=farm).first()
if log is None:
return data
if log.economic_data:
data["economicData"] = deepcopy(log.economic_data)
if log.chart_series:
data["chartSeries"] = deepcopy(log.chart_series)
if log.chart_categories:
data["chartCategories"] = deepcopy(log.chart_categories)
return data
+102 -1
View File
@@ -1,7 +1,16 @@
from collections import Counter
from copy import deepcopy
from farm_hub.models import FarmHub from farm_hub.models import FarmHub
from notifications.models import FarmNotification from notifications.models import FarmNotification
from .models import FarmAlert from .mock_data import (
ANOMALY_DETECTION_CARD,
ARM_ALERTS_TRACKER,
FARM_ALERTS_TIMELINE,
RECOMMENDATIONS_LIST,
)
from .models import AnomalyDetection, FarmAlert, Recommendation
class AlertService: class AlertService:
@@ -47,3 +56,95 @@ class AlertService:
level=level_map.get(alert.color, "info"), level=level_map.get(alert.color, "info"),
metadata={"alert_uuid": str(alert.uuid), "color": alert.color}, metadata={"alert_uuid": str(alert.uuid), "color": alert.color},
) )
def get_alert_tracker_data(farm=None):
if farm is None:
return deepcopy(ARM_ALERTS_TRACKER)
alerts = list(FarmAlert.objects.filter(farm=farm, is_active=True)[:20])
if not alerts:
return deepcopy(ARM_ALERTS_TRACKER)
counts = Counter(alert.title for alert in alerts)
alert_stats = []
for title, count in counts.most_common(3):
sample = next((alert for alert in alerts if alert.title == title), None)
alert_stats.append(
{
"title": title,
"count": str(count),
"avatarColor": sample.color if sample else "info",
"avatarIcon": sample.avatar_icon or "tabler-bell",
}
)
return {
"totalAlerts": len(alerts),
"radialBarValue": min(len(alerts) * 10, 100),
"alertStats": alert_stats,
}
def get_alert_timeline_data(farm=None):
if farm is None:
return deepcopy(FARM_ALERTS_TIMELINE)
alerts = list(FarmAlert.objects.filter(farm=farm)[:10])
if not alerts:
return deepcopy(FARM_ALERTS_TIMELINE)
return {
"alerts": [
{
"title": alert.title,
"description": alert.description,
"time": alert.created_at.strftime("%Y-%m-%d %H:%M"),
"color": alert.color,
}
for alert in alerts
]
}
def get_anomaly_detection_data(farm=None):
if farm is None:
return deepcopy(ANOMALY_DETECTION_CARD)
anomalies = list(AnomalyDetection.objects.filter(farm=farm)[:10])
if not anomalies:
return deepcopy(ANOMALY_DETECTION_CARD)
return {
"anomalies": [
{
"sensor": anomaly.sensor,
"value": anomaly.value,
"expected": anomaly.expected,
"deviation": anomaly.deviation,
"severity": anomaly.severity,
}
for anomaly in anomalies
]
}
def get_recommendations_list_data(farm=None):
if farm is None:
return deepcopy(RECOMMENDATIONS_LIST)
recommendations = list(Recommendation.objects.filter(farm=farm)[:10])
if not recommendations:
return deepcopy(RECOMMENDATIONS_LIST)
return {
"recommendations": [
{
"title": recommendation.title,
"subtitle": recommendation.subtitle,
"avatarIcon": recommendation.avatar_icon or "tabler-bulb",
"avatarColor": recommendation.avatar_color or "info",
}
for recommendation in recommendations
]
}
@@ -35,3 +35,10 @@ RECOMMEND_RESPONSE_DATA = {
"reasoning": "Your loamy soil with medium organic matter (2.5%) provides good nutrient retention. Water EC of 1.2 dS/m indicates low salinity—suitable for most crops. At the flowering stage, increased phosphorus supports bloom development. We recommend a balanced NPK to maintain nitrogen for vegetative growth while boosting phosphorous for flowering.", "reasoning": "Your loamy soil with medium organic matter (2.5%) provides good nutrient retention. Water EC of 1.2 dS/m indicates low salinity—suitable for most crops. At the flowering stage, increased phosphorus supports bloom development. We recommend a balanced NPK to maintain nitrogen for vegetative growth while boosting phosphorous for flowering.",
}, },
} }
FERTILIZATION_DASHBOARD_RECOMMENDATION = {
"title": "کود: 20-20-20 (NPK)",
"subtitle": "150 kg/ha، با روش Foliar spray + soil broadcast و هر 14 روز.",
"avatarIcon": "tabler-leaf",
"avatarColor": "success",
}
+50
View File
@@ -0,0 +1,50 @@
from copy import deepcopy
from .mock_data import FERTILIZATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA
from .models import FertilizationRecommendationRequest
def _extract_result(response_payload):
if not isinstance(response_payload, dict):
return {}
data = response_payload.get("data")
if isinstance(data, dict) and isinstance(data.get("result"), dict):
return data["result"]
result = response_payload.get("result")
if isinstance(result, dict):
return result
return {}
def _get_latest_result(farm):
if farm is None:
return {}
for request in FertilizationRecommendationRequest.objects.filter(farm=farm):
result = _extract_result(request.response_payload)
if result:
return result
return {}
def get_fertilization_dashboard_recommendation(farm=None):
default_item = deepcopy(FERTILIZATION_DASHBOARD_RECOMMENDATION)
result = _get_latest_result(farm)
plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {})
npk_ratio = plan.get("npkRatio") or "20-20-20 (NPK)"
amount = plan.get("amountPerHectare")
method = plan.get("applicationMethod")
interval = plan.get("applicationInterval")
subtitle_parts = [part for part in [amount, method, interval] if part]
default_item["title"] = f"کود: {npk_ratio}"
if subtitle_parts:
default_item["subtitle"] = "، ".join(subtitle_parts)
return default_item
+14
View File
@@ -28,3 +28,17 @@ RECOMMEND_RESPONSE_DATA = {
"warning": "Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.", "warning": "Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.",
}, },
} }
WATER_NEED_PREDICTION = {
"totalNext7Days": 3290,
"unit": "m3",
"categories": ["روز 1", "روز 2", "روز 3", "روز 4", "روز 5", "روز 6", "روز 7"],
"series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}],
}
IRRIGATION_DASHBOARD_RECOMMENDATION = {
"title": "آبیاری: 05:00 - 07:00",
"subtitle": "4 نوبت در هفته، 45 دقیقه برای هر نوبت. رطوبت هدف 72%.",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
}
+77
View File
@@ -0,0 +1,77 @@
from copy import deepcopy
from .mock_data import IRRIGATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA, WATER_NEED_PREDICTION
from .models import IrrigationRecommendationRequest
def _extract_result(response_payload):
if not isinstance(response_payload, dict):
return {}
data = response_payload.get("data")
if isinstance(data, dict) and isinstance(data.get("result"), dict):
return data["result"]
result = response_payload.get("result")
if isinstance(result, dict):
return result
return {}
def _get_latest_result(farm):
if farm is None:
return {}
for request in IrrigationRecommendationRequest.objects.filter(farm=farm):
result = _extract_result(request.response_payload)
if result:
return result
return {}
def get_water_need_prediction_data(farm=None):
default_data = deepcopy(WATER_NEED_PREDICTION)
result = _get_latest_result(farm)
water_balance = result.get("water_balance", {})
daily = water_balance.get("daily", [])
if not daily:
return default_data
categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)]
series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily]
return {
"totalNext7Days": round(sum(series_data), 2),
"unit": "mm",
"categories": categories,
"series": [{"name": "نیاز آبی", "data": series_data}],
}
def get_irrigation_dashboard_recommendation(farm=None):
default_item = deepcopy(IRRIGATION_DASHBOARD_RECOMMENDATION)
result = _get_latest_result(farm)
plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {})
best_time = plan.get("bestTimeOfDay") or "05:00 - 07:00"
frequency = plan.get("frequencyPerWeek")
duration = plan.get("durationMinutes")
moisture = plan.get("moistureLevel")
warning = plan.get("warning")
subtitle_parts = []
if frequency is not None and duration is not None:
subtitle_parts.append(f"{frequency} نوبت در هفته، {duration} دقیقه برای هر نوبت")
if moisture is not None:
subtitle_parts.append(f"رطوبت هدف {moisture}%")
if warning:
subtitle_parts.append(str(warning))
default_item["title"] = f"آبیاری: {best_time}"
if subtitle_parts:
default_item["subtitle"] = ". ".join(subtitle_parts)
return default_item
+7
View File
@@ -0,0 +1,7 @@
from copy import deepcopy
from .mock_data import RISK_SUMMARY_RESPONSE_DATA
def get_risk_summary_data(farm=None):
return deepcopy(RISK_SUMMARY_RESPONSE_DATA)
-7
View File
@@ -1,7 +0,0 @@
from django.apps import AppConfig
class PlantSimulatorConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "plant_simulator"
verbose_name = "Plant Simulator"
-176
View File
@@ -1,176 +0,0 @@
"""
Static mock data for Plant Simulator API.
Matches PLANT_SIMULATOR_API.md. No database, no dynamic values.
Smooth animation: 51 points (010s, ~5 frames per second).
"""
# ---------------------------------------------------------------------------
# GET /api/plant-simulator/config (ورود: فقط اسلایدرها)
# ---------------------------------------------------------------------------
CONFIG_SLIDERS_ONLY = {
"sliders": [
{
"key": "light",
"label": "نور",
"min": 0,
"max": 100,
"step": 5,
"unit_type": "percent",
"default_value": 75,
"icon": "☀️",
},
{
"key": "water",
"label": "آب",
"min": 0,
"max": 100,
"step": 5,
"unit_type": "percent",
"default_value": 65,
"icon": "💧",
},
{
"key": "soil_ph",
"label": "pH خاک",
"min": 4,
"max": 9,
"step": 0.5,
"unit_type": "number",
"unit": "",
"default_value": 6.5,
},
{
"key": "growth_speed",
"label": "سرعت رشد",
"min": 0.5,
"max": 5,
"step": 0.5,
"unit_type": "number",
"unit": "×",
"default_value": 1.5,
},
],
}
# ---------------------------------------------------------------------------
# POST /api/plant-simulator/start (ثابت‌ها + چارت کانفیگ + plant + progress + chart)
# ---------------------------------------------------------------------------
CONSTANTS = {
"max_height": 280,
"max_leaves": 14,
"max_branches": 6,
"max_yield": 500,
"yield_unit": "g",
"yield_rate_unit": "g/s",
"height_unit": "px",
}
CHART_CONFIG = {
"title": "پیشرفت رشد",
"x_axis_label": "زمان (ثانیه)",
"series": [
{
"key": "height",
"label": "ارتفاع (px)",
"y_axis_id": "yHeight",
"min": 0,
"max": 280,
"unit": "px",
},
{
"key": "leaves",
"label": "تعداد برگ",
"y_axis_id": "yLeaf",
"min": 0,
"max": 14,
},
{
"key": "yield",
"label": "محصول (g)",
"y_axis_id": "yYield",
"min": 0,
"max": 500,
"unit": "g",
},
{
"key": "yield_rate",
"label": "نرخ محصول (g/s)",
"y_axis_id": "yYieldRate",
"min": 0,
"unit": "g/s",
},
],
}
# 51 نقطه برای انیمیشن نرم (۰ تا ۱۰ ثانیه، هر ~۰٫۲s)
_labels = [f"{i * 0.2:.1f}s" for i in range(51)]
_height = [round(142 * (i / 50) ** 0.9) for i in range(51)] # رشد کمی شتاب‌دار
_leaf = [min(5, int(i / 10)) for i in range(51)] # 0,0..,1,1..,2,...,5
_yield = [round(12.4 * (i / 50) ** 1.2, 1) for i in range(51)] # محصول با شتاب ملایم
_yield_rate = [round(0.087 * max(0, (i - 15) / 35), 3) for i in range(51)] # نرخ از ثانیه ~۳
START_RESPONSE_DATA = {
"constants": CONSTANTS,
"chart": CHART_CONFIG,
"plant": {
"height": 142,
"leaves_count": 5,
"branches_count": 2,
"fruits_count": 0,
"yield": 12.4,
"yield_rate": 0.087,
"tick": 520,
"is_healthy": True,
"can_continue": True,
},
"progress": {
"growth_progress": 50,
"light_status": 75,
"water_status": 65,
"yield_progress": 2.5,
"yield_current": 12.4,
"yield_rate_current": 0.087,
},
"chart_history": {
"labels": _labels,
"height_history": _height,
"leaf_history": _leaf,
"yield_history": _yield,
"yield_rate_history": _yield_rate,
},
}
# ---------------------------------------------------------------------------
# GET /api/plant-simulator/state (plant + progress + chart history)
# ---------------------------------------------------------------------------
STATE_RESPONSE_DATA = {
"plant": {
"height": 142,
"leaves_count": 5,
"branches_count": 2,
"fruits_count": 0,
"yield": 12.4,
"yield_rate": 0.087,
"tick": 520,
"is_healthy": True,
"can_continue": True,
},
"progress": {
"growth_progress": 50,
"light_status": 75,
"water_status": 65,
"yield_progress": 2.5,
"yield_current": 12.4,
"yield_rate_current": 0.087,
},
"chart": {
"labels": _labels,
"height_history": _height,
"leaf_history": _leaf,
"yield_history": _yield,
"yield_rate_history": _yield_rate,
},
}
@@ -1,101 +0,0 @@
{
"info": {
"name": "Plant Simulator",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"description": "Plant Simulator API. GET config (sliders only), GET state, POST start (body: environment + growth_speed per serializers), stop/reset/environment. Static mock only."
},
"item": [
{
"name": "Get config (GET)",
"request": {
"method": "GET",
"header": [{"key": "Content-Type", "value": "application/json"}],
"url": "{{baseUrl}}/api/plant-simulator/config/",
"description": "ورود: فقط اسلایدرها. Returns sliders only (key, label, min, max, step, unit_type, default_value, icon)."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"sliders\": [\n {\"key\": \"light\", \"label\": \"نور\", \"min\": 0, \"max\": 100, \"step\": 5, \"unit_type\": \"percent\", \"default_value\": 75, \"icon\": \"☀️\"},\n {\"key\": \"water\", \"label\": \"آب\", \"min\": 0, \"max\": 100, \"step\": 5, \"unit_type\": \"percent\", \"default_value\": 65, \"icon\": \"💧\"},\n {\"key\": \"soil_ph\", \"label\": \"pH خاک\", \"min\": 4, \"max\": 9, \"step\": 0.5, \"unit_type\": \"number\", \"unit\": \"\", \"default_value\": 6.5},\n {\"key\": \"growth_speed\", \"label\": \"سرعت رشد\", \"min\": 0.5, \"max\": 5, \"step\": 0.5, \"unit_type\": \"number\", \"unit\": \"×\", \"default_value\": 1.5}\n ]\n }\n}"
}
]
},
{
"name": "Get state (GET)",
"request": {
"method": "GET",
"header": [{"key": "Content-Type", "value": "application/json"}],
"url": "{{baseUrl}}/api/plant-simulator/state/",
"description": "Returns static plant state, progress, and chart history (plant, progress, chart)."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"plant\": {\"height\": 142, \"leaves_count\": 5, \"branches_count\": 2, \"fruits_count\": 0, \"yield\": 12.4, \"yield_rate\": 0.087, \"tick\": 520, \"is_healthy\": true, \"can_continue\": true},\n \"progress\": {\"growth_progress\": 50, \"light_status\": 75, \"water_status\": 65, \"yield_progress\": 2.5, \"yield_current\": 12.4, \"yield_rate_current\": 0.087},\n \"chart\": {\n \"labels\": [\"0s\", \"1s\", \"2s\", \"3s\", \"4s\", \"5s\", \"6s\", \"7s\", \"8s\", \"9s\", \"10s\"],\n \"height_history\": [0, 5, 12, 28, 45, 68, 92, 110, 125, 135, 142],\n \"leaf_history\": [0, 0, 1, 2, 3, 4, 4, 5, 5, 5, 5],\n \"yield_history\": [0, 0, 0, 0.1, 0.5, 1.2, 3.2, 5.8, 8.2, 10.1, 12.4],\n \"yield_rate_history\": [0, 0, 0, 0.01, 0.03, 0.05, 0.06, 0.07, 0.08, 0.085, 0.087]\n }\n }\n}"
}
]
},
{
"name": "Start simulation (POST)",
"request": {
"method": "POST",
"header": [{"key": "Content-Type", "value": "application/json"}],
"body": {
"mode": "raw",
"raw": "{\n \"environment\": {\n \"light\": 75,\n \"water\": 65,\n \"soil_ph\": 6.5\n },\n \"growth_speed\": 1.5\n}"
},
"url": "{{baseUrl}}/api/plant-simulator/start/",
"description": "Body per serializers.START_REQUEST_EXAMPLE_STATIC: environment (light, water, soil_ph) + growth_speed. Returns constants, chart, plant, progress, chart_history."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"constants\": {\"max_height\": 280, \"max_leaves\": 14, \"max_branches\": 6, \"max_yield\": 500, \"yield_unit\": \"g\", \"yield_rate_unit\": \"g/s\", \"height_unit\": \"px\"},\n \"chart\": {\"title\": \"پیشرفت رشد\", \"x_axis_label\": \"زمان (ثانیه)\", \"series\": [{\"key\": \"height\", \"label\": \"ارتفاع (px)\", \"y_axis_id\": \"yHeight\", \"min\": 0, \"max\": 280, \"unit\": \"px\"}, {\"key\": \"leaves\", \"label\": \"تعداد برگ\", \"y_axis_id\": \"yLeaf\", \"min\": 0, \"max\": 14}, {\"key\": \"yield\", \"label\": \"محصول (g)\", \"y_axis_id\": \"yYield\", \"min\": 0, \"max\": 500, \"unit\": \"g\"}, {\"key\": \"yield_rate\", \"label\": \"نرخ محصول (g/s)\", \"y_axis_id\": \"yYieldRate\", \"min\": 0, \"unit\": \"g/s\"}]},\n \"plant\": {\"height\": 142, \"leaves_count\": 5, \"branches_count\": 2, \"fruits_count\": 0, \"yield\": 12.4, \"yield_rate\": 0.087, \"tick\": 520, \"is_healthy\": true, \"can_continue\": true},\n \"progress\": {\"growth_progress\": 50, \"light_status\": 75, \"water_status\": 65, \"yield_progress\": 2.5, \"yield_current\": 12.4, \"yield_rate_current\": 0.087},\n \"chart_history\": {\"labels\": [\"0.0s\", \"0.2s\", \"0.4s\", \"1.0s\", \"2.0s\", \"5.0s\", \"10.0s\"], \"height_history\": [0, 2, 5, 18, 45, 110, 142], \"leaf_history\": [0, 0, 0, 0, 0, 1, 5], \"yield_history\": [0, 0, 0, 0.2, 1.5, 6.2, 12.4], \"yield_rate_history\": [0, 0, 0, 0.01, 0.03, 0.06, 0.087]}\n }\n}"
}
]
},
{
"name": "Stop simulation (POST)",
"request": {
"method": "POST",
"header": [{"key": "Content-Type", "value": "application/json"}],
"body": {"mode": "raw", "raw": "{}"},
"url": "{{baseUrl}}/api/plant-simulator/stop/",
"description": "Empty body. Returns success only."
},
"response": [{"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"}]
},
{
"name": "Reset simulation (POST)",
"request": {
"method": "POST",
"header": [{"key": "Content-Type", "value": "application/json"}],
"body": {"mode": "raw", "raw": "{}"},
"url": "{{baseUrl}}/api/plant-simulator/reset/",
"description": "Empty body. Returns success only."
},
"response": [{"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"}]
},
{
"name": "Update environment (PATCH)",
"request": {
"method": "PATCH",
"header": [{"key": "Content-Type", "value": "application/json"}],
"body": {
"mode": "raw",
"raw": "{\n \"environment\": {\n \"light\": 80,\n \"water\": 70,\n \"soil_ph\": 6.5\n },\n \"growth_speed\": 2\n}"
},
"url": "{{baseUrl}}/api/plant-simulator/environment/",
"description": "Body: environment (same keys as sliders) + optional growth_speed. Returns success only."
},
"response": [{"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"}]
}
],
"variable": [{"key": "baseUrl", "value": "http://localhost:8000"}]
}
-62
View File
@@ -1,62 +0,0 @@
"""
Response serialization for Plant Simulator API.
Plain Django; no DRF. Builds response envelope only. All payloads are static mock data.
Request: on POST /start the client must send a body with keys matching the sliders
from config (light, water, soil_ph) plus growth_speed. See START_REQUEST_EXAMPLE.
No validation or use of request in response; backend returns static mock only.
"""
from .mock_data import CONFIG_SLIDERS_ONLY
# ---------------------------------------------------------------------------
# POST /start — بدنه‌ای که کلاینت باید ارسال کند (مطابق اسلایدرهای config)
# ---------------------------------------------------------------------------
# کلیدهای environment (همان key هر اسلایدر به‌جز growth_speed)
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 که کلاینت باید ارسال کند
START_REQUEST_EXAMPLE = {
"environment": {
k: v for k, v in _defaults_from_sliders().items() if k != "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():
"""
Response when endpoint does not return data.
Returns: {"status": "success"}
"""
return {"status": "success"}
def success_with_data(data):
"""
Response when endpoint returns data.
Returns: {"status": "success", "data": data}
"""
return {"status": "success", "data": data}
-19
View File
@@ -1,19 +0,0 @@
from django.urls import path
from .views import (
ConfigView,
EnvironmentView,
ResetView,
StartView,
StateView,
StopView,
)
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"),
]
-184
View File
@@ -1,184 +0,0 @@
"""
Plant Simulator API views.
No database. All responses are static mock data.
Response format: {"status": "success"} or {"status": "success", "data": <payload>}. HTTP 200 only.
No processing, validation, or use of input parameters in responses.
"""
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from config.swagger import status_response
from .mock_data import CONFIG_SLIDERS_ONLY, START_RESPONSE_DATA, STATE_RESPONSE_DATA
from .serializers import success_response, success_with_data
class ConfigView(APIView):
"""
GET endpoint for simulator configuration (ورود).
Purpose:
Returns only sliders (min, max, step, unit, label, default_value, icon).
Used when loading/entering the simulator page.
Input parameters:
None. Query parameters, if sent, are not read or used.
Response structure:
- status: string, always "success".
- data: object with key "sliders" (array of slider configs).
No processing or validation is performed on inputs.
"""
@extend_schema(
tags=["Plant Simulator"],
responses={200: status_response("PlantSimulatorConfigResponse", data=serializers.JSONField())},
)
def get(self, request):
return Response(success_with_data(CONFIG_SLIDERS_ONLY), status=status.HTTP_200_OK)
class StateView(APIView):
"""
GET endpoint for plant state, progress, and chart history.
Purpose:
Returns static plant state (height, leaves_count, branches_count,
fruits_count, yield, yield_rate, tick, is_healthy, can_continue),
progress (growth_progress, light_status, water_status, yield_progress,
yield_current, yield_rate_current), and chart (labels, height_history,
leaf_history, yield_history, yield_rate_history). Used during or after
simulation for UI and chart.
Input parameters:
None. Query parameters, if sent, are not read or used.
Response structure:
- status: string, always "success".
- data: object with keys "plant", "progress", "chart" per
PLANT_SIMULATOR_API.md §3.
No processing or validation is performed on inputs.
"""
@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):
"""
POST endpoint to start simulation.
Purpose:
Returns constants, chart config, plant state, progress, and chart
history. Body may contain environment and growth_speed; not read or used.
Input parameters:
- body (optional): JSON. May contain "environment" and "growth_speed".
Location: body. Not read or validated; not used in response.
Response structure:
- status: string, always "success".
- data: object with "constants", "chart", "plant", "progress", "chart_history".
No processing or validation is performed on inputs.
"""
@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):
"""
POST endpoint to stop simulation.
Purpose:
Accepts stop request. Returns success only. No processing performed.
Body may be empty or contain session_id; not read or used.
Input parameters:
- body (optional): JSON or empty. Location: body. Not read or used.
Response structure:
- status: string, always "success".
No "data" field.
No processing or validation is performed on inputs.
"""
@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):
"""
POST endpoint to reset simulation.
Purpose:
Accepts reset request. Returns success only. No processing performed.
Body may be empty or contain session_id; not read or used.
Input parameters:
- body (optional): JSON or empty. Location: body. Not read or used.
Response structure:
- status: string, always "success".
No "data" field.
No processing or validation is performed on inputs.
"""
@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):
"""
PATCH endpoint to update environment (slider values).
Purpose:
Accepts environment update. Returns success only. No processing
performed. Body may contain environment and growth_speed; not
read or used in the response.
Input parameters:
- body (optional): JSON. May contain "environment" (object)
and "growth_speed" (number). Location: body. Not read or used.
Response structure:
- status: string, always "success".
No "data" field.
No processing or validation is performed on inputs.
"""
@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)
+1
View File
@@ -0,0 +1 @@
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class Sensor7In1Config(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "sensor_7_in_1"
verbose_name = "Sensor 7 in 1"
+133
View File
@@ -0,0 +1,133 @@
AVG_SOIL_MOISTURE = {
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "سنسور 7 در 1 خاک",
"stats": "45%",
"avatarColor": "primary",
"avatarIcon": "tabler-droplet",
"chipText": "متوسط",
"chipColor": "warning",
}
SENSOR_VALUES_LIST = {
"sensor": {
"name": "سنسور 7 در 1 خاک",
"physicalDeviceUuid": None,
"sensorCatalogCode": "sensor-7-in-1",
"updatedAt": None,
},
"sensors": [
{
"id": "soil_moisture",
"title": "45%",
"subtitle": "رطوبت خاک",
"trendNumber": 1.5,
"trend": "positive",
"unit": "%",
},
{
"id": "soil_temperature",
"title": "22.5°C",
"subtitle": "دمای خاک",
"trendNumber": 0.8,
"trend": "positive",
"unit": "°C",
},
{
"id": "soil_ph",
"title": "6.8",
"subtitle": "pH خاک",
"trendNumber": 0.1,
"trend": "positive",
"unit": "pH",
},
{
"id": "electrical_conductivity",
"title": "1.2 dS/m",
"subtitle": "هدایت الکتریکی",
"trendNumber": -0.1,
"trend": "negative",
"unit": "dS/m",
},
{
"id": "nitrogen",
"title": "30 mg/kg",
"subtitle": "نیتروژن",
"trendNumber": 2.0,
"trend": "positive",
"unit": "mg/kg",
},
{
"id": "phosphorus",
"title": "15 mg/kg",
"subtitle": "فسفر",
"trendNumber": 1.0,
"trend": "positive",
"unit": "mg/kg",
},
{
"id": "potassium",
"title": "20 mg/kg",
"subtitle": "پتاسیم",
"trendNumber": -1.0,
"trend": "negative",
"unit": "mg/kg",
},
],
}
SENSOR_RADAR_CHART = {
"labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"],
"series": [
{"name": "اکنون", "data": [82, 76, 90, 72, 68, 62, 70]},
{"name": "هدف", "data": [100, 100, 100, 100, 100, 100, 100]},
],
}
SENSOR_COMPARISON_CHART = {
"currentValue": 45,
"vsLastWeek": "+4.7%",
"vsLastWeekValue": 4.7,
"categories": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
"series": [
{"name": "رطوبت خاک", "data": [42, 44, 45, 47, 46, 45, 45]},
{"name": "بازه هدف", "data": [55, 55, 55, 55, 55, 55, 55]},
],
}
ANOMALY_DETECTION_CARD = {
"anomalies": [
{
"sensor": "هدایت الکتریکی",
"value": "1.2 dS/m",
"expected": "0.8-1.1 dS/m",
"deviation": "+0.1 dS/m",
"severity": "warning",
}
]
}
SOIL_MOISTURE_HEATMAP = {
"zones": ["سنسور 7 در 1 خاک"],
"hours": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
"series": [
{
"name": "سنسور 7 در 1 خاک",
"data": [
{"x": "08:00", "y": 42},
{"x": "10:00", "y": 44},
{"x": "12:00", "y": 45},
{"x": "14:00", "y": 47},
{"x": "16:00", "y": 46},
{"x": "18:00", "y": 45},
{"x": "20:00", "y": 45},
],
}
],
}
+4
View File
@@ -0,0 +1,4 @@
"""
This app is service-based and does not define local database models.
"""
+41
View File
@@ -0,0 +1,41 @@
from rest_framework import serializers
from soil.serializers import (
SoilAnomalyDetectionSerializer,
SoilComparisonChartSerializer,
SoilKpiSerializer,
SoilMoistureHeatmapSerializer,
SoilRadarChartSerializer,
)
class Sensor7In1MetaSerializer(serializers.Serializer):
name = serializers.CharField(required=False, allow_blank=True)
physicalDeviceUuid = serializers.CharField(required=False, allow_null=True)
sensorCatalogCode = serializers.CharField(required=False, allow_blank=True)
updatedAt = serializers.CharField(required=False, allow_null=True)
class Sensor7In1ValueSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True)
title = serializers.CharField(required=False, allow_blank=True)
subtitle = serializers.CharField(required=False, allow_blank=True)
trendNumber = serializers.FloatField(required=False)
trend = serializers.CharField(required=False, allow_blank=True)
unit = serializers.CharField(required=False, allow_blank=True)
class Sensor7In1ValuesListSerializer(serializers.Serializer):
sensor = Sensor7In1MetaSerializer(required=False)
sensors = Sensor7In1ValueSerializer(many=True, required=False)
class Sensor7In1SummarySerializer(serializers.Serializer):
sensor = Sensor7In1MetaSerializer(required=False)
sensorValuesList = Sensor7In1ValuesListSerializer(required=False)
avgSoilMoisture = SoilKpiSerializer(required=False)
sensorRadarChart = SoilRadarChartSerializer(required=False)
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
+436
View File
@@ -0,0 +1,436 @@
from copy import deepcopy
from sensor_external_api.services import get_farm_sensor_map_for_logs, get_sensor_external_request_logs_for_farm
from .mock_data import (
ANOMALY_DETECTION_CARD,
AVG_SOIL_MOISTURE,
SENSOR_COMPARISON_CHART,
SENSOR_RADAR_CHART,
SENSOR_VALUES_LIST,
SOIL_MOISTURE_HEATMAP,
)
SENSOR_FIELDS = [
{
"id": "soil_moisture",
"label": "رطوبت خاک",
"unit": "%",
"payload_keys": ("soil_moisture", "soilMoisture", "moisture"),
"ideal_min": 45.0,
"ideal_max": 65.0,
"radar_label": "رطوبت",
},
{
"id": "soil_temperature",
"label": "دمای خاک",
"unit": "°C",
"payload_keys": ("soil_temperature", "soilTemperature", "temperature"),
"ideal_min": 18.0,
"ideal_max": 28.0,
"radar_label": "دما",
},
{
"id": "soil_ph",
"label": "pH خاک",
"unit": "pH",
"payload_keys": ("soil_ph", "soilPh", "ph"),
"ideal_min": 6.0,
"ideal_max": 7.5,
"radar_label": "pH",
},
{
"id": "electrical_conductivity",
"label": "هدایت الکتریکی",
"unit": "dS/m",
"payload_keys": ("electrical_conductivity", "electricalConductivity", "ec"),
"ideal_min": 0.8,
"ideal_max": 1.8,
"radar_label": "EC",
},
{
"id": "nitrogen",
"label": "نیتروژن",
"unit": "mg/kg",
"payload_keys": ("nitrogen", "n"),
"ideal_min": 20.0,
"ideal_max": 40.0,
"radar_label": "نیتروژن",
},
{
"id": "phosphorus",
"label": "فسفر",
"unit": "mg/kg",
"payload_keys": ("phosphorus", "p"),
"ideal_min": 10.0,
"ideal_max": 25.0,
"radar_label": "فسفر",
},
{
"id": "potassium",
"label": "پتاسیم",
"unit": "mg/kg",
"payload_keys": ("potassium", "k"),
"ideal_min": 15.0,
"ideal_max": 35.0,
"radar_label": "پتاسیم",
},
]
MIN_REQUIRED_SENSOR_FIELDS = 4
MAX_HISTORY_ITEMS = 20
MAX_CHART_POINTS = 7
def _to_float(value):
if value is None or isinstance(value, bool):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _extract_payload(payload):
if not isinstance(payload, dict):
return {}
if isinstance(payload.get("payload"), dict):
payload = payload["payload"]
if isinstance(payload.get("data"), dict):
nested = payload["data"]
if any(any(key in nested for key in field["payload_keys"]) for field in SENSOR_FIELDS):
payload = nested
return payload
def _extract_readings(payload):
payload = _extract_payload(payload)
readings = {}
for field in SENSOR_FIELDS:
for key in field["payload_keys"]:
value = _to_float(payload.get(key))
if value is not None:
readings[field["id"]] = value
break
return readings
def _format_number(value):
if value is None:
return ""
if float(value).is_integer():
return str(int(value))
return f"{value:.1f}".rstrip("0").rstrip(".")
def _format_value(value, unit):
number = _format_number(value)
if not number:
return number
if unit in {"", "pH"}:
return number
if unit in {"%", "°C"}:
return f"{number}{unit}"
return f"{number} {unit}"
def _format_range(field):
lower = _format_number(field["ideal_min"])
upper = _format_number(field["ideal_max"])
unit = field["unit"]
if unit in {"", "pH"}:
return f"{lower}-{upper}"
return f"{lower}-{upper} {unit}"
def _get_sensor_context(farm=None):
if farm is None:
return None
try:
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid)
except ValueError:
return None
candidate_log = None
candidate_readings = {}
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
readings = _extract_readings(log.payload)
if len(readings) >= MIN_REQUIRED_SENSOR_FIELDS:
candidate_log = log
candidate_readings = readings
break
if candidate_log is None:
return None
history = []
for log in logs_queryset.filter(physical_device_uuid=candidate_log.physical_device_uuid)[:MAX_HISTORY_ITEMS]:
readings = _extract_readings(log.payload)
if readings:
history.append((log, readings))
if not history:
history = [(candidate_log, candidate_readings)]
farm_sensor_map = get_farm_sensor_map_for_logs(logs=[candidate_log])
farm_sensor = farm_sensor_map.get(
(candidate_log.farm_uuid, candidate_log.sensor_catalog_uuid, candidate_log.physical_device_uuid)
)
return {
"farm_sensor": farm_sensor,
"latest_log": history[0][0],
"latest_readings": history[0][1],
"previous_readings": history[1][1] if len(history) > 1 else {},
"history": history,
}
def _build_sensor_meta(context, fallback_sensor):
sensor = deepcopy(fallback_sensor)
if not context:
return sensor
farm_sensor = context.get("farm_sensor")
latest_log = context["latest_log"]
sensor["physicalDeviceUuid"] = str(latest_log.physical_device_uuid)
sensor["updatedAt"] = latest_log.created_at.isoformat()
if farm_sensor is not None:
sensor["name"] = farm_sensor.name or sensor["name"]
if farm_sensor.sensor_catalog is not None:
sensor["sensorCatalogCode"] = farm_sensor.sensor_catalog.code
return sensor
def _calculate_status_chip(value):
if value is None:
return ("نامشخص", "secondary", "secondary")
if value >= 60:
return ("بهینه", "success", "primary")
if value >= 45:
return ("متوسط", "warning", "warning")
return ("کم", "error", "error")
def get_sensor_7_in_1_values_list_data(farm=None, context=None):
data = deepcopy(SENSOR_VALUES_LIST)
context = _get_sensor_context(farm) if context is None else context
data["sensor"] = _build_sensor_meta(context, data["sensor"])
if not context:
return data
latest_readings = context["latest_readings"]
previous_readings = context["previous_readings"]
sensors = []
for field in SENSOR_FIELDS:
value = latest_readings.get(field["id"])
if value is None:
continue
previous = previous_readings.get(field["id"])
change = 0.0 if previous is None else round(value - previous, 2)
sensors.append(
{
"id": field["id"],
"title": _format_value(value, field["unit"]),
"subtitle": field["label"],
"trendNumber": abs(change),
"trend": "positive" if change >= 0 else "negative",
"unit": field["unit"],
}
)
if sensors:
data["sensors"] = sensors
return data
def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None):
data = deepcopy(AVG_SOIL_MOISTURE)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
moisture = context["latest_readings"].get("soil_moisture")
if moisture is None:
return data
chip_text, chip_color, avatar_color = _calculate_status_chip(moisture)
data["stats"] = _format_value(moisture, "%")
data["chipText"] = chip_text
data["chipColor"] = chip_color
data["avatarColor"] = avatar_color
return data
def _score_field(value, field):
min_value = field["ideal_min"]
max_value = field["ideal_max"]
midpoint = (min_value + max_value) / 2
half_span = max((max_value - min_value) / 2, 0.1)
distance = abs(value - midpoint)
if min_value <= value <= max_value:
return round(max(80.0, 100.0 - ((distance / half_span) * 20.0)), 1)
overflow = max(0.0, distance - half_span)
return round(max(0.0, 80.0 - ((overflow / half_span) * 80.0)), 1)
def get_sensor_7_in_1_radar_chart_data(farm=None, context=None):
data = deepcopy(SENSOR_RADAR_CHART)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
latest_readings = context["latest_readings"]
scores = []
labels = []
for field in SENSOR_FIELDS:
value = latest_readings.get(field["id"])
if value is None:
continue
labels.append(field["radar_label"])
scores.append(_score_field(value, field))
if labels:
data["labels"] = labels
data["series"] = [
{"name": "اکنون", "data": scores},
{"name": "هدف", "data": [100.0] * len(labels)},
]
return data
def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None):
data = deepcopy(SENSOR_COMPARISON_CHART)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
moisture_points = [
(log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture"))
for log, readings in history
if readings.get("soil_moisture") is not None
]
if not moisture_points:
return data
categories = [item[0] for item in moisture_points]
values = [round(item[1], 2) for item in moisture_points]
current_value = values[-1]
baseline_value = values[0] if len(values) > 1 else 55.0
percent_change = 0.0
if baseline_value:
percent_change = ((current_value - baseline_value) / baseline_value) * 100
data["currentValue"] = round(current_value, 2)
data["vsLastWeekValue"] = round(percent_change, 2)
data["vsLastWeek"] = f"{percent_change:+.1f}%"
data["categories"] = categories
data["series"] = [
{"name": "رطوبت خاک", "data": values},
{"name": "بازه هدف", "data": [55.0] * len(values)},
]
return data
def _build_anomaly_item(field, value):
lower = field["ideal_min"]
upper = field["ideal_max"]
if lower <= value <= upper:
return None
deviation = value - upper if value > upper else value - lower
severity = "warning"
span = max(upper - lower, 0.1)
if abs(deviation) >= span * 0.5:
severity = "error"
sign = "+" if deviation > 0 else ""
return {
"sensor": field["label"],
"value": _format_value(value, field["unit"]),
"expected": _format_range(field),
"deviation": f"{sign}{_format_value(deviation, field['unit'])}",
"severity": severity,
}
def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None):
data = deepcopy(ANOMALY_DETECTION_CARD)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
anomalies = []
for field in SENSOR_FIELDS:
value = context["latest_readings"].get(field["id"])
if value is None:
continue
anomaly = _build_anomaly_item(field, value)
if anomaly is not None:
anomalies.append(anomaly)
if anomalies:
data["anomalies"] = anomalies
else:
data["anomalies"] = [
{
"sensor": "سنسور 7 در 1 خاک",
"value": "نرمال",
"expected": "تمام شاخص‌ها در بازه مجاز هستند",
"deviation": "0",
"severity": "success",
}
]
return data
def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None):
data = deepcopy(SOIL_MOISTURE_HEATMAP)
context = _get_sensor_context(farm) if context is None else context
if not context:
return data
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
chart_points = [
{"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)}
for log, readings in history
if readings.get("soil_moisture") is not None
]
if not chart_points:
return data
sensor_name = data["zones"][0]
farm_sensor = context.get("farm_sensor")
if farm_sensor is not None and farm_sensor.name:
sensor_name = farm_sensor.name
data["zones"] = [sensor_name]
data["hours"] = [point["x"] for point in chart_points]
data["series"] = [{"name": sensor_name, "data": chart_points}]
return data
def get_sensor_7_in_1_summary_data(farm=None):
context = _get_sensor_context(farm)
values_list = get_sensor_7_in_1_values_list_data(farm, context=context)
return {
"sensor": values_list["sensor"],
"sensorValuesList": values_list,
"avgSoilMoisture": get_sensor_7_in_1_avg_soil_moisture_data(farm, context=context),
"sensorRadarChart": get_sensor_7_in_1_radar_chart_data(farm, context=context),
"sensorComparisonChart": get_sensor_7_in_1_comparison_chart_data(farm, context=context),
"anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context),
"soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context),
}
+120
View File
@@ -0,0 +1,120 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from farm_hub.models import FarmHub, FarmSensor, FarmType
from sensor_catalog.models import SensorCatalog
from sensor_external_api.models import SensorExternalRequestLog
from dashboard.services import get_farm_dashboard_cards
from .services import get_sensor_7_in_1_summary_data
from .views import Sensor7In1SummaryView
class Sensor7In1BaseTestCase(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="sensor-7-in-1-user",
password="secret123",
email="sensor7@example.com",
phone_number="09120000017",
)
self.farm_type = FarmType.objects.create(name="مزرعه سنسور 7 در 1")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Farm Sensor 7 in 1",
farm_uuid="11111111-1111-1111-1111-111111111111",
)
self.sensor_catalog = SensorCatalog.objects.create(
code="sensor-7-in-1",
name="7 in 1 Soil Sensor",
returned_data_fields=[
"soil_moisture",
"soil_temperature",
"soil_ph",
"electrical_conductivity",
"nitrogen",
"phosphorus",
"potassium",
],
)
self.sensor = FarmSensor.objects.create(
farm=self.farm,
sensor_catalog=self.sensor_catalog,
physical_device_uuid="33333333-3333-3333-3333-333333333333",
name="Soil Sensor 7-in-1",
sensor_type="soil_7_in_1",
)
SensorExternalRequestLog.objects.create(
farm_uuid=self.farm.farm_uuid,
sensor_catalog_uuid=self.sensor_catalog.uuid,
physical_device_uuid=self.sensor.physical_device_uuid,
payload={
"soil_moisture": 41.0,
"soil_temperature": 21.0,
"soil_ph": 6.5,
"electrical_conductivity": 1.0,
"nitrogen": 28.0,
"phosphorus": 14.0,
"potassium": 19.0,
},
)
self.latest_log = SensorExternalRequestLog.objects.create(
farm_uuid=self.farm.farm_uuid,
sensor_catalog_uuid=self.sensor_catalog.uuid,
physical_device_uuid=self.sensor.physical_device_uuid,
payload={
"soil_moisture": 48.5,
"soil_temperature": 23.2,
"soil_ph": 6.8,
"electrical_conductivity": 1.4,
"nitrogen": 31.0,
"phosphorus": 16.0,
"potassium": 24.0,
},
)
class Sensor7In1ServiceTests(Sensor7In1BaseTestCase):
def test_summary_returns_latest_specific_sensor_data(self):
data = get_sensor_7_in_1_summary_data(self.farm)
self.assertEqual(data["sensor"]["name"], "Soil Sensor 7-in-1")
self.assertEqual(data["sensor"]["physicalDeviceUuid"], str(self.sensor.physical_device_uuid))
self.assertEqual(data["sensorValuesList"]["sensors"][0]["id"], "soil_moisture")
self.assertEqual(data["avgSoilMoisture"]["stats"], "48.5%")
self.assertEqual(data["sensorComparisonChart"]["currentValue"], 48.5)
self.assertEqual(data["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1")
def test_dashboard_cards_use_sensor_service_outputs(self):
cards = get_farm_dashboard_cards(self.farm)
self.assertEqual(cards["sensorValuesList"]["sensor"]["physicalDeviceUuid"], str(self.sensor.physical_device_uuid))
self.assertEqual(cards["sensorValuesList"]["sensors"][0]["title"], "48.5%")
self.assertEqual(cards["sensorRadarChart"]["series"][0]["name"], "اکنون")
self.assertEqual(cards["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1")
self.assertEqual(cards["farmOverviewKpis"]["kpis"][2]["stats"], "48.5%")
class Sensor7In1ViewTests(Sensor7In1BaseTestCase):
def test_summary_view_returns_sensor_cards(self):
request = self.factory.get(f"/api/sensor-7-in-1/summary/?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = Sensor7In1SummaryView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["sensor"]["sensorCatalogCode"], "sensor-7-in-1")
def test_summary_view_requires_farm_uuid(self):
request = self.factory.get("/api/sensor-7-in-1/summary/")
force_authenticate(request, user=self.user)
response = Sensor7In1SummaryView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import Sensor7In1SummaryView
urlpatterns = [
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
]
+48
View File
@@ -0,0 +1,48 @@
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import code_response
from farm_hub.models import FarmHub
from .serializers import Sensor7In1SummarySerializer
from .services import get_sensor_7_in_1_summary_data
class Sensor7In1SummaryView(APIView):
permission_classes = [IsAuthenticated]
required_feature_code = "sensor-7-in-1"
@staticmethod
def _get_farm(request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
try:
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
except FarmHub.DoesNotExist as exc:
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
@extend_schema(
tags=["Sensor 7 in 1"],
parameters=[
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=True,
default="11111111-1111-1111-1111-111111111111",
)
],
responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())},
)
def get(self, request):
farm = self._get_farm(request)
return Response(
{"code": 200, "msg": "OK", "data": get_sensor_7_in_1_summary_data(farm)},
status=status.HTTP_200_OK,
)
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class SoilConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "soil"
verbose_name = "Soil"
+83
View File
@@ -0,0 +1,83 @@
AVG_SOIL_MOISTURE = {
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "کل مزرعه",
"stats": "65%",
"avatarColor": "primary",
"avatarIcon": "tabler-plant-2",
"chipText": "بهینه",
"chipColor": "success",
}
SENSOR_RADAR_CHART = {
"labels": ["دما", "رطوبت", "pH", "هدایت الکتریکی", "نور", "باد"],
"series": [
{"name": "امروز", "data": [75, 65, 80, 70, 85, 60]},
{"name": "ایده آل", "data": [80, 70, 75, 75, 90, 50]},
],
}
SENSOR_COMPARISON_CHART = {
"currentValue": 48,
"vsLastWeek": "+5%",
"vsLastWeekValue": 5,
"categories": ["دوشنبه", "سه شنبه", "چهارشنبه", "پنج شنبه", "جمعه", "شنبه", "یکشنبه"],
"series": [
{"name": "امروز", "data": [42, 45, 48, 52, 50, 48, 46]},
{"name": "هفته قبل", "data": [38, 40, 42, 45, 43, 40, 38]},
],
}
ANOMALY_DETECTION_CARD = {
"anomalies": [
{
"sensor": "رطوبت خاک زون 3",
"value": "38%",
"expected": "45-65%",
"deviation": "-12%",
"severity": "warning",
},
{
"sensor": "pH بخش 2",
"value": "5.2",
"expected": "6.0-7.0",
"deviation": "-0.8",
"severity": "error",
},
]
}
SOIL_MOISTURE_HEATMAP = {
"zones": ["زون 1", "زون 2", "زون 3", "زون 4", "زون 5", "زون 6", "زون 7"],
"hours": ["6 ص", "8 ص", "10 ص", "12 ظ", "14 ع", "16 ع", "18 ع"],
"series": [
{
"name": "زون 1",
"data": [
{"x": "6 ص", "y": 52},
{"x": "8 ص", "y": 48},
{"x": "10 ص", "y": 55},
{"x": "12 ظ", "y": 60},
{"x": "14 ع", "y": 58},
{"x": "16 ع", "y": 54},
{"x": "18 ع", "y": 50},
],
},
{
"name": "زون 2",
"data": [
{"x": "6 ص", "y": 45},
{"x": "8 ص", "y": 42},
{"x": "10 ص", "y": 48},
{"x": "12 ظ", "y": 52},
{"x": "14 ع", "y": 50},
{"x": "16 ع", "y": 47},
{"x": "18 ع", "y": 44},
],
},
],
}
+1
View File
@@ -0,0 +1 @@
from django.db import models
+66
View File
@@ -0,0 +1,66 @@
from rest_framework import serializers
class SoilKpiSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True)
title = serializers.CharField(required=False, allow_blank=True)
subtitle = serializers.CharField(required=False, allow_blank=True)
stats = serializers.CharField(required=False, allow_blank=True)
avatarColor = serializers.CharField(required=False, allow_blank=True)
avatarIcon = serializers.CharField(required=False, allow_blank=True)
chipText = serializers.CharField(required=False, allow_blank=True)
chipColor = serializers.CharField(required=False, allow_blank=True)
class SoilRadarSeriesSerializer(serializers.Serializer):
name = serializers.CharField(required=False, allow_blank=True)
data = serializers.ListField(child=serializers.FloatField(), required=False)
class SoilRadarChartSerializer(serializers.Serializer):
labels = serializers.ListField(child=serializers.CharField(), required=False)
series = SoilRadarSeriesSerializer(many=True, required=False)
class SoilComparisonChartSerializer(serializers.Serializer):
currentValue = serializers.FloatField(required=False)
vsLastWeek = serializers.CharField(required=False, allow_blank=True)
vsLastWeekValue = serializers.FloatField(required=False)
categories = serializers.ListField(child=serializers.CharField(), required=False)
series = SoilRadarSeriesSerializer(many=True, required=False)
class SoilAnomalyItemSerializer(serializers.Serializer):
sensor = serializers.CharField(required=False, allow_blank=True)
value = serializers.CharField(required=False, allow_blank=True)
expected = serializers.CharField(required=False, allow_blank=True)
deviation = serializers.CharField(required=False, allow_blank=True)
severity = serializers.CharField(required=False, allow_blank=True)
class SoilAnomalyDetectionSerializer(serializers.Serializer):
anomalies = SoilAnomalyItemSerializer(many=True, required=False)
class SoilHeatmapPointSerializer(serializers.Serializer):
x = serializers.CharField(required=False, allow_blank=True)
y = serializers.FloatField(required=False)
class SoilHeatmapSeriesSerializer(serializers.Serializer):
name = serializers.CharField(required=False, allow_blank=True)
data = SoilHeatmapPointSerializer(many=True, required=False)
class SoilMoistureHeatmapSerializer(serializers.Serializer):
zones = serializers.ListField(child=serializers.CharField(), required=False)
hours = serializers.ListField(child=serializers.CharField(), required=False)
series = SoilHeatmapSeriesSerializer(many=True, required=False)
class SoilSummarySerializer(serializers.Serializer):
avgSoilMoisture = SoilKpiSerializer(required=False)
sensorRadarChart = SoilRadarChartSerializer(required=False)
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
+84
View File
@@ -0,0 +1,84 @@
from copy import deepcopy
from farm_alerts.models import AnomalyDetection
from .mock_data import (
ANOMALY_DETECTION_CARD,
AVG_SOIL_MOISTURE,
SENSOR_COMPARISON_CHART,
SENSOR_RADAR_CHART,
SOIL_MOISTURE_HEATMAP,
)
def get_avg_soil_moisture_data(farm=None):
data = deepcopy(AVG_SOIL_MOISTURE)
heatmap = get_soil_moisture_heatmap_data(farm)
values = [
point.get("y")
for series in heatmap.get("series", [])
for point in series.get("data", [])
if point.get("y") is not None
]
if not values:
return data
average = round(sum(values) / len(values))
data["stats"] = f"{average}%"
if average >= 60:
data["chipText"] = "بهینه"
data["chipColor"] = "success"
elif average >= 45:
data["chipText"] = "متوسط"
data["chipColor"] = "warning"
else:
data["chipText"] = "کم"
data["chipColor"] = "error"
data["avatarColor"] = "warning"
return data
def get_sensor_radar_chart_data(farm=None):
return deepcopy(SENSOR_RADAR_CHART)
def get_sensor_comparison_chart_data(farm=None):
return deepcopy(SENSOR_COMPARISON_CHART)
def get_anomaly_detection_card_data(farm=None):
if farm is None:
return deepcopy(ANOMALY_DETECTION_CARD)
anomalies = list(AnomalyDetection.objects.filter(farm=farm)[:10])
if not anomalies:
return deepcopy(ANOMALY_DETECTION_CARD)
return {
"anomalies": [
{
"sensor": anomaly.sensor,
"value": anomaly.value,
"expected": anomaly.expected,
"deviation": anomaly.deviation,
"severity": anomaly.severity,
}
for anomaly in anomalies
]
}
def get_soil_moisture_heatmap_data(farm=None):
return deepcopy(SOIL_MOISTURE_HEATMAP)
def get_soil_summary_data(farm=None):
return {
"avgSoilMoisture": get_avg_soil_moisture_data(farm),
"sensorRadarChart": get_sensor_radar_chart_data(farm),
"sensorComparisonChart": get_sensor_comparison_chart_data(farm),
"anomalyDetectionCard": get_anomaly_detection_card_data(farm),
"soilMoistureHeatmap": get_soil_moisture_heatmap_data(farm),
}
+19
View File
@@ -0,0 +1,19 @@
from django.urls import path
from .views import (
AvgSoilMoistureView,
SensorComparisonChartView,
SensorRadarChartView,
SoilAnomalyDetectionView,
SoilMoistureHeatmapView,
SoilSummaryView,
)
urlpatterns = [
path("avg-moisture/", AvgSoilMoistureView.as_view(), name="soil-avg-moisture"),
path("sensor-radar-chart/", SensorRadarChartView.as_view(), name="soil-sensor-radar-chart"),
path("sensor-comparison-chart/", SensorComparisonChartView.as_view(), name="soil-sensor-comparison-chart"),
path("anomalies/", SoilAnomalyDetectionView.as_view(), name="soil-anomalies"),
path("moisture-heatmap/", SoilMoistureHeatmapView.as_view(), name="soil-moisture-heatmap"),
path("summary/", SoilSummaryView.as_view(), name="soil-summary"),
]
+132
View File
@@ -0,0 +1,132 @@
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from farm_hub.models import FarmHub
from .serializers import (
SoilAnomalyDetectionSerializer,
SoilComparisonChartSerializer,
SoilKpiSerializer,
SoilMoistureHeatmapSerializer,
SoilRadarChartSerializer,
SoilSummarySerializer,
)
from .services import (
get_anomaly_detection_card_data,
get_avg_soil_moisture_data,
get_sensor_comparison_chart_data,
get_sensor_radar_chart_data,
get_soil_moisture_heatmap_data,
get_soil_summary_data,
)
def _get_farm_from_request(request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
return None
try:
return FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception):
return None
class AvgSoilMoistureView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=False,
description="UUID of the farm for average soil moisture.",
default="11111111-1111-1111-1111-111111111111",
),
],
responses={200: status_response("AvgSoilMoistureResponse", data=SoilKpiSerializer())},
)
def get(self, request):
return Response(
{"status": "success", "data": get_avg_soil_moisture_data(_get_farm_from_request(request))},
status=status.HTTP_200_OK,
)
class SensorRadarChartView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
],
responses={200: status_response("SensorRadarChartResponse", data=SoilRadarChartSerializer())},
)
def get(self, request):
return Response(
{"status": "success", "data": get_sensor_radar_chart_data(_get_farm_from_request(request))},
status=status.HTTP_200_OK,
)
class SensorComparisonChartView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
],
responses={200: status_response("SensorComparisonChartResponse", data=SoilComparisonChartSerializer())},
)
def get(self, request):
return Response(
{"status": "success", "data": get_sensor_comparison_chart_data(_get_farm_from_request(request))},
status=status.HTTP_200_OK,
)
class SoilAnomalyDetectionView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
],
responses={200: status_response("SoilAnomalyDetectionResponse", data=SoilAnomalyDetectionSerializer())},
)
def get(self, request):
return Response(
{"status": "success", "data": get_anomaly_detection_card_data(_get_farm_from_request(request))},
status=status.HTTP_200_OK,
)
class SoilMoistureHeatmapView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
],
responses={200: status_response("SoilMoistureHeatmapResponse", data=SoilMoistureHeatmapSerializer())},
)
def get(self, request):
return Response(
{"status": "success", "data": get_soil_moisture_heatmap_data(_get_farm_from_request(request))},
status=status.HTTP_200_OK,
)
class SoilSummaryView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
],
responses={200: status_response("SoilSummaryResponse", data=SoilSummarySerializer())},
)
def get(self, request):
return Response(
{"status": "success", "data": get_soil_summary_data(_get_farm_from_request(request))},
status=status.HTTP_200_OK,
)
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class WaterConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "water"
label = "weather_forecast"
verbose_name = "water"
+34
View File
@@ -0,0 +1,34 @@
"""
Static mock data for WATER API.
"""
FARM_WEATHER_CARD = {
"condition": "صاف",
"temperature": 24,
"unit": "°C",
"humidity": 45,
"windSpeed": 12,
"windUnit": "km/h",
"chartData": {
"labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"],
"series": [[18, 22, 26, 28, 25, 20, 18]],
},
}
WATER_NEED_PREDICTION = {
"totalNext7Days": 3290,
"unit": "m3",
"categories": ["روز 1", "روز 2", "روز 3", "روز 4", "روز 5", "روز 6", "روز 7"],
"series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}],
}
WATER_STRESS_INDEX = {
"id": "water_stress_index",
"title": "شاخص تنش آبی",
"subtitle": "فعلی",
"stats": "12%",
"avatarColor": "info",
"avatarIcon": "tabler-droplet",
"chipText": "پایین",
"chipColor": "success",
}
+48
View File
@@ -0,0 +1,48 @@
from rest_framework import serializers
class WeatherChartDataSerializer(serializers.Serializer):
labels = serializers.ListField(child=serializers.CharField(), required=False)
series = serializers.ListField(
child=serializers.ListField(child=serializers.FloatField()),
required=False,
)
class FarmWeatherCardSerializer(serializers.Serializer):
condition = serializers.CharField(required=False, allow_blank=True)
temperature = serializers.FloatField(required=False)
unit = serializers.CharField(required=False, allow_blank=True)
humidity = serializers.IntegerField(required=False)
windSpeed = serializers.FloatField(required=False)
windUnit = serializers.CharField(required=False, allow_blank=True)
chartData = WeatherChartDataSerializer(required=False)
class WaterNeedSeriesSerializer(serializers.Serializer):
name = serializers.CharField(required=False, allow_blank=True)
data = serializers.ListField(child=serializers.FloatField(), required=False)
class WaterNeedPredictionSerializer(serializers.Serializer):
totalNext7Days = serializers.FloatField(required=False)
unit = serializers.CharField(required=False, allow_blank=True)
categories = serializers.ListField(child=serializers.CharField(), required=False)
series = WaterNeedSeriesSerializer(many=True, required=False)
class WaterStressIndexSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True)
title = serializers.CharField(required=False, allow_blank=True)
subtitle = serializers.CharField(required=False, allow_blank=True)
stats = serializers.CharField(required=False, allow_blank=True)
avatarColor = serializers.CharField(required=False, allow_blank=True)
avatarIcon = serializers.CharField(required=False, allow_blank=True)
chipText = serializers.CharField(required=False, allow_blank=True)
chipColor = serializers.CharField(required=False, allow_blank=True)
class WaterSummarySerializer(serializers.Serializer):
farmWeatherCard = FarmWeatherCardSerializer(required=False)
waterNeedPrediction = WaterNeedPredictionSerializer(required=False)
waterStressIndex = WaterStressIndexSerializer(required=False)
+106
View File
@@ -0,0 +1,106 @@
from copy import deepcopy
from irrigation_recommendation.models import IrrigationRecommendationRequest
from .mock_data import FARM_WEATHER_CARD, WATER_NEED_PREDICTION, WATER_STRESS_INDEX
from .models import WeatherForecastLog
def get_farm_weather_card_data(farm=None):
if farm is None:
return deepcopy(FARM_WEATHER_CARD)
log = WeatherForecastLog.objects.filter(farm=farm).first()
if log is None:
return deepcopy(FARM_WEATHER_CARD)
return {
"condition": log.condition or FARM_WEATHER_CARD["condition"],
"temperature": log.temperature if log.temperature is not None else FARM_WEATHER_CARD["temperature"],
"unit": log.unit or FARM_WEATHER_CARD["unit"],
"humidity": log.humidity if log.humidity is not None else FARM_WEATHER_CARD["humidity"],
"windSpeed": log.wind_speed if log.wind_speed is not None else FARM_WEATHER_CARD["windSpeed"],
"windUnit": log.wind_unit or FARM_WEATHER_CARD["windUnit"],
"chartData": deepcopy(log.chart_data or FARM_WEATHER_CARD["chartData"]),
}
def _extract_irrigation_result(response_payload):
if not isinstance(response_payload, dict):
return {}
data = response_payload.get("data")
if isinstance(data, dict) and isinstance(data.get("result"), dict):
return data["result"]
result = response_payload.get("result")
if isinstance(result, dict):
return result
return {}
def _get_latest_irrigation_result(farm):
if farm is None:
return {}
for request in IrrigationRecommendationRequest.objects.filter(farm=farm):
result = _extract_irrigation_result(request.response_payload)
if result:
return result
return {}
def get_water_need_prediction_data(farm=None):
default_data = deepcopy(WATER_NEED_PREDICTION)
result = _get_latest_irrigation_result(farm)
water_balance = result.get("water_balance", {})
daily = water_balance.get("daily", [])
if not daily:
return default_data
categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)]
series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily]
return {
"totalNext7Days": round(sum(series_data), 2),
"unit": "mm",
"categories": categories,
"series": [{"name": "نیاز آبی", "data": series_data}],
}
def get_water_stress_index_data(farm=None):
data = deepcopy(WATER_STRESS_INDEX)
result = _get_latest_irrigation_result(farm)
moisture_level = (result.get("plan") or {}).get("moistureLevel")
if moisture_level is None:
return data
stress_value = max(0, round(80 - float(moisture_level)))
if stress_value <= 15:
data["chipText"] = "پایین"
data["chipColor"] = "success"
data["avatarColor"] = "info"
elif stress_value <= 30:
data["chipText"] = "متوسط"
data["chipColor"] = "warning"
data["avatarColor"] = "warning"
else:
data["chipText"] = "بالا"
data["chipColor"] = "error"
data["avatarColor"] = "error"
data["stats"] = f"{stress_value}%"
return data
def get_water_summary_data(farm=None):
return {
"farmWeatherCard": get_farm_weather_card_data(farm),
"waterNeedPrediction": get_water_need_prediction_data(farm),
"waterStressIndex": get_water_stress_index_data(farm),
}
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .views import FarmWeatherCardView, WaterNeedPredictionView, WaterStressIndexView, WaterSummaryView
urlpatterns = [
path("card/", FarmWeatherCardView.as_view(), name="water-card"),
path("need-prediction/", WaterNeedPredictionView.as_view(), name="water-need-prediction"),
path("stress-index/", WaterStressIndexView.as_view(), name="water-stress-index"),
path("summary/", WaterSummaryView.as_view(), name="water-summary"),
]
+180
View File
@@ -0,0 +1,180 @@
"""
WATER API views.
"""
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from .models import WeatherForecastLog
from .serializers import FarmWeatherCardSerializer, WaterNeedPredictionSerializer, WaterStressIndexSerializer, WaterSummarySerializer
from .services import get_water_need_prediction_data, get_water_stress_index_data, get_water_summary_data
class FarmWeatherCardView(APIView):
"""
GET endpoint for the farm weather card dashboard data.
Purpose:
Returns current weather conditions and an intraday temperature chart
for a given farm. Data is fetched from the AI external adapter.
If farm_uuid is provided and the farm exists, the result is persisted
in WeatherForecastLog for historical reference.
Input parameters:
- farm_uuid (query, optional): UUID of the farm.
Response structure:
- status: string, always "success".
- data: object matching the farmWeatherCard shape — condition,
temperature, unit, humidity, windSpeed, windUnit, chartData.
"""
@extend_schema(
tags=["WATER"],
parameters=[
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=False,
description="UUID of the farm to fetch weather data for.",
default="11111111-1111-1111-1111-111111111111"),
],
responses={200: status_response("FarmWeatherCardResponse", data=FarmWeatherCardSerializer())},
)
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {}
adapter_response = external_api_request(
"ai",
"/weather-forecast/card",
method="GET",
query=query,
)
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
card_data = response_data.get("result", response_data.get("data", response_data))
self._persist_log(farm_uuid, card_data)
return Response(
{"status": "success", "data": card_data},
status=status.HTTP_200_OK,
)
@staticmethod
def _persist_log(farm_uuid, card_data):
farm = None
if farm_uuid:
try:
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception):
pass
WeatherForecastLog.objects.create(
farm=farm,
condition=card_data.get("condition", ""),
temperature=card_data.get("temperature"),
unit=card_data.get("unit", "°C"),
humidity=card_data.get("humidity"),
wind_speed=card_data.get("windSpeed"),
wind_unit=card_data.get("windUnit", "km/h"),
chart_data=card_data.get("chartData", {}),
)
class WaterNeedPredictionView(APIView):
@extend_schema(
tags=["WATER"],
parameters=[
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=False,
description="UUID of the farm to fetch water need prediction for.",
default="11111111-1111-1111-1111-111111111111",
),
],
responses={200: status_response("WaterNeedPredictionResponse", data=WaterNeedPredictionSerializer())},
)
def get(self, request):
farm = None
farm_uuid = request.query_params.get("farm_uuid")
if farm_uuid:
try:
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception):
farm = None
return Response(
{"status": "success", "data": get_water_need_prediction_data(farm)},
status=status.HTTP_200_OK,
)
class WaterStressIndexView(APIView):
@extend_schema(
tags=["WATER"],
parameters=[
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=False,
description="UUID of the farm to fetch water stress index for.",
default="11111111-1111-1111-1111-111111111111",
),
],
responses={200: status_response("WaterStressIndexResponse", data=WaterStressIndexSerializer())},
)
def get(self, request):
farm = None
farm_uuid = request.query_params.get("farm_uuid")
if farm_uuid:
try:
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception):
farm = None
return Response(
{"status": "success", "data": get_water_stress_index_data(farm)},
status=status.HTTP_200_OK,
)
class WaterSummaryView(APIView):
@extend_schema(
tags=["WATER"],
parameters=[
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=False,
description="UUID of the farm to fetch water summary for.",
default="11111111-1111-1111-1111-111111111111",
),
],
responses={200: status_response("WaterSummaryResponse", data=WaterSummarySerializer())},
)
def get(self, request):
farm = None
farm_uuid = request.query_params.get("farm_uuid")
if farm_uuid:
try:
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception):
farm = None
return Response(
{"status": "success", "data": get_water_summary_data(farm)},
status=status.HTTP_200_OK,
)
-7
View File
@@ -1,7 +0,0 @@
from django.apps import AppConfig
class WeatherForecastConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "weather_forecast"
verbose_name = "Weather Forecast"
-17
View File
@@ -1,17 +0,0 @@
"""
Static mock data for Weather Forecast API.
Mirrors the farmWeatherCard dashboard card shape.
"""
FARM_WEATHER_CARD = {
"condition": "صاف",
"temperature": 24,
"unit": "°C",
"humidity": 45,
"windSpeed": 12,
"windUnit": "km/h",
"chartData": {
"labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"],
"series": [[18, 22, 26, 28, 25, 20, 18]],
},
}
-19
View File
@@ -1,19 +0,0 @@
from rest_framework import serializers
class WeatherChartDataSerializer(serializers.Serializer):
labels = serializers.ListField(child=serializers.CharField(), required=False)
series = serializers.ListField(
child=serializers.ListField(child=serializers.FloatField()),
required=False,
)
class FarmWeatherCardSerializer(serializers.Serializer):
condition = serializers.CharField(required=False, allow_blank=True)
temperature = serializers.FloatField(required=False)
unit = serializers.CharField(required=False, allow_blank=True)
humidity = serializers.IntegerField(required=False)
windSpeed = serializers.FloatField(required=False)
windUnit = serializers.CharField(required=False, allow_blank=True)
chartData = WeatherChartDataSerializer(required=False)
-7
View File
@@ -1,7 +0,0 @@
from django.urls import path
from .views import FarmWeatherCardView
urlpatterns = [
path("card/", FarmWeatherCardView.as_view(), name="weather-forecast-card"),
]
-92
View File
@@ -1,92 +0,0 @@
"""
Weather Forecast API views.
Response format: {"status": "success", "data": <payload>}. HTTP 200 only.
Fetches weather card data from the AI external adapter and persists a log entry
if a valid farm_uuid is provided.
"""
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from .models import WeatherForecastLog
from .serializers import FarmWeatherCardSerializer
class FarmWeatherCardView(APIView):
"""
GET endpoint for the farm weather card dashboard data.
Purpose:
Returns current weather conditions and an intraday temperature chart
for a given farm. Data is fetched from the AI external adapter.
If farm_uuid is provided and the farm exists, the result is persisted
in WeatherForecastLog for historical reference.
Input parameters:
- farm_uuid (query, optional): UUID of the farm.
Response structure:
- status: string, always "success".
- data: object matching the farmWeatherCard shape — condition,
temperature, unit, humidity, windSpeed, windUnit, chartData.
"""
@extend_schema(
tags=["Weather Forecast"],
parameters=[
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=False,
description="UUID of the farm to fetch weather data for.",
default="11111111-1111-1111-1111-111111111111"),
],
responses={200: status_response("FarmWeatherCardResponse", data=FarmWeatherCardSerializer())},
)
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {}
adapter_response = external_api_request(
"ai",
"/weather-forecast/card",
method="GET",
query=query,
)
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
card_data = response_data.get("result", response_data.get("data", response_data))
self._persist_log(farm_uuid, card_data)
return Response(
{"status": "success", "data": card_data},
status=status.HTTP_200_OK,
)
@staticmethod
def _persist_log(farm_uuid, card_data):
farm = None
if farm_uuid:
try:
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception):
pass
WeatherForecastLog.objects.create(
farm=farm,
condition=card_data.get("condition", ""),
temperature=card_data.get("temperature"),
unit=card_data.get("unit", "°C"),
humidity=card_data.get("humidity"),
wind_speed=card_data.get("windSpeed"),
wind_unit=card_data.get("windUnit", "km/h"),
chart_data=card_data.get("chartData", {}),
)
+158
View File
@@ -3,6 +3,164 @@ Static mock data for Yield & Harvest Prediction API.
Mirrors the yieldPredictionChart and harvestPredictionCard dashboard card shapes. Mirrors the yieldPredictionChart and harvestPredictionCard dashboard card shapes.
""" """
CONFIG_SLIDERS_ONLY = {
"sliders": [
{
"key": "light",
"label": "نور",
"min": 0,
"max": 100,
"step": 5,
"unit_type": "percent",
"default_value": 75,
"icon": "☀️",
},
{
"key": "water",
"label": "آب",
"min": 0,
"max": 100,
"step": 5,
"unit_type": "percent",
"default_value": 65,
"icon": "💧",
},
{
"key": "soil_ph",
"label": "pH خاک",
"min": 4,
"max": 9,
"step": 0.5,
"unit_type": "number",
"unit": "",
"default_value": 6.5,
},
{
"key": "growth_speed",
"label": "سرعت رشد",
"min": 0.5,
"max": 5,
"step": 0.5,
"unit_type": "number",
"unit": "x",
"default_value": 1.5,
},
],
}
CONSTANTS = {
"max_height": 280,
"max_leaves": 14,
"max_branches": 6,
"max_yield": 500,
"yield_unit": "g",
"yield_rate_unit": "g/s",
"height_unit": "px",
}
CHART_CONFIG = {
"title": "پیشرفت رشد",
"x_axis_label": "زمان (ثانیه)",
"series": [
{
"key": "height",
"label": "ارتفاع (px)",
"y_axis_id": "yHeight",
"min": 0,
"max": 280,
"unit": "px",
},
{
"key": "leaves",
"label": "تعداد برگ",
"y_axis_id": "yLeaf",
"min": 0,
"max": 14,
},
{
"key": "yield",
"label": "محصول (g)",
"y_axis_id": "yYield",
"min": 0,
"max": 500,
"unit": "g",
},
{
"key": "yield_rate",
"label": "نرخ محصول (g/s)",
"y_axis_id": "yYieldRate",
"min": 0,
"unit": "g/s",
},
],
}
_labels = [f"{i * 0.2:.1f}s" for i in range(51)]
_height = [round(142 * (i / 50) ** 0.9) for i in range(51)]
_leaf = [min(5, int(i / 10)) for i in range(51)]
_yield = [round(12.4 * (i / 50) ** 1.2, 1) for i in range(51)]
_yield_rate = [round(0.087 * max(0, (i - 15) / 35), 3) for i in range(51)]
START_RESPONSE_DATA = {
"constants": CONSTANTS,
"chart": CHART_CONFIG,
"plant": {
"height": 142,
"leaves_count": 5,
"branches_count": 2,
"fruits_count": 0,
"yield": 12.4,
"yield_rate": 0.087,
"tick": 520,
"is_healthy": True,
"can_continue": True,
},
"progress": {
"growth_progress": 50,
"light_status": 75,
"water_status": 65,
"yield_progress": 2.5,
"yield_current": 12.4,
"yield_rate_current": 0.087,
},
"chart_history": {
"labels": _labels,
"height_history": _height,
"leaf_history": _leaf,
"yield_history": _yield,
"yield_rate_history": _yield_rate,
},
}
STATE_RESPONSE_DATA = {
"plant": {
"height": 142,
"leaves_count": 5,
"branches_count": 2,
"fruits_count": 0,
"yield": 12.4,
"yield_rate": 0.087,
"tick": 520,
"is_healthy": True,
"can_continue": True,
},
"progress": {
"growth_progress": 50,
"light_status": 75,
"water_status": 65,
"yield_progress": 2.5,
"yield_current": 12.4,
"yield_rate_current": 0.087,
},
"chart": {
"labels": _labels,
"height_history": _height,
"leaf_history": _leaf,
"yield_history": _yield,
"yield_rate_history": _yield_rate,
},
}
YIELD_PREDICTION_CARD = { YIELD_PREDICTION_CARD = {
"id": "yield_prediction", "id": "yield_prediction",
"title": "پیش‌بینی عملکرد", "title": "پیش‌بینی عملکرد",
+42
View File
@@ -1,5 +1,47 @@
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():
return {"status": "success"}
def success_with_data(data):
return {"status": "success", "data": data}
class YieldPredictionCardSerializer(serializers.Serializer): class YieldPredictionCardSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True) id = serializers.CharField(required=False, allow_blank=True)
+37
View File
@@ -0,0 +1,37 @@
from copy import deepcopy
from .mock_data import HARVEST_PREDICTION_CARD, YIELD_PREDICTION_CARD, YIELD_PREDICTION_CHART
from .models import YieldHarvestPredictionLog
def get_yield_harvest_summary_data(farm=None):
data = {
"yield_prediction_card": deepcopy(YIELD_PREDICTION_CARD),
"yield_prediction_chart": deepcopy(YIELD_PREDICTION_CHART),
"harvest_prediction_card": deepcopy(HARVEST_PREDICTION_CARD),
}
if farm is None:
return data
log = YieldHarvestPredictionLog.objects.filter(farm=farm).first()
if log is None:
return data
if log.yield_stats:
data["yield_prediction_card"]["stats"] = log.yield_stats
if log.yield_chip_text:
data["yield_prediction_card"]["chipText"] = log.yield_chip_text
if log.chart_data:
data["yield_prediction_chart"] = deepcopy(log.chart_data)
if log.harvest_date:
data["harvest_prediction_card"]["date"] = log.harvest_date.isoformat()
if log.days_until_harvest is not None:
data["harvest_prediction_card"]["daysUntil"] = log.days_until_harvest
if log.optimal_window_start:
data["harvest_prediction_card"]["optimalWindowStart"] = log.optimal_window_start.isoformat()
if log.optimal_window_end:
data["harvest_prediction_card"]["optimalWindowEnd"] = log.optimal_window_end.isoformat()
return data
+25 -1
View File
@@ -1,6 +1,30 @@
from django.urls import path from django.urls import path
from .views import YieldHarvestSummaryView from .views import (
ConfigView,
EnvironmentView,
ResetView,
StartView,
StateView,
StopView,
YieldHarvestSummaryView,
)
ConfigView.__module__ = "plant_simulator.views"
EnvironmentView.__module__ = "plant_simulator.views"
ResetView.__module__ = "plant_simulator.views"
StartView.__module__ = "plant_simulator.views"
StateView.__module__ = "plant_simulator.views"
StopView.__module__ = "plant_simulator.views"
plant_simulator_urlpatterns = [
path("config/", ConfigView.as_view(), name="plant-simulator-config"),
path("state/", StateView.as_view(), name="plant-simulator-state"),
path("start/", StartView.as_view(), name="plant-simulator-start"),
path("stop/", StopView.as_view(), name="plant-simulator-stop"),
path("reset/", ResetView.as_view(), name="plant-simulator-reset"),
path("environment/", EnvironmentView.as_view(), name="plant-simulator-environment"),
]
urlpatterns = [ urlpatterns = [
path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"), path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
+62 -7
View File
@@ -1,12 +1,8 @@
""" """
Yield & Harvest Prediction API views. Yield & Harvest Prediction and Plant Simulator API views.
Response format: {"status": "success", "data": <payload>}. HTTP 200 only.
Fetches all three prediction payloads (yield card, yield chart, harvest card)
from the AI external adapter in a single call and persists a log entry
if a valid farm_uuid is provided.
""" """
from rest_framework import 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.types import OpenApiTypes
@@ -15,8 +11,67 @@ 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 .mock_data import CONFIG_SLIDERS_ONLY, START_RESPONSE_DATA, STATE_RESPONSE_DATA
from .models import YieldHarvestPredictionLog from .models import YieldHarvestPredictionLog
from .serializers import YieldHarvestSummarySerializer from .serializers import YieldHarvestSummarySerializer, success_response, success_with_data
class ConfigView(APIView):
@extend_schema(
tags=["Plant Simulator"],
responses={200: status_response("PlantSimulatorConfigResponse", data=serializers.JSONField())},
)
def get(self, request):
return Response(success_with_data(CONFIG_SLIDERS_ONLY), status=status.HTTP_200_OK)
class StateView(APIView):
@extend_schema(
tags=["Plant Simulator"],
responses={200: status_response("PlantSimulatorStateResponse", data=serializers.JSONField())},
)
def get(self, request):
return Response(success_with_data(STATE_RESPONSE_DATA), status=status.HTTP_200_OK)
class StartView(APIView):
@extend_schema(
tags=["Plant Simulator"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("PlantSimulatorStartResponse", data=serializers.JSONField())},
)
def post(self, request):
return Response(success_with_data(START_RESPONSE_DATA), status=status.HTTP_200_OK)
class StopView(APIView):
@extend_schema(
tags=["Plant Simulator"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("PlantSimulatorStopResponse")},
)
def post(self, request):
return Response(success_response(), status=status.HTTP_200_OK)
class ResetView(APIView):
@extend_schema(
tags=["Plant Simulator"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("PlantSimulatorResetResponse")},
)
def post(self, request):
return Response(success_response(), status=status.HTTP_200_OK)
class EnvironmentView(APIView):
@extend_schema(
tags=["Plant Simulator"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("PlantSimulatorEnvironmentResponse")},
)
def patch(self, request):
return Response(success_response(), status=status.HTTP_200_OK)
class YieldHarvestSummaryView(APIView): class YieldHarvestSummaryView(APIView):