AI UPDATE

This commit is contained in:
2026-03-22 01:09:09 +03:30
parent c37b5c8558
commit 3ee14ca977
30 changed files with 1011 additions and 0 deletions
+2
View File
@@ -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)"},
+1
View File
@@ -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")),
+1
View File
@@ -0,0 +1 @@
+17
View File
@@ -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": [],
}
+8
View File
@@ -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"
+81
View File
@@ -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, "نامشخص")
+1
View File
@@ -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}
+50
View File
@@ -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",
},
]
}
+32
View File
@@ -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),
}
+30
View File
@@ -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": "",
"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",
},
],
}
+40
View File
@@ -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,
}
+47
View File
@@ -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"),
],
},
),
]
+1
View File
@@ -0,0 +1 @@
+38
View File
@@ -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}"
+105
View File
@@ -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
+36
View File
@@ -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,
}
+9
View File
@@ -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"),
]
+91
View File
@@ -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,
)