This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class DashboardConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "dashboard"
verbose_name = "Farm Dashboard"
+42
View File
@@ -0,0 +1,42 @@
from copy import deepcopy
VALID_ROW_IDS = [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic",
]
VALID_CARD_IDS = [
"farmOverviewKpis",
"farmWeatherCard",
"farmAlertsTracker",
"sensorValuesList",
"sensorRadarChart",
"sensorComparisonChart",
"anomalyDetectionCard",
"farmAlertsTimeline",
"waterNeedPrediction",
"harvestPredictionCard",
"yieldPredictionChart",
"soilMoistureHeatmap",
"ndviHealthCard",
"recommendationsList",
"economicOverview",
]
DEFAULT_CONFIG = {
"disabled_card_ids": [],
"row_order": VALID_ROW_IDS.copy(),
"enable_drag_reorder": True,
}
def get_default_dashboard_config():
return deepcopy(DEFAULT_CONFIG)
@@ -0,0 +1,36 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("farm_hub", "0002_seed_default_catalog"),
]
operations = [
migrations.CreateModel(
name="FarmDashboardConfig",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("disabled_card_ids", models.JSONField(blank=True, default=list)),
("row_order", models.JSONField(default=list)),
("enable_drag_reorder", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"farm",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="dashboard_config",
to="farm_hub.farmhub",
),
),
],
options={
"db_table": "farm_dashboard_configs",
"ordering": ["-updated_at", "-id"],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-04-25 21:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='farmdashboardconfig',
name='row_order',
field=models.JSONField(blank=True, default=list),
),
]
+21
View File
@@ -0,0 +1,21 @@
"""
Backward-compatible mock exports for dashboard fake content.
Use `dashboard.defaults` for runtime configuration defaults and
`dashboard.templates` for fallback card payload templates.
"""
from .defaults import DEFAULT_CONFIG, VALID_CARD_IDS, VALID_ROW_IDS
from .templates import (
ALL_CARD_TEMPLATES as ALL_CARDS,
ECONOMIC_OVERVIEW,
FARM_ALERTS_TIMELINE,
FARM_ALERTS_TRACKER,
FARM_OVERVIEW_KPIS,
FARM_WEATHER_CARD,
HARVEST_PREDICTION_CARD,
RECOMMENDATIONS_LIST,
SENSOR_VALUES_LIST,
WATER_NEED_PREDICTION,
YIELD_PREDICTION_CHART,
)
+23
View File
@@ -0,0 +1,23 @@
from django.db import models
from farm_hub.models import FarmHub
class FarmDashboardConfig(models.Model):
farm = models.OneToOneField(
FarmHub,
on_delete=models.CASCADE,
related_name="dashboard_config",
)
disabled_card_ids = models.JSONField(default=list, blank=True)
row_order = models.JSONField(default=list, blank=True)
enable_drag_reorder = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "farm_dashboard_configs"
ordering = ["-updated_at", "-id"]
def __str__(self):
return f"Dashboard config for {self.farm.name}"
File diff suppressed because one or more lines are too long
+61
View File
@@ -0,0 +1,61 @@
from rest_framework import serializers
from .defaults import VALID_CARD_IDS, VALID_ROW_IDS
class FarmDashboardConfigSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(read_only=True)
disabled_card_ids = serializers.ListField(
child=serializers.CharField(),
allow_empty=True,
)
row_order = serializers.ListField(
child=serializers.CharField(),
allow_empty=False,
)
enable_drag_reorder = serializers.BooleanField()
def validate_disabled_card_ids(self, value):
invalid_ids = [card_id for card_id in value if card_id not in VALID_CARD_IDS]
if invalid_ids:
raise serializers.ValidationError(
f"Invalid card IDs: {', '.join(invalid_ids)}."
)
if len(set(value)) != len(value):
raise serializers.ValidationError("disabled_card_ids must not contain duplicates.")
return value
def validate_row_order(self, value):
invalid_ids = [row_id for row_id in value if row_id not in VALID_ROW_IDS]
if invalid_ids:
raise serializers.ValidationError(
f"Invalid row IDs: {', '.join(invalid_ids)}."
)
if len(set(value)) != len(value):
raise serializers.ValidationError("row_order must not contain duplicates.")
if set(value) != set(VALID_ROW_IDS):
raise serializers.ValidationError(
"row_order must contain each valid row ID exactly once."
)
return value
class FarmDashboardConfigPatchSerializer(FarmDashboardConfigSerializer):
farm_uuid = serializers.UUIDField(required=True)
disabled_card_ids = serializers.ListField(
child=serializers.CharField(),
allow_empty=True,
required=False,
)
row_order = serializers.ListField(
child=serializers.CharField(),
allow_empty=False,
required=False,
)
enable_drag_reorder = serializers.BooleanField(required=False)
def validate(self, attrs):
attrs = super().validate(attrs)
if set(attrs.keys()) == {"farm_uuid"}:
raise serializers.ValidationError("At least one config field must be provided.")
return attrs
+185
View File
@@ -0,0 +1,185 @@
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.services import get_fertilization_dashboard_recommendation
from irrigation.services import get_irrigation_dashboard_recommendation
from pest_detection.services import get_risk_summary_data
from device_hub.services import (
get_sensor_7_in_1_summary_data,
)
from yield_harvest.services import get_yield_harvest_summary_data
from .templates import get_all_card_templates
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_quality_score_card(yield_summary):
quality_card = {
"id": "quality_score",
"title": "امتیاز کیفیت",
"subtitle": "برآورد کیفیت محصول",
"stats": "۵۹",
"avatarColor": "info",
"avatarIcon": "tabler-stars",
"chipText": "متوسط",
"chipColor": "warning",
}
chart_summary = yield_summary.get("yield_prediction_chart", {}).get("summary", [])
if not isinstance(chart_summary, list):
return quality_card
for item in chart_summary:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
if "کیفیت" not in title:
continue
amount = item.get("amount")
subtitle = item.get("subtitle")
if amount not in (None, ""):
quality_card["stats"] = str(amount)
if subtitle:
quality_card["chipText"] = str(subtitle)
quality_card["chipColor"] = "warning"
return quality_card
return quality_card
def _build_days_until_harvest_card(yield_summary):
harvest_card = yield_summary.get("harvest_prediction_card", {})
days_until = harvest_card.get("daysUntil")
card = {
"id": "days_until_harvest",
"title": "روز تا برداشت",
"subtitle": "زمان باقیمانده تا پنجره برداشت",
"stats": "۱۳۵",
"avatarColor": "warning",
"avatarIcon": "tabler-calendar-event",
"chipText": "برنامه ریزی",
"chipColor": "success",
}
if days_until is not None:
card["stats"] = str(days_until)
return card
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", {}))
_update_kpi(card_lookup, _build_quality_score_card(yield_summary))
_update_kpi(card_lookup, _build_days_until_harvest_card(yield_summary))
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 = get_all_card_templates()
water_cards = {
"farmWeatherCard": get_farm_weather_card_data(farm),
"waterNeedPrediction": get_water_need_prediction_data(farm),
"waterStressIndex": get_water_stress_index_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)
sensor_summary = get_sensor_7_in_1_summary_data(farm)
alert_cards = {
"farmAlertsTracker": get_alert_tracker_data(farm),
"farmAlertsTimeline": get_alert_timeline_data(farm),
}
economic_overview = get_economic_overview_data(farm)
avg_soil_moisture = sensor_summary["avgSoilMoisture"]
cards["farmWeatherCard"] = water_cards["farmWeatherCard"]
cards["farmAlertsTracker"] = alert_cards["farmAlertsTracker"]
cards["farmAlertsTimeline"] = alert_cards["farmAlertsTimeline"]
cards["sensorValuesList"] = sensor_summary["sensorValuesList"]
cards["anomalyDetectionCard"] = sensor_summary["anomalyDetectionCard"]
cards["waterNeedPrediction"] = water_cards["waterNeedPrediction"]
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"] = economic_overview
cards["farmOverviewKpis"] = _build_overview_kpis(
cards["farmOverviewKpis"],
crop_health_summary,
water_cards["waterStressIndex"],
avg_soil_moisture,
risk_summary,
yield_summary,
)
cards["recommendationsList"] = _build_recommendations_list(
farm,
cards["recommendationsList"],
yield_summary.get("harvest_prediction_card", {}),
)
return cards
+318
View File
@@ -0,0 +1,318 @@
"""
Static dashboard payload templates used only as fallback content.
"""
from copy import deepcopy
FARM_OVERVIEW_KPIS = {
"kpis": [
{
"id": "disease_risk",
"title": "ریسک بیماری",
"subtitle": "پیش بینی هوشمند",
"stats": "پایین",
"avatarColor": "success",
"avatarIcon": "tabler-biohazard",
"chipText": "32%",
"chipColor": "success",
},
{
"id": "pest_risk",
"title": "ریسک آفات",
"subtitle": "پیش بینی هوشمند",
"stats": "پایین",
"avatarColor": "success",
"avatarIcon": "tabler-bug",
"chipText": "22%",
"chipColor": "success",
},
{
"id": "yield_prediction",
"title": "عملکرد پیش بینی شده",
"subtitle": "پیش بینی عملکرد این مزرعه",
"stats": "۰ تن",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-arcs",
"chipText": "نیازمند بررسی",
"chipColor": "warning",
},
{
"id": "quality_score",
"title": "امتیاز کیفیت",
"subtitle": "برآورد کیفیت محصول",
"stats": "۵۹",
"avatarColor": "info",
"avatarIcon": "tabler-stars",
"chipText": "متوسط",
"chipColor": "warning",
},
{
"id": "days_until_harvest",
"title": "روز تا برداشت",
"subtitle": "زمان باقیمانده تا پنجره برداشت",
"stats": "۱۳۵",
"avatarColor": "warning",
"avatarIcon": "tabler-calendar-event",
"chipText": "برنامه ریزی",
"chipColor": "warning",
},
]
}
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]],
},
}
FARM_ALERTS_TRACKER = {
"totalAlerts": 3,
"radialBarValue": 30,
"alertStats": [
{
"title": "کمبود آب",
"count": "2",
"avatarColor": "error",
"avatarIcon": "tabler-droplet-half-2",
},
{
"title": "ریسک قارچی",
"count": "1",
"avatarColor": "warning",
"avatarIcon": "tabler-mushroom",
},
{
"title": "هشدار یخبندان",
"count": "0",
"avatarColor": "info",
"avatarIcon": "tabler-snowflake",
},
],
}
SENSOR_VALUES_LIST = {
"sensors": [
{
"title": "28°C",
"subtitle": "دمای هوا",
"trendNumber": 2.1,
"trend": "positive",
"unit": "°C",
},
{
"title": "24°C",
"subtitle": "دمای خاک",
"trendNumber": -0.5,
"trend": "negative",
"unit": "°C",
},
{
"title": "65%",
"subtitle": "رطوبت هوا",
"trendNumber": 3.2,
"trend": "positive",
"unit": "%",
},
{
"title": "42%",
"subtitle": "رطوبت خاک (۱۰ سانتی‌متر)",
"trendNumber": -1.8,
"trend": "negative",
"unit": "%",
},
{
"title": "6.8",
"subtitle": "pH خاک",
"trendNumber": 0.2,
"trend": "positive",
"unit": "pH",
},
{
"title": "1.2",
"subtitle": "هدایت الکتریکی (dS/m)",
"trendNumber": 0.1,
"trend": "positive",
"unit": "dS/m",
},
{
"title": "850",
"subtitle": "شدت نور (لوکس)",
"trendNumber": 15.3,
"trend": "positive",
"unit": "lux",
},
{
"title": "12",
"subtitle": "سرعت باد (کیلومتر/ساعت)",
"trendNumber": -2.4,
"trend": "negative",
"unit": "km/h",
},
]
}
FARM_ALERTS_TIMELINE = {
"alerts": [
{
"title": "ریسک کمبود آب",
"description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.",
"time": "۱۵ دقیقه پیش",
"color": "warning",
},
{
"title": "ریسک بیماری قارچی",
"description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.",
"time": "۱ ساعت پیش",
"color": "error",
},
{
"title": "پیشنهاد آبیاری",
"description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.",
"time": "۲ ساعت پیش",
"color": "info",
},
{
"title": "بررسی شوری خاک",
"description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.",
"time": "۴ ساعت پیش",
"color": "success",
},
]
}
WATER_NEED_PREDICTION = {
"totalNext7Days": 3290,
"unit": "",
"categories": ["روز ۱", "روز ۲", "روز ۳", "روز ۴", "روز ۵", "روز ۶", "روز ۷"],
"series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}],
}
HARVEST_PREDICTION_CARD = {
"date": "2025-10-15",
"dateFormatted": "۱۵ اکتبر ۲۰۲۵",
"daysUntil": 58,
"description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.",
"optimalWindowStart": "2025-10-12",
"optimalWindowEnd": "2025-10-18",
}
YIELD_PREDICTION_CHART = {
"categories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
"series": [
{"name": "امسال", "data": [35, 38, 40, 42, 45, 48, 50, 48, 46, 44, 42, 42]},
{"name": "سال گذشته", "data": [32, 34, 36, 38, 40, 42, 44, 42, 40, 38, 36, 38]},
],
"summary": [
{
"title": "عملکرد پیش‌بینی‌شده",
"subtitle": "این فصل",
"amount": "42 تن",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-bar",
},
{
"title": "تاریخ برداشت",
"subtitle": "حدود ۱۵ اکتبر",
"amount": "+8%",
"avatarColor": "success",
"avatarIcon": "tabler-calendar",
},
],
}
RECOMMENDATIONS_LIST = {
"recommendations": [
{
"title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح",
"subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
},
{
"title": "کود: NPK 20-20-20",
"subtitle": "اعمال ۲۵ کیلوگرم در هکتار ظرف ۷ روز. کمبود نیتروژن فعلی در بخش ۲.",
"avatarIcon": "tabler-leaf",
"avatarColor": "success",
},
{
"title": "قارچ‌کش: پیشگیرانه",
"subtitle": "رطوبت و دما مساعد قارچ. سمپاشی بر پایه مس را در نظر بگیرید.",
"avatarIcon": "tabler-mushroom",
"avatarColor": "warning",
},
{
"title": "بازه برداشت: ۱۲ تا ۱۸ اکتبر",
"subtitle": "اوج رسیدگی حدود ۱۵ اکتبر. نیروی کار را متناسب برنامه‌ریزی کنید.",
"avatarIcon": "tabler-calendar-event",
"avatarColor": "info",
},
]
}
ECONOMIC_OVERVIEW = {
"economicData": [
{
"title": "هزینه آب",
"value": "€720",
"subtitle": "این ماه",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
},
{
"title": "صرفه‌جویی آب هوشمند",
"value": "€156",
"subtitle": "۱۸٪ صرفه‌جویی شده",
"avatarIcon": "tabler-bulb",
"avatarColor": "success",
},
{
"title": "بازده سرمایه پلتفرم",
"value": "127%",
"subtitle": "نسبت به سال گذشته",
"avatarIcon": "tabler-chart-line",
"avatarColor": "info",
},
{
"title": "پیش‌بینی درآمد",
"value": "€42k",
"subtitle": "این فصل",
"avatarIcon": "tabler-currency-euro",
"avatarColor": "success",
},
],
"chartSeries": [
{"name": "هزینه آب", "data": [120, 115, 110, 125, 118, 122]},
{"name": "کود", "data": [80, 85, 90, 75, 82, 78]},
],
"chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"],
}
ALL_CARD_TEMPLATES = {
"farmOverviewKpis": FARM_OVERVIEW_KPIS,
"farmWeatherCard": FARM_WEATHER_CARD,
"farmAlertsTracker": FARM_ALERTS_TRACKER,
"sensorValuesList": SENSOR_VALUES_LIST,
"sensorRadarChart": {},
"sensorComparisonChart": {},
"anomalyDetectionCard": {},
"farmAlertsTimeline": FARM_ALERTS_TIMELINE,
"waterNeedPrediction": WATER_NEED_PREDICTION,
"harvestPredictionCard": HARVEST_PREDICTION_CARD,
"yieldPredictionChart": YIELD_PREDICTION_CHART,
"soilMoistureHeatmap": {},
"ndviHealthCard": {},
"recommendationsList": RECOMMENDATIONS_LIST,
"economicOverview": ECONOMIC_OVERVIEW,
}
def get_all_card_templates():
return deepcopy(ALL_CARD_TEMPLATES)
+186
View File
@@ -0,0 +1,186 @@
from copy import deepcopy
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from access_control.models import AccessFeature, AccessRule
from farm_hub.models import FarmHub, FarmType
from .defaults import DEFAULT_CONFIG
from .models import FarmDashboardConfig
from .views import FarmDashboardCardsView, FarmDashboardConfigView
class DashboardBaseTestCase(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="farmer",
password="secret123",
email="farmer@example.com",
phone_number="09120000000",
)
self.other_user = get_user_model().objects.create_user(
username="other-farmer",
password="secret123",
email="other@example.com",
phone_number="09120000001",
)
self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1")
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2")
self.dashboard_feature = AccessFeature.objects.create(
code="greenhouse-dashboard",
name="Greenhouse Dashboard",
feature_type=AccessFeature.PAGE,
)
self.allow_dashboard_rule = AccessRule.objects.create(
code="allow-greenhouse-dashboard",
name="Allow Greenhouse Dashboard",
priority=10,
)
self.allow_dashboard_rule.features.add(self.dashboard_feature)
class FarmDashboardConfigViewTests(DashboardBaseTestCase):
def test_get_returns_default_config_and_persists_it(self):
request = self.factory.get(f"/api/farm-dashboard-config/?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG)
expected["farm_uuid"] = str(self.farm.farm_uuid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["msg"], "OK")
self.assertEqual(response.data["data"], expected)
self.assertTrue(FarmDashboardConfig.objects.filter(farm=self.farm).exists())
def test_get_requires_farm_uuid(self):
request = self.factory.get("/api/farm-dashboard-config/")
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
def test_get_rejects_foreign_farm_uuid(self):
request = self.factory.get(f"/api/farm-dashboard-config/?farm_uuid={self.other_farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "Farm not found.")
def test_patch_partial_update_returns_full_final_config(self):
request = self.factory.patch(
"/api/farm-dashboard-config/",
{
"farm_uuid": str(self.farm.farm_uuid),
"disabled_card_ids": ["farmWeatherCard"],
},
format="json",
)
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG)
expected["farm_uuid"] = str(self.farm.farm_uuid)
expected["disabled_card_ids"] = ["farmWeatherCard"]
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"], expected)
self.assertEqual(
FarmDashboardConfig.objects.get(farm=self.farm).disabled_card_ids,
["farmWeatherCard"],
)
def test_patch_only_drag_flag_still_returns_full_config(self):
request = self.factory.patch(
"/api/farm-dashboard-config/",
{
"farm_uuid": str(self.farm.farm_uuid),
"enable_drag_reorder": False,
},
format="json",
)
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG)
expected["farm_uuid"] = str(self.farm.farm_uuid)
expected["enable_drag_reorder"] = False
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"], expected)
self.assertIn("disabled_card_ids", response.data["data"])
self.assertIn("row_order", response.data["data"])
def test_patch_rejects_invalid_row_order(self):
request = self.factory.patch(
"/api/farm-dashboard-config/",
{
"farm_uuid": str(self.farm.farm_uuid),
"row_order": ["overviewKpis"],
},
format="json",
)
force_authenticate(request, user=self.user)
response = FarmDashboardConfigView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertIn("row_order", response.data)
class FarmDashboardCardsViewTests(DashboardBaseTestCase):
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)
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")
kpi_ids = [item["id"] for item in response.data["data"]["farmOverviewKpis"]["kpis"]]
self.assertIn("disease_risk", kpi_ids)
self.assertIn("pest_risk", kpi_ids)
self.assertIn("yield_prediction", kpi_ids)
self.assertIn("quality_score", kpi_ids)
self.assertIn("days_until_harvest", kpi_ids)
def test_get_requires_farm_uuid(self):
request = self.factory.get("/api/farm-dashboard/")
force_authenticate(request, user=self.user)
response = FarmDashboardCardsView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
def test_get_denies_access_when_feature_is_blocked(self):
deny_rule = AccessRule.objects.create(
code="deny-greenhouse-dashboard",
name="Deny Greenhouse Dashboard",
priority=20,
effect=AccessRule.DENY,
)
deny_rule.features.add(self.dashboard_feature)
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, 403)
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .views import FarmDashboardCardsView
urlpatterns = [
# path("cards/", FarmDashboardCardsView.as_view(), name="farm-dashboard-cards"),
path("", FarmDashboardCardsView.as_view(), name="farm-dashboard"),
]
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import FarmDashboardConfigView
urlpatterns = [
path("", FarmDashboardConfigView.as_view(), name="farm-dashboard-config"),
]
+132
View File
@@ -0,0 +1,132 @@
"""
Farm Dashboard API views.
"""
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, extend_schema_view
from config.swagger import code_response
from farm_hub.models import FarmHub
from .defaults import get_default_dashboard_config
from .services import get_farm_dashboard_cards
from .models import FarmDashboardConfig
from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer
class FarmAccessMixin:
@staticmethod
def _get_farm(request, 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
@staticmethod
def _get_or_create_dashboard_config(farm):
default_config = get_default_dashboard_config()
config, _created = FarmDashboardConfig.objects.get_or_create(
farm=farm,
defaults={
"disabled_card_ids": default_config["disabled_card_ids"],
"row_order": default_config["row_order"],
"enable_drag_reorder": default_config["enable_drag_reorder"],
},
)
return config
@staticmethod
def _serialize_config(config):
return {
"farm_uuid": str(config.farm.farm_uuid),
"disabled_card_ids": config.disabled_card_ids,
"row_order": config.row_order,
"enable_drag_reorder": config.enable_drag_reorder,
}
@extend_schema_view(
get=extend_schema(
tags=["Farm Dashboard"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"),
],
responses={200: code_response("FarmDashboardConfigGetResponse", data=FarmDashboardConfigSerializer())},
),
patch=extend_schema(
tags=["Farm Dashboard"],
request=FarmDashboardConfigPatchSerializer,
responses={200: code_response("FarmDashboardConfigPatchResponse", data=FarmDashboardConfigSerializer())},
),
)
class FarmDashboardConfigView(FarmAccessMixin, APIView):
"""
Farm dashboard config endpoints.
GET/PATCH are persisted in DB per farm.
"""
permission_classes = [IsAuthenticated]
required_feature_code = "greenhouse-dashboard"
def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
config = self._get_or_create_dashboard_config(farm)
return Response(
{"code": 200, "msg": "OK", "data": self._serialize_config(config)},
status=status.HTTP_200_OK,
)
def patch(self, request):
serializer = FarmDashboardConfigPatchSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request, serializer.validated_data["farm_uuid"])
config = self._get_or_create_dashboard_config(farm)
update_fields = ["updated_at"]
if "disabled_card_ids" in serializer.validated_data:
config.disabled_card_ids = serializer.validated_data["disabled_card_ids"]
update_fields.append("disabled_card_ids")
if "row_order" in serializer.validated_data:
config.row_order = serializer.validated_data["row_order"]
update_fields.append("row_order")
if "enable_drag_reorder" in serializer.validated_data:
config.enable_drag_reorder = serializer.validated_data["enable_drag_reorder"]
update_fields.append("enable_drag_reorder")
config.save(update_fields=update_fields)
return Response(
{"code": 200, "msg": "OK", "data": self._serialize_config(config)},
status=status.HTTP_200_OK,
)
@extend_schema_view(
get=extend_schema(
tags=["Farm Dashboard"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"),
],
responses={200: code_response("FarmDashboardCardsResponse", data=serializers.JSONField())},
),
)
class FarmDashboardCardsView(FarmAccessMixin, APIView):
"""
Farm dashboard cards endpoint: GET.
Requires farm_uuid and assembles local dashboard services.
"""
permission_classes = [IsAuthenticated]
required_feature_code = "greenhouse-dashboard"
def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
return Response(
{"code": 200, "msg": "OK", "data": get_farm_dashboard_cards(farm)},
status=status.HTTP_200_OK,
)