AI UPDATE
This commit is contained in:
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
|
|||||||
"corsheaders",
|
"corsheaders",
|
||||||
"drf_spectacular",
|
"drf_spectacular",
|
||||||
"drf_spectacular_sidecar",
|
"drf_spectacular_sidecar",
|
||||||
|
"dashboard_data",
|
||||||
"rag",
|
"rag",
|
||||||
"tasks",
|
"tasks",
|
||||||
"location_data",
|
"location_data",
|
||||||
@@ -119,6 +120,7 @@ SPECTACULAR_SETTINGS = {
|
|||||||
"REDOC_DIST": "SIDECAR",
|
"REDOC_DIST": "SIDECAR",
|
||||||
"COMPONENT_SPLIT_REQUEST": True,
|
"COMPONENT_SPLIT_REQUEST": True,
|
||||||
"TAGS": [
|
"TAGS": [
|
||||||
|
{"name": "Dashboard Data", "description": "تجمیع دادههای داشبورد مزرعه"},
|
||||||
{"name": "RAG Chat", "description": "چت هوشمند RAG"},
|
{"name": "RAG Chat", "description": "چت هوشمند RAG"},
|
||||||
{"name": "Tasks", "description": "مدیریت تسکهای Celery"},
|
{"name": "Tasks", "description": "مدیریت تسکهای Celery"},
|
||||||
{"name": "Soil Data", "description": "دادههای خاک (SoilGrids)"},
|
{"name": "Soil Data", "description": "دادههای خاک (SoilGrids)"},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ urlpatterns = [
|
|||||||
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
||||||
# --- App APIs ---
|
# --- App APIs ---
|
||||||
path("api/rag/", include("rag.urls")),
|
path("api/rag/", include("rag.urls")),
|
||||||
|
path("api/dashboard-data/", include("dashboard_data.urls")),
|
||||||
path("api/tasks/", include("tasks.urls")),
|
path("api/tasks/", include("tasks.urls")),
|
||||||
path("api/soil-data/", include("location_data.urls")),
|
path("api/soil-data/", include("location_data.urls")),
|
||||||
path("api/sensor-data/", include("sensor_data.urls")),
|
path("api/sensor-data/", include("sensor_data.urls")),
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from .models import DashboardAiRequestLog
|
||||||
|
|
||||||
|
|
||||||
|
def request_dashboard_ai_bundle(sensor_id: str, payload: dict) -> dict:
|
||||||
|
log = DashboardAiRequestLog.objects.create(
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
request_payload=payload,
|
||||||
|
response_payload={},
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"log_id": log.id,
|
||||||
|
"timeline": [],
|
||||||
|
"recommendations": [],
|
||||||
|
"alerts": [],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardDataConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "dashboard_data"
|
||||||
|
verbose_name = "Dashboard Data"
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
CARD_TTLS = {
|
||||||
|
"farmOverviewKpis": timedelta(days=3),
|
||||||
|
"farmWeatherCard": timedelta(days=1),
|
||||||
|
"farmAlertsTracker": timedelta(days=1),
|
||||||
|
"sensorValuesList": timedelta(days=1),
|
||||||
|
"sensorRadarChart": timedelta(days=1),
|
||||||
|
"sensorComparisonChart": timedelta(days=1),
|
||||||
|
"anomalyDetectionCard": timedelta(days=1),
|
||||||
|
"farmAlertsTimeline": timedelta(days=1),
|
||||||
|
"waterNeedPrediction": timedelta(days=1),
|
||||||
|
"harvestPredictionCard": timedelta(days=1),
|
||||||
|
"yieldPredictionChart": timedelta(days=1),
|
||||||
|
"soilMoistureHeatmap": timedelta(days=1),
|
||||||
|
"ndviHealthCard": timedelta(days=1),
|
||||||
|
"recommendationsList": timedelta(days=1),
|
||||||
|
"economicOverview": timedelta(days=1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
WMO_CONDITIONS = {
|
||||||
|
0: "صاف",
|
||||||
|
1: "عمدتاً صاف",
|
||||||
|
2: "نیمهابری",
|
||||||
|
3: "ابری",
|
||||||
|
45: "مه",
|
||||||
|
48: "مه یخزده",
|
||||||
|
51: "نمنم باران",
|
||||||
|
61: "بارش خفیف",
|
||||||
|
63: "بارش متوسط",
|
||||||
|
65: "بارش شدید",
|
||||||
|
71: "برف خفیف",
|
||||||
|
80: "رگبار",
|
||||||
|
95: "رعد و برق",
|
||||||
|
}
|
||||||
|
|
||||||
|
PERSIAN_WEEKDAYS = ["دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه", "یکشنبه"]
|
||||||
|
|
||||||
|
|
||||||
|
def ttl_for_card(card_name: str):
|
||||||
|
return CARD_TTLS[card_name]
|
||||||
|
|
||||||
|
|
||||||
|
def is_fresh(snapshot) -> bool:
|
||||||
|
return snapshot and snapshot.expires_at > timezone.now()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_number(value, default=0):
|
||||||
|
return default if value is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def average(values, default=0):
|
||||||
|
clean_values = [value for value in values if value is not None]
|
||||||
|
if not clean_values:
|
||||||
|
return default
|
||||||
|
return sum(clean_values) / len(clean_values)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_history_value(history, field_name, default=None):
|
||||||
|
if not history:
|
||||||
|
return default
|
||||||
|
return getattr(history[0], field_name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_trend(current, previous):
|
||||||
|
current_value = safe_number(current, 0)
|
||||||
|
previous_value = safe_number(previous, current_value)
|
||||||
|
diff = round(current_value - previous_value, 1)
|
||||||
|
return {
|
||||||
|
"trendNumber": diff,
|
||||||
|
"trend": "positive" if diff >= 0 else "negative",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def weather_condition(weather_code):
|
||||||
|
return WMO_CONDITIONS.get(weather_code, "نامشخص")
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from dashboard_data.card_utils import safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_anomaly_detection_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
sensor = (context or {}).get("sensor")
|
||||||
|
if sensor is None:
|
||||||
|
return {"anomalies": []}
|
||||||
|
|
||||||
|
anomalies = []
|
||||||
|
moisture = safe_number(sensor.soil_moisture, 0)
|
||||||
|
if moisture < 45:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"sensor": "رطوبت خاک",
|
||||||
|
"value": f"{round(moisture)}%",
|
||||||
|
"expected": "45-65%",
|
||||||
|
"deviation": f"{round(moisture - 55)}%",
|
||||||
|
"severity": "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
soil_ph = safe_number(sensor.soil_ph, 7)
|
||||||
|
if soil_ph < 6 or soil_ph > 7:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"sensor": "pH خاک",
|
||||||
|
"value": f"{soil_ph:.1f}",
|
||||||
|
"expected": "6.0-7.0",
|
||||||
|
"deviation": f"{round(soil_ph - 6.5, 1)}",
|
||||||
|
"severity": "error" if soil_ph < 5.5 or soil_ph > 7.5 else "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"anomalies": anomalies}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from dashboard_data.card_utils import safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_economic_overview(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
sensor = (context or {}).get("sensor")
|
||||||
|
forecasts = (context or {}).get("forecasts", [])
|
||||||
|
if sensor is None:
|
||||||
|
return {"economicData": [], "chartSeries": [], "chartCategories": []}
|
||||||
|
|
||||||
|
water_cost = round(sum(max(0, safe_number(forecast.et0, 0) * 20) for forecast in forecasts[:6]))
|
||||||
|
fertilizer_need = round((safe_number(sensor.nitrogen, 0) + safe_number(sensor.phosphorus, 0) + safe_number(sensor.potassium, 0)) / 3)
|
||||||
|
revenue = round(max(1000, water_cost * 4.5))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"economicData": [
|
||||||
|
{
|
||||||
|
"title": "هزینه آب",
|
||||||
|
"value": f"€{water_cost}",
|
||||||
|
"subtitle": "این ماه",
|
||||||
|
"avatarIcon": "tabler-droplet",
|
||||||
|
"avatarColor": "primary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "صرفهجویی آب هوشمند",
|
||||||
|
"value": f"€{round(water_cost * 0.18)}",
|
||||||
|
"subtitle": "۱۸٪ صرفهجویی شده",
|
||||||
|
"avatarIcon": "tabler-bulb",
|
||||||
|
"avatarColor": "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "بازده سرمایه پلتفرم",
|
||||||
|
"value": "127%",
|
||||||
|
"subtitle": "نسبت به سال گذشته",
|
||||||
|
"avatarIcon": "tabler-chart-line",
|
||||||
|
"avatarColor": "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "پیشبینی درآمد",
|
||||||
|
"value": f"€{round(revenue / 1000)}k",
|
||||||
|
"subtitle": "این فصل",
|
||||||
|
"avatarIcon": "tabler-currency-euro",
|
||||||
|
"avatarColor": "success",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"chartSeries": [
|
||||||
|
{"name": "هزینه آب", "data": [max(1, round(water_cost / 6)) for _ in range(6)]},
|
||||||
|
{"name": "کود", "data": [max(1, round(fertilizer_need / 6)) for _ in range(6)]},
|
||||||
|
],
|
||||||
|
"chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
def build_farm_alerts_timeline(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
ai_bundle = ai_bundle or {}
|
||||||
|
return {"alerts": ai_bundle.get("timeline", [])}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from dashboard_data.card_utils import average, safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
context = context or {}
|
||||||
|
sensor = context.get("sensor")
|
||||||
|
forecasts = context.get("forecasts", [])
|
||||||
|
if sensor is None:
|
||||||
|
return {"totalAlerts": 0, "radialBarValue": 0, "alertStats": []}
|
||||||
|
|
||||||
|
moisture = safe_number(sensor.soil_moisture, 0)
|
||||||
|
humidity = average([forecast.humidity_mean for forecast in forecasts[:3]], default=0)
|
||||||
|
frost_count = sum(1 for forecast in forecasts[:3] if safe_number(forecast.temperature_min, 10) <= 0)
|
||||||
|
low_water_count = 2 if moisture < 45 else 0
|
||||||
|
fungal_count = 1 if humidity > 70 and moisture > 60 else 0
|
||||||
|
total = low_water_count + fungal_count + frost_count
|
||||||
|
|
||||||
|
return {
|
||||||
|
"totalAlerts": total,
|
||||||
|
"radialBarValue": min(100, total * 10),
|
||||||
|
"alertStats": [
|
||||||
|
{
|
||||||
|
"title": "کمبود آب",
|
||||||
|
"count": str(low_water_count),
|
||||||
|
"avatarColor": "error",
|
||||||
|
"avatarIcon": "tabler-droplet-half-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "ریسک قارچی",
|
||||||
|
"count": str(fungal_count),
|
||||||
|
"avatarColor": "warning",
|
||||||
|
"avatarIcon": "tabler-mushroom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "هشدار یخبندان",
|
||||||
|
"count": str(frost_count),
|
||||||
|
"avatarColor": "info",
|
||||||
|
"avatarIcon": "tabler-snowflake",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from dashboard_data.card_utils import average, safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_farm_overview_kpis(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
context = context or {}
|
||||||
|
sensor = context.get("sensor")
|
||||||
|
forecasts = context.get("forecasts", [])
|
||||||
|
if sensor is None:
|
||||||
|
return {"kpis": []}
|
||||||
|
|
||||||
|
moisture = safe_number(sensor.soil_moisture, 0)
|
||||||
|
ph = safe_number(sensor.soil_ph, 7)
|
||||||
|
ec = safe_number(sensor.electrical_conductivity, 0)
|
||||||
|
humidity = average([forecast.humidity_mean for forecast in forecasts[:3]], default=45)
|
||||||
|
health_score = max(0, min(100, round(100 - abs(65 - moisture) - (abs(6.8 - ph) * 10) - (ec * 5))))
|
||||||
|
water_stress = max(0, min(100, round(35 - (moisture / 2))))
|
||||||
|
disease_risk = max(0, min(100, round((humidity * 0.4) + (safe_number(sensor.soil_temperature, 0) * 0.6) - 20)))
|
||||||
|
yield_prediction = round(max(5, (health_score / 2.1)), 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kpis": [
|
||||||
|
{
|
||||||
|
"id": "farm_health_score",
|
||||||
|
"title": "امتیاز سلامت مزرعه",
|
||||||
|
"subtitle": "تحلیل هوشمند",
|
||||||
|
"stats": f"{health_score}%",
|
||||||
|
"avatarColor": "success" if health_score >= 70 else "warning",
|
||||||
|
"avatarIcon": "tabler-heartbeat",
|
||||||
|
"chipText": "خوب" if health_score >= 70 else "متوسط",
|
||||||
|
"chipColor": "success" if health_score >= 70 else "warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "water_stress_index",
|
||||||
|
"title": "شاخص تنش آبی",
|
||||||
|
"subtitle": "فعلی",
|
||||||
|
"stats": f"{water_stress}%",
|
||||||
|
"avatarColor": "info",
|
||||||
|
"avatarIcon": "tabler-droplet",
|
||||||
|
"chipText": "پایین" if water_stress <= 20 else "متوسط",
|
||||||
|
"chipColor": "success" if water_stress <= 20 else "warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "disease_risk",
|
||||||
|
"title": "ریسک بیماری",
|
||||||
|
"subtitle": "۷ روز اخیر",
|
||||||
|
"stats": "پایین" if disease_risk < 30 else "متوسط",
|
||||||
|
"avatarColor": "success" if disease_risk < 30 else "warning",
|
||||||
|
"avatarIcon": "tabler-bug",
|
||||||
|
"chipText": f"{disease_risk}%",
|
||||||
|
"chipColor": "success" if disease_risk < 30 else "warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avg_soil_moisture",
|
||||||
|
"title": "میانگین رطوبت خاک",
|
||||||
|
"subtitle": "کل مزرعه",
|
||||||
|
"stats": f"{round(moisture)}%",
|
||||||
|
"avatarColor": "primary",
|
||||||
|
"avatarIcon": "tabler-plant-2",
|
||||||
|
"chipText": "بهینه" if 45 <= moisture <= 75 else "نیازمند بررسی",
|
||||||
|
"chipColor": "success" if 45 <= moisture <= 75 else "warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "yield_prediction",
|
||||||
|
"title": "پیشبینی عملکرد",
|
||||||
|
"subtitle": "این فصل",
|
||||||
|
"stats": f"{yield_prediction} تن",
|
||||||
|
"avatarColor": "secondary",
|
||||||
|
"avatarIcon": "tabler-chart-bar",
|
||||||
|
"chipText": f"+{max(0, health_score - 50)}%",
|
||||||
|
"chipColor": "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pest_risk",
|
||||||
|
"title": "ریسک آفات",
|
||||||
|
"subtitle": "پیشبینی هوشمند",
|
||||||
|
"stats": f"{max(5, round(disease_risk * 0.7))}%",
|
||||||
|
"avatarColor": "warning",
|
||||||
|
"avatarIcon": "tabler-bug-off",
|
||||||
|
"chipText": "تحت نظر",
|
||||||
|
"chipColor": "warning",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from dashboard_data.card_utils import average, safe_number, weather_condition
|
||||||
|
|
||||||
|
|
||||||
|
def build_farm_weather_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
forecasts = (context or {}).get("forecasts", [])
|
||||||
|
if not forecasts:
|
||||||
|
return {
|
||||||
|
"condition": "نامشخص",
|
||||||
|
"temperature": 0,
|
||||||
|
"unit": "°C",
|
||||||
|
"humidity": 0,
|
||||||
|
"windSpeed": 0,
|
||||||
|
"windUnit": "km/h",
|
||||||
|
"chartData": {"labels": [], "series": [[]]},
|
||||||
|
}
|
||||||
|
|
||||||
|
current_forecast = forecasts[0]
|
||||||
|
labels = [str(forecast.forecast_date) for forecast in forecasts[:7]]
|
||||||
|
series = [[round(safe_number(forecast.temperature_mean, 0)) for forecast in forecasts[:7]]]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"condition": weather_condition(current_forecast.weather_code),
|
||||||
|
"temperature": round(safe_number(current_forecast.temperature_mean, current_forecast.temperature_max)),
|
||||||
|
"unit": "°C",
|
||||||
|
"humidity": round(average([current_forecast.humidity_mean], default=0)),
|
||||||
|
"windSpeed": round(safe_number(current_forecast.wind_speed_max, 0)),
|
||||||
|
"windUnit": "km/h",
|
||||||
|
"chartData": {
|
||||||
|
"labels": labels,
|
||||||
|
"series": series,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from dashboard_data.card_utils import average, safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_harvest_prediction_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
context = context or {}
|
||||||
|
forecasts = context.get("forecasts", [])
|
||||||
|
plants = context.get("plants", [])
|
||||||
|
|
||||||
|
avg_temp = average([forecast.temperature_mean for forecast in forecasts], default=24)
|
||||||
|
moisture_factor = safe_number(getattr(context.get("sensor"), "soil_moisture", None), 50)
|
||||||
|
days_until = max(10, int(90 - avg_temp - (moisture_factor / 5)))
|
||||||
|
target_date = date.today() + timedelta(days=days_until)
|
||||||
|
window_start = target_date - timedelta(days=3)
|
||||||
|
window_end = target_date + timedelta(days=3)
|
||||||
|
plant_name = plants[0].name if plants else "محصول"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": str(target_date),
|
||||||
|
"dateFormatted": f"{target_date.day} {target_date.strftime('%B')} {target_date.year}",
|
||||||
|
"daysUntil": days_until,
|
||||||
|
"description": f"بر اساس دمای فعلی، رطوبت خاک و اطلاعات {plant_name}. بازه بهینه برداشت محاسبه شده است.",
|
||||||
|
"optimalWindowStart": str(window_start),
|
||||||
|
"optimalWindowEnd": str(window_end),
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from dashboard_data.card_utils import safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_ndvi_health_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
context = context or {}
|
||||||
|
sensor = context.get("sensor")
|
||||||
|
if sensor is None:
|
||||||
|
return {"ndviIndex": 0, "healthData": []}
|
||||||
|
|
||||||
|
nitrogen = safe_number(sensor.nitrogen, 0)
|
||||||
|
moisture = safe_number(sensor.soil_moisture, 0)
|
||||||
|
ndvi = round(min(0.95, max(0.1, ((nitrogen / 100) * 0.4) + ((moisture / 100) * 0.6))), 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ndviIndex": ndvi,
|
||||||
|
"healthData": [
|
||||||
|
{
|
||||||
|
"title": "تنش نیتروژن",
|
||||||
|
"value": "پایین" if nitrogen >= 30 else "بالا",
|
||||||
|
"color": "success" if nitrogen >= 30 else "warning",
|
||||||
|
"icon": "tabler-leaf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "سلامت محصول",
|
||||||
|
"value": "خوب" if ndvi >= 0.65 else "متوسط",
|
||||||
|
"color": "success" if ndvi >= 0.65 else "warning",
|
||||||
|
"icon": "tabler-plant",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
def build_recommendations_list(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
ai_bundle = ai_bundle or {}
|
||||||
|
return {"recommendations": ai_bundle.get("recommendations", [])}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from dashboard_data.card_utils import PERSIAN_WEEKDAYS, safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_sensor_comparison_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
history = (context or {}).get("history", [])
|
||||||
|
current_sensor = (context or {}).get("sensor")
|
||||||
|
current_value = round(safe_number(getattr(current_sensor, "soil_moisture", None), 0))
|
||||||
|
|
||||||
|
recent = list(reversed(history[:7]))
|
||||||
|
previous = list(reversed(history[7:14]))
|
||||||
|
this_week = [round(safe_number(item.soil_moisture, current_value)) for item in recent]
|
||||||
|
last_week = [round(safe_number(item.soil_moisture, current_value - 5)) for item in previous]
|
||||||
|
|
||||||
|
while len(this_week) < 7:
|
||||||
|
this_week.append(current_value)
|
||||||
|
while len(last_week) < 7:
|
||||||
|
last_week.append(max(0, current_value - 5))
|
||||||
|
|
||||||
|
categories = [PERSIAN_WEEKDAYS[(date.today() - timedelta(days=offset)).weekday()] for offset in range(6, -1, -1)]
|
||||||
|
avg_this = sum(this_week) / len(this_week)
|
||||||
|
avg_last = sum(last_week) / len(last_week)
|
||||||
|
delta = round(((avg_this - avg_last) / avg_last) * 100) if avg_last else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"currentValue": current_value,
|
||||||
|
"vsLastWeek": f"{'+' if delta >= 0 else ''}{delta}%",
|
||||||
|
"vsLastWeekValue": delta,
|
||||||
|
"categories": categories,
|
||||||
|
"series": [
|
||||||
|
{"name": "امروز", "data": this_week},
|
||||||
|
{"name": "هفته قبل", "data": last_week},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from dashboard_data.card_utils import safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def _to_score(value, lower, upper):
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if value <= lower:
|
||||||
|
return 0
|
||||||
|
if value >= upper:
|
||||||
|
return 100
|
||||||
|
return round(((value - lower) / (upper - lower)) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def build_sensor_radar_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
context = context or {}
|
||||||
|
sensor = context.get("sensor")
|
||||||
|
forecasts = context.get("forecasts", [])
|
||||||
|
if sensor is None:
|
||||||
|
return {"labels": [], "series": []}
|
||||||
|
|
||||||
|
current_weather = forecasts[0] if forecasts else None
|
||||||
|
current = [
|
||||||
|
_to_score(sensor.soil_temperature, 0, 40),
|
||||||
|
_to_score(sensor.soil_moisture, 0, 100),
|
||||||
|
_to_score(sensor.soil_ph, 0, 14),
|
||||||
|
_to_score(sensor.electrical_conductivity, 0, 5),
|
||||||
|
85,
|
||||||
|
_to_score(current_weather.wind_speed_max if current_weather else 0, 0, 30),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"labels": ["دما", "رطوبت", "pH", "هدایت الکتریکی", "نور", "باد"],
|
||||||
|
"series": [
|
||||||
|
{"name": "امروز", "data": current},
|
||||||
|
{"name": "ایدهآل", "data": [80, 70, 75, 75, 90, 50]},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from dashboard_data.card_utils import compute_trend, latest_history_value, safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_sensor_values_list(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
context = context or {}
|
||||||
|
sensor = context.get("sensor")
|
||||||
|
history = context.get("history", [])
|
||||||
|
forecasts = context.get("forecasts", [])
|
||||||
|
if sensor is None:
|
||||||
|
return {"sensors": []}
|
||||||
|
|
||||||
|
current_weather = forecasts[0] if forecasts else None
|
||||||
|
|
||||||
|
sensors = [
|
||||||
|
{
|
||||||
|
"title": f"{round(safe_number(current_weather.temperature_mean if current_weather else None, 0))}°C",
|
||||||
|
"subtitle": "دمای هوا",
|
||||||
|
**compute_trend(
|
||||||
|
current_weather.temperature_mean if current_weather else 0,
|
||||||
|
latest_history_value(history, "soil_temperature", 0),
|
||||||
|
),
|
||||||
|
"unit": "°C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": f"{round(safe_number(sensor.soil_temperature, 0))}°C",
|
||||||
|
"subtitle": "دمای خاک",
|
||||||
|
**compute_trend(sensor.soil_temperature, latest_history_value(history, "soil_temperature", 0)),
|
||||||
|
"unit": "°C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": f"{round(safe_number(current_weather.humidity_mean if current_weather else None, 0))}%",
|
||||||
|
"subtitle": "رطوبت هوا",
|
||||||
|
**compute_trend(current_weather.humidity_mean if current_weather else 0, 0),
|
||||||
|
"unit": "%",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": f"{round(safe_number(sensor.soil_moisture, 0))}%",
|
||||||
|
"subtitle": "رطوبت خاک (۱۰ سانتیمتر)",
|
||||||
|
**compute_trend(sensor.soil_moisture, latest_history_value(history, "soil_moisture", 0)),
|
||||||
|
"unit": "%",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": f"{safe_number(sensor.soil_ph, 0):.1f}",
|
||||||
|
"subtitle": "pH خاک",
|
||||||
|
**compute_trend(sensor.soil_ph, latest_history_value(history, "soil_ph", 0)),
|
||||||
|
"unit": "pH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": f"{safe_number(sensor.electrical_conductivity, 0):.1f}",
|
||||||
|
"subtitle": "هدایت الکتریکی (dS/m)",
|
||||||
|
**compute_trend(
|
||||||
|
sensor.electrical_conductivity,
|
||||||
|
latest_history_value(history, "electrical_conductivity", 0),
|
||||||
|
),
|
||||||
|
"unit": "dS/m",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "850",
|
||||||
|
"subtitle": "شدت نور (لوکس)",
|
||||||
|
"trendNumber": 0,
|
||||||
|
"trend": "positive",
|
||||||
|
"unit": "lux",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": f"{round(safe_number(current_weather.wind_speed_max if current_weather else None, 0))}",
|
||||||
|
"subtitle": "سرعت باد (کیلومتر/ساعت)",
|
||||||
|
**compute_trend(current_weather.wind_speed_max if current_weather else 0, 0),
|
||||||
|
"unit": "km/h",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return {"sensors": sensors}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from dashboard_data.card_utils import safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_soil_moisture_heatmap(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
context = context or {}
|
||||||
|
sensor = context.get("sensor")
|
||||||
|
depths = context.get("depths", [])
|
||||||
|
if sensor is None:
|
||||||
|
return {"zones": [], "hours": [], "series": []}
|
||||||
|
|
||||||
|
hours = ["۶ ص", "۸ ص", "۱۰ ص", "۱۲ ظ", "۱۴ ع", "۱۶ ع", "۱۸ ع"]
|
||||||
|
base_moisture = safe_number(sensor.soil_moisture, 0)
|
||||||
|
series = []
|
||||||
|
zones = []
|
||||||
|
|
||||||
|
if not depths:
|
||||||
|
depths = [None, None]
|
||||||
|
|
||||||
|
for index, depth in enumerate(depths[:7], start=1):
|
||||||
|
zones.append(f"زون {index}")
|
||||||
|
depth_offset = 0 if depth is None else round(safe_number(getattr(depth, "wv0033", None), 0) / 10)
|
||||||
|
data = []
|
||||||
|
for hour_index, hour in enumerate(hours):
|
||||||
|
value = max(0, min(100, round(base_moisture + depth_offset - abs(3 - hour_index) * 2)))
|
||||||
|
data.append({"x": hour, "y": value})
|
||||||
|
series.append({"name": f"زون {index}", "data": data})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"zones": zones,
|
||||||
|
"hours": hours,
|
||||||
|
"series": series,
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from dashboard_data.card_utils import safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_water_need_prediction(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
forecasts = (context or {}).get("forecasts", [])
|
||||||
|
daily_needs = []
|
||||||
|
for forecast in forecasts[:7]:
|
||||||
|
et0 = safe_number(forecast.et0, 4)
|
||||||
|
rain = safe_number(forecast.precipitation, 0)
|
||||||
|
need = max(0, round((et0 * 100) - (rain * 20)))
|
||||||
|
daily_needs.append(need)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"totalNext7Days": sum(daily_needs),
|
||||||
|
"unit": "m³",
|
||||||
|
"categories": [f"روز {index}" for index in range(1, len(daily_needs) + 1)],
|
||||||
|
"series": [{"name": "نیاز آبی", "data": daily_needs}],
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from dashboard_data.card_utils import safe_number
|
||||||
|
|
||||||
|
|
||||||
|
def build_yield_prediction_chart(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
sensor = (context or {}).get("sensor")
|
||||||
|
if sensor is None:
|
||||||
|
return {"categories": [], "series": [], "summary": []}
|
||||||
|
|
||||||
|
base = max(10, round(safe_number(sensor.soil_moisture, 0) * 0.6))
|
||||||
|
current_year = [base + offset for offset in [0, 2, 4, 6, 8, 10, 12, 11, 9, 7, 5, 4]]
|
||||||
|
last_year = [value - 3 for value in current_year]
|
||||||
|
harvest_month = "حدود " + str(date.today().month)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"categories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
|
||||||
|
"series": [
|
||||||
|
{"name": "امسال", "data": current_year},
|
||||||
|
{"name": "سال گذشته", "data": last_year},
|
||||||
|
],
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"title": "عملکرد پیشبینیشده",
|
||||||
|
"subtitle": "این فصل",
|
||||||
|
"amount": f"{current_year[9]} تن",
|
||||||
|
"avatarColor": "primary",
|
||||||
|
"avatarIcon": "tabler-chart-bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "تاریخ برداشت",
|
||||||
|
"subtitle": harvest_month,
|
||||||
|
"amount": "+8%",
|
||||||
|
"avatarColor": "success",
|
||||||
|
"avatarIcon": "tabler-calendar",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
def load_dashboard_context(sensor_id: str) -> dict | None:
|
||||||
|
from irrigation.models import IrrigationMethod
|
||||||
|
from location_data.models import SoilDepthData
|
||||||
|
from sensor_data.models import SensorData, SensorDataHistory
|
||||||
|
from weather.models import WeatherForecast
|
||||||
|
|
||||||
|
try:
|
||||||
|
sensor = SensorData.objects.select_related("location").prefetch_related("plants").get(
|
||||||
|
uuid_sensor=sensor_id
|
||||||
|
)
|
||||||
|
except SensorData.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
location = sensor.location
|
||||||
|
depths = list(
|
||||||
|
SoilDepthData.objects.filter(soil_location=location).order_by("depth_label")
|
||||||
|
)
|
||||||
|
forecasts = list(
|
||||||
|
WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today())
|
||||||
|
.order_by("forecast_date")[:7]
|
||||||
|
)
|
||||||
|
history = list(
|
||||||
|
SensorDataHistory.objects.filter(uuid_sensor=sensor_id).order_by("-recorded_at")[:30]
|
||||||
|
)
|
||||||
|
plants = list(sensor.plants.all())
|
||||||
|
irrigation_methods = list(IrrigationMethod.objects.all()[:5])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sensor": sensor,
|
||||||
|
"location": location,
|
||||||
|
"depths": depths,
|
||||||
|
"forecasts": forecasts,
|
||||||
|
"history": history,
|
||||||
|
"plants": plants,
|
||||||
|
"irrigation_methods": irrigation_methods,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DashboardAiRequestLog",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("sensor_id", models.UUIDField(db_index=True)),
|
||||||
|
("request_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("response_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("status", models.CharField(default="pending", max_length=32)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
"verbose_name": "Dashboard AI Request Log",
|
||||||
|
"verbose_name_plural": "Dashboard AI Request Logs",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DashboardCardSnapshot",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("sensor_id", models.UUIDField(db_index=True)),
|
||||||
|
("card_name", models.CharField(db_index=True, max_length=128)),
|
||||||
|
("payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("generated_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
("expires_at", models.DateTimeField(db_index=True)),
|
||||||
|
("source", models.CharField(default="computed", max_length=32)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-generated_at"],
|
||||||
|
"verbose_name": "Dashboard Card Snapshot",
|
||||||
|
"verbose_name_plural": "Dashboard Card Snapshots",
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["sensor_id", "card_name", "-generated_at"], name="dashboard_d_sensor__c0a279_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardCardSnapshot(models.Model):
|
||||||
|
sensor_id = models.UUIDField(db_index=True)
|
||||||
|
card_name = models.CharField(max_length=128, db_index=True)
|
||||||
|
payload = models.JSONField(default=dict, blank=True)
|
||||||
|
generated_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
expires_at = models.DateTimeField(db_index=True)
|
||||||
|
source = models.CharField(max_length=32, default="computed")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-generated_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["sensor_id", "card_name", "-generated_at"]),
|
||||||
|
]
|
||||||
|
verbose_name = "Dashboard Card Snapshot"
|
||||||
|
verbose_name_plural = "Dashboard Card Snapshots"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.card_name} - {self.sensor_id} - {self.generated_at}"
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardAiRequestLog(models.Model):
|
||||||
|
sensor_id = models.UUIDField(db_index=True)
|
||||||
|
request_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
response_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
status = models.CharField(max_length=32, default="pending")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
verbose_name = "Dashboard AI Request Log"
|
||||||
|
verbose_name_plural = "Dashboard AI Request Logs"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.sensor_id} - {self.status} - {self.created_at}"
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .ai_bundle import request_dashboard_ai_bundle
|
||||||
|
from .card_utils import is_fresh, ttl_for_card
|
||||||
|
from .cards.anomaly_detection_card import build_anomaly_detection_card
|
||||||
|
from .cards.economic_overview import build_economic_overview
|
||||||
|
from .cards.farm_alerts_timeline import build_farm_alerts_timeline
|
||||||
|
from .cards.farm_alerts_tracker import build_farm_alerts_tracker
|
||||||
|
from .cards.farm_overview_kpis import build_farm_overview_kpis
|
||||||
|
from .cards.farm_weather_card import build_farm_weather_card
|
||||||
|
from .cards.harvest_prediction_card import build_harvest_prediction_card
|
||||||
|
from .cards.ndvi_health_card import build_ndvi_health_card
|
||||||
|
from .cards.recommendations_list import build_recommendations_list
|
||||||
|
from .cards.sensor_comparison_chart import build_sensor_comparison_chart
|
||||||
|
from .cards.sensor_radar_chart import build_sensor_radar_chart
|
||||||
|
from .cards.sensor_values_list import build_sensor_values_list
|
||||||
|
from .cards.soil_moisture_heatmap import build_soil_moisture_heatmap
|
||||||
|
from .cards.water_need_prediction import build_water_need_prediction
|
||||||
|
from .cards.yield_prediction_chart import build_yield_prediction_chart
|
||||||
|
from .context import load_dashboard_context
|
||||||
|
from .models import DashboardCardSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
CARD_BUILDERS = {
|
||||||
|
"farmOverviewKpis": build_farm_overview_kpis,
|
||||||
|
"farmWeatherCard": build_farm_weather_card,
|
||||||
|
"farmAlertsTracker": build_farm_alerts_tracker,
|
||||||
|
"sensorValuesList": build_sensor_values_list,
|
||||||
|
"sensorRadarChart": build_sensor_radar_chart,
|
||||||
|
"sensorComparisonChart": build_sensor_comparison_chart,
|
||||||
|
"anomalyDetectionCard": build_anomaly_detection_card,
|
||||||
|
"farmAlertsTimeline": build_farm_alerts_timeline,
|
||||||
|
"waterNeedPrediction": build_water_need_prediction,
|
||||||
|
"harvestPredictionCard": build_harvest_prediction_card,
|
||||||
|
"yieldPredictionChart": build_yield_prediction_chart,
|
||||||
|
"soilMoistureHeatmap": build_soil_moisture_heatmap,
|
||||||
|
"ndviHealthCard": build_ndvi_health_card,
|
||||||
|
"recommendationsList": build_recommendations_list,
|
||||||
|
"economicOverview": build_economic_overview,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
AI_DRIVEN_CARDS = {
|
||||||
|
"farmAlertsTimeline",
|
||||||
|
"recommendationsList",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_payload(sensor_id: str) -> dict:
|
||||||
|
context = load_dashboard_context(sensor_id)
|
||||||
|
if context is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
ai_payload_request = {
|
||||||
|
"sensor_id": sensor_id,
|
||||||
|
"cards": sorted(AI_DRIVEN_CARDS),
|
||||||
|
}
|
||||||
|
ai_bundle = request_dashboard_ai_bundle(sensor_id=sensor_id, payload=ai_payload_request)
|
||||||
|
|
||||||
|
return {
|
||||||
|
card_name: builder(sensor_id=sensor_id, context=context, ai_bundle=ai_bundle)
|
||||||
|
for card_name, builder in CARD_BUILDERS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_build_card(sensor_id: str, card_name: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
|
latest_snapshot = (
|
||||||
|
DashboardCardSnapshot.objects.filter(sensor_id=sensor_id, card_name=card_name)
|
||||||
|
.order_by("-generated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if is_fresh(latest_snapshot):
|
||||||
|
return latest_snapshot.payload
|
||||||
|
|
||||||
|
payload = CARD_BUILDERS[card_name](sensor_id=sensor_id, context=context, ai_bundle=ai_bundle)
|
||||||
|
DashboardCardSnapshot.objects.create(
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
card_name=card_name,
|
||||||
|
payload=payload,
|
||||||
|
expires_at=timezone.now() + ttl_for_card(card_name),
|
||||||
|
source="ai" if card_name in AI_DRIVEN_CARDS else "computed",
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_payload_with_cache(sensor_id: str) -> dict:
|
||||||
|
context = load_dashboard_context(sensor_id)
|
||||||
|
if context is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
ai_payload_request = {
|
||||||
|
"sensor_id": sensor_id,
|
||||||
|
"cards": sorted(AI_DRIVEN_CARDS),
|
||||||
|
}
|
||||||
|
ai_bundle = request_dashboard_ai_bundle(sensor_id=sensor_id, payload=ai_payload_request)
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
for card_name in CARD_BUILDERS:
|
||||||
|
payload[card_name] = get_or_build_card(
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
card_name=card_name,
|
||||||
|
context=context,
|
||||||
|
ai_bundle=ai_bundle,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from config.celery import app
|
||||||
|
|
||||||
|
from .services import CARD_BUILDERS, build_dashboard_payload_with_cache
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(bind=True)
|
||||||
|
def generate_dashboard_data_task(self, sensor_id: str) -> dict:
|
||||||
|
total_cards = len(CARD_BUILDERS)
|
||||||
|
self.update_state(
|
||||||
|
state="PROGRESS",
|
||||||
|
meta={
|
||||||
|
"current": 0,
|
||||||
|
"total": total_cards,
|
||||||
|
"card": None,
|
||||||
|
"message": "loading sensor context",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = {}
|
||||||
|
dashboard_payload = build_dashboard_payload_with_cache(sensor_id)
|
||||||
|
|
||||||
|
for index, card_name in enumerate(CARD_BUILDERS.keys(), start=1):
|
||||||
|
self.update_state(
|
||||||
|
state="PROGRESS",
|
||||||
|
meta={
|
||||||
|
"current": index,
|
||||||
|
"total": total_cards,
|
||||||
|
"card": card_name,
|
||||||
|
"message": f"processing {card_name}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload[card_name] = dashboard_payload.get(card_name, {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sensor_id": sensor_id,
|
||||||
|
"all_cards": payload,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import DashboardDataGenerateView, DashboardDataStatusView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("generate/", DashboardDataGenerateView.as_view(), name="dashboard-data-generate"),
|
||||||
|
path("<str:task_id>/status/", DashboardDataStatusView.as_view(), name="dashboard-data-status"),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
||||||
|
from rest_framework import serializers as drf_serializers
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from .tasks import generate_dashboard_data_task
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardDataGenerateView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Dashboard Data"],
|
||||||
|
summary="Generate dashboard data",
|
||||||
|
request=inline_serializer(
|
||||||
|
name="DashboardDataGenerateRequest",
|
||||||
|
fields={
|
||||||
|
"sensor_id": drf_serializers.UUIDField(required=False),
|
||||||
|
"snesor_id": drf_serializers.UUIDField(required=False),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
responses={
|
||||||
|
202: inline_serializer(
|
||||||
|
name="DashboardDataGenerateResponse",
|
||||||
|
fields={
|
||||||
|
"code": drf_serializers.IntegerField(),
|
||||||
|
"msg": drf_serializers.CharField(),
|
||||||
|
"data": inline_serializer(
|
||||||
|
name="DashboardDataGenerateResponseData",
|
||||||
|
fields={
|
||||||
|
"task_id": drf_serializers.CharField(),
|
||||||
|
"status_url": drf_serializers.CharField(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
400: OpenApiResponse(description="Invalid input"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
sensor_id = request.data.get("sensor_id") or request.data.get("snesor_id")
|
||||||
|
if not sensor_id:
|
||||||
|
return Response(
|
||||||
|
{"code": 400, "msg": "پارامتر sensor_id الزامی است.", "data": None},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
sensor_id = str(UUID(str(sensor_id)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response(
|
||||||
|
{"code": 400, "msg": "sensor_id باید UUID معتبر باشد.", "data": None},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
task = generate_dashboard_data_task.delay(sensor_id)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"code": 202,
|
||||||
|
"msg": "dashboard task queued",
|
||||||
|
"data": {
|
||||||
|
"task_id": task.id,
|
||||||
|
"status_url": f"/api/dashboard-data/{task.id}/status/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardDataStatusView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Dashboard Data"],
|
||||||
|
summary="Dashboard task status",
|
||||||
|
)
|
||||||
|
def get(self, request, task_id):
|
||||||
|
result = AsyncResult(task_id)
|
||||||
|
data = {"task_id": task_id, "status": result.state}
|
||||||
|
if result.state == "PENDING":
|
||||||
|
data["message"] = "تسک در صف یا یافت نشد."
|
||||||
|
elif result.state == "PROGRESS":
|
||||||
|
data["progress"] = result.info
|
||||||
|
elif result.state == "SUCCESS":
|
||||||
|
data["result"] = result.result
|
||||||
|
elif result.state == "FAILURE":
|
||||||
|
data["error"] = str(result.result)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"code": 200, "msg": "success", "data": data},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user