diff --git a/DASHBOARD_CARD_SOURCES.md b/DASHBOARD_CARD_SOURCES.md new file mode 100644 index 0000000..95a9da6 --- /dev/null +++ b/DASHBOARD_CARD_SOURCES.md @@ -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` diff --git a/FRONTEND_PAGES_APIS_GUIDE.md b/FRONTEND_PAGES_APIS_GUIDE.md new file mode 100644 index 0000000..597e016 --- /dev/null +++ b/FRONTEND_PAGES_APIS_GUIDE.md @@ -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 استفاده کنید diff --git a/SENSOR_APIS_FRONTEND_GUIDE.md b/SENSOR_APIS_FRONTEND_GUIDE.md new file mode 100644 index 0000000..f3516b6 --- /dev/null +++ b/SENSOR_APIS_FRONTEND_GUIDE.md @@ -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=` +- 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 +``` + +### پاسخ موفق + +```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=` +- 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 +``` + +### پاسخ موفق + +```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/` استفاده کنید + diff --git a/config/feature.json b/config/feature.json index e201aa8..d8bffa1 100644 --- a/config/feature.json +++ b/config/feature.json @@ -8,6 +8,7 @@ "crop_zoning": "crop_zoning", "plant_simulator": "plant_simulator", "pest_detection": "pest_detection", + "sensor_7_in_1": "sensor-7-in-1", "irrigation_recommendation": "irrigation_recommendation", "fertilization_recommendation": "fertilization_recommendation", "farm_ai_assistant": "farm_ai_assistant", diff --git a/config/settings.py b/config/settings.py index 409e3bd..91d23ed 100644 --- a/config/settings.py +++ b/config/settings.py @@ -31,10 +31,12 @@ INSTALLED_APPS = [ "access_control.apps.AccessControlConfig", "sensor_catalog.apps.SensorCatalogConfig", "dashboard", + "crop_health.apps.CropHealthConfig", + "soil.apps.SoilConfig", "crop_zoning", - "plant_simulator", "pest_detection", - "weather_forecast.apps.WeatherForecastConfig", + "sensor_7_in_1.apps.Sensor7In1Config", + "water.apps.WaterConfig", "irrigation_recommendation", "yield_harvest.apps.YieldHarvestConfig", "economic_overview.apps.EconomicOverviewConfig", diff --git a/config/urls.py b/config/urls.py index 0dd9fe5..0b8b878 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView +from yield_harvest.urls import plant_simulator_urlpatterns urlpatterns = [ path("admin/", admin.site.urls), @@ -14,11 +15,14 @@ urlpatterns = [ path("api/sensor-catalog/", include("sensor_catalog.urls")), path("api/farm-dashboard-config/", include("dashboard.urls_config")), path("api/farm-dashboard/", include("dashboard.urls")), + path("api/crop-health/", include("crop_health.urls")), + path("api/soil/", include("soil.urls")), path("api/crop-zoning/", include("crop_zoning.urls")), - path("api/plant-simulator/", include("plant_simulator.urls")), + path("api/plant-simulator/", include(plant_simulator_urlpatterns)), path("api/pest-detection/", include("pest_detection.urls")), + path("api/sensor-7-in-1/", include("sensor_7_in_1.urls")), path("api/irrigation-recommendation/", include("irrigation_recommendation.urls")), - path("api/weather-forecast/", include("weather_forecast.urls")), + path("api/water/", include("water.urls")), path("api/yield-harvest/", include("yield_harvest.urls")), path("api/economic-overview/", include("economic_overview.urls")), path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")), diff --git a/plant_simulator/__init__.py b/crop_health/__init__.py similarity index 100% rename from plant_simulator/__init__.py rename to crop_health/__init__.py diff --git a/crop_health/apps.py b/crop_health/apps.py new file mode 100644 index 0000000..ef96ce3 --- /dev/null +++ b/crop_health/apps.py @@ -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" diff --git a/crop_health/mock_data.py b/crop_health/mock_data.py new file mode 100644 index 0000000..cea1f54 --- /dev/null +++ b/crop_health/mock_data.py @@ -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"}, + ], +} diff --git a/crop_health/models.py b/crop_health/models.py new file mode 100644 index 0000000..beeb308 --- /dev/null +++ b/crop_health/models.py @@ -0,0 +1,2 @@ +from django.db import models + diff --git a/crop_health/serializers.py b/crop_health/serializers.py new file mode 100644 index 0000000..b1c633b --- /dev/null +++ b/crop_health/serializers.py @@ -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) diff --git a/crop_health/services.py b/crop_health/services.py new file mode 100644 index 0000000..ef79113 --- /dev/null +++ b/crop_health/services.py @@ -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), + } diff --git a/crop_health/urls.py b/crop_health/urls.py new file mode 100644 index 0000000..79cd178 --- /dev/null +++ b/crop_health/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import CropHealthSummaryView + +urlpatterns = [ + path("summary/", CropHealthSummaryView.as_view(), name="crop-health-summary"), +] diff --git a/crop_health/views.py b/crop_health/views.py new file mode 100644 index 0000000..30b1bc6 --- /dev/null +++ b/crop_health/views.py @@ -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, + ) diff --git a/dashboard/mock_data.py b/dashboard/mock_data.py index 968fdbe..d3cc378 100644 --- a/dashboard/mock_data.py +++ b/dashboard/mock_data.py @@ -66,26 +66,6 @@ def reset_config(): # 4.1 farmOverviewKpis FARM_OVERVIEW_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", "title": "ریسک بیماری", @@ -96,16 +76,6 @@ FARM_OVERVIEW_KPIS = { "chipText": "5%", "chipColor": "success", }, - { - "id": "avg_soil_moisture", - "title": "میانگین رطوبت خاک", - "subtitle": "کل مزرعه", - "stats": "65%", - "avatarColor": "primary", - "avatarIcon": "tabler-plant-2", - "chipText": "بهینه", - "chipColor": "success", - }, { "id": "yield_prediction", "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 FARM_ALERTS_TIMELINE = { "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 RECOMMENDATIONS_LIST = { "recommendations": [ @@ -461,15 +349,15 @@ ALL_CARDS = { "farmWeatherCard": FARM_WEATHER_CARD, # هروز "farmAlertsTracker": FARM_ALERTS_TRACKER, #هروز "sensorValuesList": SENSOR_VALUES_LIST,#هروز - "sensorRadarChart": SENSOR_RADAR_CHART, - "sensorComparisonChart": SENSOR_COMPARISON_CHART, - "anomalyDetectionCard": ANOMALY_DETECTION_CARD, + "sensorRadarChart": {}, + "sensorComparisonChart": {}, + "anomalyDetectionCard": {}, "farmAlertsTimeline": FARM_ALERTS_TIMELINE, "waterNeedPrediction": WATER_NEED_PREDICTION, "harvestPredictionCard": HARVEST_PREDICTION_CARD, "yieldPredictionChart": YIELD_PREDICTION_CHART, - "soilMoistureHeatmap": SOIL_MOISTURE_HEATMAP, - "ndviHealthCard": NDVI_HEALTH_CARD, + "soilMoistureHeatmap": {}, + "ndviHealthCard": {}, "recommendationsList": RECOMMENDATIONS_LIST, # این باید حتما از recommendetion ها گرفته بشه "economicOverview": ECONOMIC_OVERVIEW, } diff --git a/dashboard/services.py b/dashboard/services.py new file mode 100644 index 0000000..26ce700 --- /dev/null +++ b/dashboard/services.py @@ -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 diff --git a/dashboard/tests.py b/dashboard/tests.py index 5ec8520..9c95eac 100644 --- a/dashboard/tests.py +++ b/dashboard/tests.py @@ -1,5 +1,4 @@ from copy import deepcopy -from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TestCase @@ -136,23 +135,24 @@ class FarmDashboardConfigViewTests(DashboardBaseTestCase): class FarmDashboardCardsViewTests(DashboardBaseTestCase): - @patch("dashboard.views.external_api_request") - 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 - + def test_get_returns_locally_aggregated_cards(self): request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}") force_authenticate(request, user=self.user) response = FarmDashboardCardsView.as_view()(request) self.assertEqual(response.status_code, 200) - mock_external_api_request.assert_called_once_with( - "ai", - "/dashboard-data/status", - method="GET", - query={"farm_uuid": str(self.farm.farm_uuid)}, - ) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["msg"], "OK") + self.assertIn("farmWeatherCard", response.data["data"]) + self.assertIn("farmAlertsTracker", response.data["data"]) + 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): request = self.factory.get("/api/farm-dashboard/") diff --git a/dashboard/views.py b/dashboard/views.py index 9cfe192..f9c04c6 100644 --- a/dashboard/views.py +++ b/dashboard/views.py @@ -10,8 +10,8 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from config.swagger import code_response -from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub +from .services import get_farm_dashboard_cards from .mock_data import DEFAULT_CONFIG from .models import FarmDashboardConfig from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer @@ -117,7 +117,7 @@ class FarmDashboardConfigView(FarmAccessMixin, APIView): class FarmDashboardCardsView(FarmAccessMixin, APIView): """ 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] @@ -125,10 +125,7 @@ class FarmDashboardCardsView(FarmAccessMixin, APIView): def get(self, request): farm = self._get_farm(request, request.query_params.get("farm_uuid")) - adapter_response = external_api_request( - "ai", - "/dashboard-data/status", - method="GET", - query={"farm_uuid": str(farm.farm_uuid)}, + return Response( + {"code": 200, "msg": "OK", "data": get_farm_dashboard_cards(farm)}, + status=status.HTTP_200_OK, ) - return Response(adapter_response.data, status=adapter_response.status_code) diff --git a/economic_overview/services.py b/economic_overview/services.py new file mode 100644 index 0000000..7f0e60b --- /dev/null +++ b/economic_overview/services.py @@ -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 diff --git a/farm_alerts/services.py b/farm_alerts/services.py index df8cc94..1435761 100644 --- a/farm_alerts/services.py +++ b/farm_alerts/services.py @@ -1,7 +1,16 @@ +from collections import Counter +from copy import deepcopy + from farm_hub.models import FarmHub 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: @@ -47,3 +56,95 @@ class AlertService: level=level_map.get(alert.color, "info"), 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 + ] + } diff --git a/fertilization_recommendation/mock_data.py b/fertilization_recommendation/mock_data.py index 84da776..d4525e4 100644 --- a/fertilization_recommendation/mock_data.py +++ b/fertilization_recommendation/mock_data.py @@ -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.", }, } + +FERTILIZATION_DASHBOARD_RECOMMENDATION = { + "title": "کود: 20-20-20 (NPK)", + "subtitle": "150 kg/ha، با روش Foliar spray + soil broadcast و هر 14 روز.", + "avatarIcon": "tabler-leaf", + "avatarColor": "success", +} diff --git a/fertilization_recommendation/services.py b/fertilization_recommendation/services.py new file mode 100644 index 0000000..e520c9e --- /dev/null +++ b/fertilization_recommendation/services.py @@ -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 diff --git a/irrigation_recommendation/mock_data.py b/irrigation_recommendation/mock_data.py index db90576..55fef1d 100644 --- a/irrigation_recommendation/mock_data.py +++ b/irrigation_recommendation/mock_data.py @@ -28,3 +28,17 @@ RECOMMEND_RESPONSE_DATA = { "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", +} diff --git a/irrigation_recommendation/services.py b/irrigation_recommendation/services.py new file mode 100644 index 0000000..632ee35 --- /dev/null +++ b/irrigation_recommendation/services.py @@ -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 diff --git a/pest_detection/services.py b/pest_detection/services.py new file mode 100644 index 0000000..1ad82da --- /dev/null +++ b/pest_detection/services.py @@ -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) diff --git a/plant_simulator/apps.py b/plant_simulator/apps.py deleted file mode 100644 index b93448b..0000000 --- a/plant_simulator/apps.py +++ /dev/null @@ -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" diff --git a/plant_simulator/mock_data.py b/plant_simulator/mock_data.py deleted file mode 100644 index ab9618b..0000000 --- a/plant_simulator/mock_data.py +++ /dev/null @@ -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 (0–10s, ~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, - }, -} \ No newline at end of file diff --git a/plant_simulator/postman/plant_simulator.json b/plant_simulator/postman/plant_simulator.json deleted file mode 100644 index 94540f3..0000000 --- a/plant_simulator/postman/plant_simulator.json +++ /dev/null @@ -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"}] - } diff --git a/plant_simulator/serializers.py b/plant_simulator/serializers.py deleted file mode 100644 index 81f4651..0000000 --- a/plant_simulator/serializers.py +++ /dev/null @@ -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} diff --git a/plant_simulator/urls.py b/plant_simulator/urls.py deleted file mode 100644 index 0175ec6..0000000 --- a/plant_simulator/urls.py +++ /dev/null @@ -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"), -] diff --git a/plant_simulator/views.py b/plant_simulator/views.py deleted file mode 100644 index 6408aae..0000000 --- a/plant_simulator/views.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -Plant Simulator API views. -No database. All responses are static mock data. -Response format: {"status": "success"} or {"status": "success", "data": }. 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) diff --git a/sensor_7_in_1/__init__.py b/sensor_7_in_1/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sensor_7_in_1/__init__.py @@ -0,0 +1 @@ + diff --git a/sensor_7_in_1/apps.py b/sensor_7_in_1/apps.py new file mode 100644 index 0000000..1e231cf --- /dev/null +++ b/sensor_7_in_1/apps.py @@ -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" + diff --git a/sensor_7_in_1/mock_data.py b/sensor_7_in_1/mock_data.py new file mode 100644 index 0000000..6334c08 --- /dev/null +++ b/sensor_7_in_1/mock_data.py @@ -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}, + ], + } + ], +} + diff --git a/sensor_7_in_1/models.py b/sensor_7_in_1/models.py new file mode 100644 index 0000000..c0fec14 --- /dev/null +++ b/sensor_7_in_1/models.py @@ -0,0 +1,4 @@ +""" +This app is service-based and does not define local database models. +""" + diff --git a/sensor_7_in_1/serializers.py b/sensor_7_in_1/serializers.py new file mode 100644 index 0000000..52bffa2 --- /dev/null +++ b/sensor_7_in_1/serializers.py @@ -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) + diff --git a/sensor_7_in_1/services.py b/sensor_7_in_1/services.py new file mode 100644 index 0000000..2221042 --- /dev/null +++ b/sensor_7_in_1/services.py @@ -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), + } diff --git a/sensor_7_in_1/tests.py b/sensor_7_in_1/tests.py new file mode 100644 index 0000000..0c18d81 --- /dev/null +++ b/sensor_7_in_1/tests.py @@ -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.") diff --git a/sensor_7_in_1/urls.py b/sensor_7_in_1/urls.py new file mode 100644 index 0000000..fa4e921 --- /dev/null +++ b/sensor_7_in_1/urls.py @@ -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"), +] + diff --git a/sensor_7_in_1/views.py b/sensor_7_in_1/views.py new file mode 100644 index 0000000..626ac81 --- /dev/null +++ b/sensor_7_in_1/views.py @@ -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, + ) + diff --git a/soil/__init__.py b/soil/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/soil/__init__.py @@ -0,0 +1 @@ + diff --git a/soil/apps.py b/soil/apps.py new file mode 100644 index 0000000..9149d22 --- /dev/null +++ b/soil/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SoilConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "soil" + verbose_name = "Soil" diff --git a/soil/mock_data.py b/soil/mock_data.py new file mode 100644 index 0000000..32e60c7 --- /dev/null +++ b/soil/mock_data.py @@ -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}, + ], + }, + ], +} diff --git a/soil/models.py b/soil/models.py new file mode 100644 index 0000000..137941f --- /dev/null +++ b/soil/models.py @@ -0,0 +1 @@ +from django.db import models diff --git a/soil/serializers.py b/soil/serializers.py new file mode 100644 index 0000000..9d6a468 --- /dev/null +++ b/soil/serializers.py @@ -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) diff --git a/soil/services.py b/soil/services.py new file mode 100644 index 0000000..dfbc70a --- /dev/null +++ b/soil/services.py @@ -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), + } diff --git a/soil/urls.py b/soil/urls.py new file mode 100644 index 0000000..7c1f420 --- /dev/null +++ b/soil/urls.py @@ -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"), +] diff --git a/soil/views.py b/soil/views.py new file mode 100644 index 0000000..51b1ca0 --- /dev/null +++ b/soil/views.py @@ -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, + ) diff --git a/weather_forecast/__init__.py b/water/__init__.py similarity index 100% rename from weather_forecast/__init__.py rename to water/__init__.py diff --git a/water/apps.py b/water/apps.py new file mode 100644 index 0000000..04770be --- /dev/null +++ b/water/apps.py @@ -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" diff --git a/weather_forecast/migrations/0001_initial.py b/water/migrations/0001_initial.py similarity index 100% rename from weather_forecast/migrations/0001_initial.py rename to water/migrations/0001_initial.py diff --git a/weather_forecast/migrations/__init__.py b/water/migrations/__init__.py similarity index 100% rename from weather_forecast/migrations/__init__.py rename to water/migrations/__init__.py diff --git a/water/mock_data.py b/water/mock_data.py new file mode 100644 index 0000000..389335b --- /dev/null +++ b/water/mock_data.py @@ -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", +} diff --git a/weather_forecast/models.py b/water/models.py similarity index 100% rename from weather_forecast/models.py rename to water/models.py diff --git a/water/serializers.py b/water/serializers.py new file mode 100644 index 0000000..79e4cfb --- /dev/null +++ b/water/serializers.py @@ -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) diff --git a/water/services.py b/water/services.py new file mode 100644 index 0000000..96ca784 --- /dev/null +++ b/water/services.py @@ -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), + } diff --git a/water/urls.py b/water/urls.py new file mode 100644 index 0000000..ec74656 --- /dev/null +++ b/water/urls.py @@ -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"), +] diff --git a/water/views.py b/water/views.py new file mode 100644 index 0000000..66a86d8 --- /dev/null +++ b/water/views.py @@ -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, + ) diff --git a/weather_forecast/apps.py b/weather_forecast/apps.py deleted file mode 100644 index 287508c..0000000 --- a/weather_forecast/apps.py +++ /dev/null @@ -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" diff --git a/weather_forecast/mock_data.py b/weather_forecast/mock_data.py deleted file mode 100644 index 08757e8..0000000 --- a/weather_forecast/mock_data.py +++ /dev/null @@ -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]], - }, -} diff --git a/weather_forecast/serializers.py b/weather_forecast/serializers.py deleted file mode 100644 index 86c5190..0000000 --- a/weather_forecast/serializers.py +++ /dev/null @@ -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) diff --git a/weather_forecast/urls.py b/weather_forecast/urls.py deleted file mode 100644 index 728f643..0000000 --- a/weather_forecast/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from .views import FarmWeatherCardView - -urlpatterns = [ - path("card/", FarmWeatherCardView.as_view(), name="weather-forecast-card"), -] diff --git a/weather_forecast/views.py b/weather_forecast/views.py deleted file mode 100644 index eab89f5..0000000 --- a/weather_forecast/views.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Weather Forecast API views. -Response format: {"status": "success", "data": }. 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", {}), - ) diff --git a/yield_harvest/mock_data.py b/yield_harvest/mock_data.py index 4abfbcf..0ea7b1b 100644 --- a/yield_harvest/mock_data.py +++ b/yield_harvest/mock_data.py @@ -3,6 +3,164 @@ Static mock data for Yield & Harvest Prediction API. 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 = { "id": "yield_prediction", "title": "پیش‌بینی عملکرد", diff --git a/yield_harvest/serializers.py b/yield_harvest/serializers.py index c32076c..dd8e7c1 100644 --- a/yield_harvest/serializers.py +++ b/yield_harvest/serializers.py @@ -1,5 +1,47 @@ 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): id = serializers.CharField(required=False, allow_blank=True) diff --git a/yield_harvest/services.py b/yield_harvest/services.py new file mode 100644 index 0000000..ddb4c80 --- /dev/null +++ b/yield_harvest/services.py @@ -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 diff --git a/yield_harvest/urls.py b/yield_harvest/urls.py index 99f8486..955c324 100644 --- a/yield_harvest/urls.py +++ b/yield_harvest/urls.py @@ -1,6 +1,30 @@ 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 = [ path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"), diff --git a/yield_harvest/views.py b/yield_harvest/views.py index da77de4..b1af04f 100644 --- a/yield_harvest/views.py +++ b/yield_harvest/views.py @@ -1,12 +1,8 @@ """ -Yield & Harvest Prediction API views. -Response format: {"status": "success", "data": }. 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. +Yield & Harvest Prediction and Plant Simulator API views. """ -from rest_framework import status +from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.views import APIView 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 external_api_adapter import request as external_api_request from farm_hub.models import FarmHub +from .mock_data import CONFIG_SLIDERS_ONLY, START_RESPONSE_DATA, STATE_RESPONSE_DATA from .models import YieldHarvestPredictionLog -from .serializers import YieldHarvestSummarySerializer +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):