This commit is contained in:
2026-03-26 15:39:31 +03:30
parent f305e00cfe
commit 32a0e3f3d9
26 changed files with 2188 additions and 265 deletions
+357
View File
@@ -0,0 +1,357 @@
# مستند ارتباط فرانت با API تنظیمات داشبورد فارم
این فایل مشخص می‌کند فرانت‌اند برای endpoint زیر چه request و responseی انتظار دارد:
```text
http://localhost:8000/api/farm-dashboard-config
```
این endpoint در فرانت از طریق فایل `src/libs/api/services/farmDashboardService.ts` مصرف می‌شود.
---
## خلاصه رفتار فرانت
- فرانت برای دریافت تنظیمات از `GET /api/farm-dashboard-config` استفاده می‌کند.
- فرانت برای ذخیره تغییرات از `PATCH /api/farm-dashboard-config` استفاده می‌کند.
- در `PATCH` فقط فیلدهای تغییرکرده ارسال می‌شوند.
- اما در response بهتر است همیشه **کل تنظیمات نهایی** برگردانده شود.
- فرانت response را هم در حالت wrapper شده و هم بدون wrapper می‌پذیرد.
---
## فرمت response قابل قبول
فرانت هر دو فرمت زیر را می‌پذیرد.
### فرمت پیشنهادی
```json
{
"code": 200,
"msg": "OK",
"data": {
"disabled_card_ids": ["farmWeatherCard", "sensorRadarChart"],
"row_order": [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic"
],
"enable_drag_reorder": true
}
}
```
### فرمت قابل قبول بدون wrapper
```json
{
"disabled_card_ids": ["farmWeatherCard", "sensorRadarChart"],
"row_order": [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic"
],
"enable_drag_reorder": true
}
```
---
## GET
### Request
```http
GET /api/farm-dashboard-config
Content-Type: application/json
Authorization: Bearer <token>
```
> هدر `Authorization` فقط وقتی ارسال می‌شود که توکن در `localStorage` موجود باشد.
### Response مورد انتظار
فرانت در خروجی GET این ساختار را انتظار دارد:
| فیلد | نوع | توضیح |
|---|---|---|
| `disabled_card_ids` | `string[]` | لیست کارت‌های مخفی‌شده |
| `row_order` | `string[]` | ترتیب ردیف‌های داشبورد |
| `enable_drag_reorder` | `boolean` | فعال/غیرفعال بودن drag reorder |
### مثال response
```json
{
"code": 200,
"msg": "OK",
"data": {
"disabled_card_ids": ["farmWeatherCard", "sensorRadarChart"],
"row_order": [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic"
],
"enable_drag_reorder": true
}
}
```
### نکات مهم GET
- اگر `enable_drag_reorder` برنگردد، فرانت مقدار پیش‌فرض `true` در نظر می‌گیرد.
- اگر `row_order` نامعتبر یا خالی باشد، فرانت از ترتیب پیش‌فرض خودش استفاده می‌کند.
- اگر request خطا بخورد، فرانت اول `localStorage` را چک می‌کند؛ اگر چیزی نبود، config پیش‌فرض را می‌سازد.
---
## PATCH
### رفتار request
فرانت فقط فیلدهای تغییرکرده را می‌فرستد. یعنی body می‌تواند یکی از حالت‌های زیر باشد یا ترکیبی از آن‌ها:
#### 1) تغییر کارت‌های غیرفعال
```json
{
"disabled_card_ids": ["farmWeatherCard", "sensorRadarChart"]
}
```
#### 2) تغییر ترتیب ردیف‌ها
```json
{
"row_order": [
"overviewKpis",
"weatherAlerts",
"predictions",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"soilHeatmap",
"ndviRecommendations",
"economic"
]
}
```
#### 3) فعال/غیرفعال کردن drag reorder
```json
{
"enable_drag_reorder": false
}
```
#### 4) ترکیبی
```json
{
"disabled_card_ids": ["farmWeatherCard"],
"row_order": [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic"
],
"enable_drag_reorder": true
}
```
### Response مورد انتظار برای PATCH
هرچند request می‌تواند partial باشد، ولی response بهتر است همیشه **کل state نهایی config** را برگرداند:
```json
{
"code": 200,
"msg": "OK",
"data": {
"disabled_card_ids": ["farmWeatherCard"],
"row_order": [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic"
],
"enable_drag_reorder": true
}
}
```
### نکته خیلی مهم برای PATCH
در پیاده‌سازی فعلی فرانت، response باید حداقل یکی از این دو فیلد را داشته باشد:
- `disabled_card_ids`
- `row_order`
اگر backend فقط این را برگرداند:
```json
{
"enable_drag_reorder": false
}
```
ممکن است فرانت این response را نامعتبر تشخیص دهد.
بنابراین برای جلوگیری از مشکل، **همیشه کل object نهایی config را برگردانید**.
---
## mapping بین فرانت و API
فرانت state داخلی را با نام‌های camelCase نگه می‌دارد، اما در request به snake_case تبدیل می‌کند:
| Frontend field | API field |
|---|---|
| `disabledCardIds` | `disabled_card_ids` |
| `rowOrder` | `row_order` |
| `enableDragReorder` | `enable_drag_reorder` |
---
## مقادیر معتبر پیشنهادی
### Row ID های معتبر
```json
[
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic"
]
```
### Card ID های معتبر
```json
[
"farmOverviewKpis",
"farmWeatherCard",
"farmAlertsTracker",
"sensorValuesList",
"sensorRadarChart",
"sensorComparisonChart",
"anomalyDetectionCard",
"farmAlertsTimeline",
"waterNeedPrediction",
"harvestPredictionCard",
"yieldPredictionChart",
"soilMoistureHeatmap",
"ndviHealthCard",
"recommendationsList",
"economicOverview"
]
```
---
## فرمت canonical پیشنهادی برای backend
اگر بخواهی backend کاملاً سازگار و بدون ambiguity باشد، بهترین قرارداد این است:
- `disabled_card_ids` فقط شامل `Card ID` باشد
- `row_order` فقط شامل `Row ID` باشد
- response همیشه full object برگرداند
- status موفق `200` باشد
- `Content-Type` برابر `application/json` باشد
---
## نمونه نهایی پیشنهادی
### GET success
```json
{
"code": 200,
"msg": "OK",
"data": {
"disabled_card_ids": [],
"row_order": [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic"
],
"enable_drag_reorder": true
}
}
```
### PATCH success
```json
{
"code": 200,
"msg": "OK",
"data": {
"disabled_card_ids": ["farmWeatherCard"],
"row_order": [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic"
],
"enable_drag_reorder": false
}
}
```
---
## منبع این مستند
این مستند بر اساس رفتار واقعی فرانت در فایل‌های زیر نوشته شده است:
- `src/libs/api/services/farmDashboardService.ts`
- `src/views/dashboards/farm/farmDashboardConfig.ts`
- `src/views/dashboards/farm/FarmDashboardWrapper.tsx`
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,21 @@
from django.core.management.base import BaseCommand, CommandError
from account.seeds import ADMIN_USER_DATA, seed_admin_user
class Command(BaseCommand):
help = "Create or update the default admin user."
def handle(self, *args, **options):
try:
user, created = seed_admin_user()
except ValueError as exc:
raise CommandError(str(exc)) from exc
action = "created" if created else "updated"
self.stdout.write(
self.style.SUCCESS(
f"Admin user {action}: username={user.username}, email={user.email}, "
f"phone_number={user.phone_number}, password={ADMIN_USER_DATA['password']}"
)
)
+44
View File
@@ -0,0 +1,44 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
ADMIN_USER_DATA = {
"username": "admin",
"email": "admin@example.com",
"phone_number": "0912345678",
"first_name": "admin",
"last_name": "admin",
"password": "admin123456",
}
@transaction.atomic
def seed_admin_user():
user_model = get_user_model()
lookup = (
Q(username=ADMIN_USER_DATA["username"])
| Q(email=ADMIN_USER_DATA["email"])
| Q(phone_number=ADMIN_USER_DATA["phone_number"])
)
matched_users = list(user_model.objects.filter(lookup).order_by("id"))
if len(matched_users) > 1:
raise ValueError(
"Multiple users matched the admin seeder lookup. Resolve duplicates before seeding."
)
created = not matched_users
user = matched_users[0] if matched_users else user_model()
user.username = ADMIN_USER_DATA["username"]
user.email = ADMIN_USER_DATA["email"]
user.phone_number = ADMIN_USER_DATA["phone_number"]
user.first_name = ADMIN_USER_DATA["first_name"]
user.last_name = ADMIN_USER_DATA["last_name"]
user.is_staff = True
user.is_superuser = True
user.is_active = True
user.set_password(ADMIN_USER_DATA["password"])
user.save()
return user, created
+1 -1
View File
@@ -19,7 +19,7 @@ class LoginSerializer(serializers.Serializer):
identifier can be username, email, or phone_number."""
identifier = serializers.CharField()
password = serializers.CharField()
password = serializers.CharField(min_length=8, write_only=True)
# --- RequestOTP (request-otp/) ---
+59 -19
View File
@@ -1,28 +1,68 @@
"""
Static mock data for Farm Dashboard API.
No database, no dynamic values. Pure static payloads.
"""
# Config payload for GET/PATCH farm-dashboard-config (section 2.1)
# row_order must use valid row IDs only: overviewKpis, weatherAlerts, sensorMonitoring,
# sensorCharts, alertsWater, predictions, soilHeatmap, ndviRecommendations, economic
CONFIG = {
"disabled_card_ids": [
"predictions",
],
"row_order": [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"soilHeatmap",
"ndviRecommendations",
"economic",
],
"enable_drag_reorder": False,
from copy import deepcopy
from threading import Lock
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,
}
_config_lock = Lock()
_config_state = deepcopy(DEFAULT_CONFIG)
def get_config():
with _config_lock:
return deepcopy(_config_state)
def update_config(changes):
with _config_lock:
_config_state.update(deepcopy(changes))
return deepcopy(_config_state)
def reset_config():
with _config_lock:
_config_state.clear()
_config_state.update(deepcopy(DEFAULT_CONFIG))
return deepcopy(_config_state)
# 4.1 farmOverviewKpis
FARM_OVERVIEW_KPIS = {
"kpis": [
+59
View File
@@ -0,0 +1,59 @@
from rest_framework import serializers
from .mock_data import VALID_CARD_IDS, VALID_ROW_IDS
class FarmDashboardConfigSerializer(serializers.Serializer):
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):
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 not attrs:
raise serializers.ValidationError("At least one config field must be provided.")
return attrs
+66
View File
@@ -0,0 +1,66 @@
from copy import deepcopy
from django.test import SimpleTestCase
from rest_framework.test import APIRequestFactory
from .mock_data import DEFAULT_CONFIG, reset_config
from .views import FarmDashboardConfigView
class FarmDashboardConfigViewTests(SimpleTestCase):
def setUp(self):
self.factory = APIRequestFactory()
reset_config()
def tearDown(self):
reset_config()
def test_get_returns_canonical_config(self):
request = self.factory.get("/api/farm-dashboard-config/")
response = FarmDashboardConfigView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["msg"], "OK")
self.assertEqual(response.data["data"], DEFAULT_CONFIG)
def test_patch_partial_update_returns_full_final_config(self):
request = self.factory.patch(
"/api/farm-dashboard-config/",
{"disabled_card_ids": ["farmWeatherCard"]},
format="json",
)
response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG)
expected["disabled_card_ids"] = ["farmWeatherCard"]
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"], expected)
def test_patch_only_drag_flag_still_returns_full_config(self):
request = self.factory.patch(
"/api/farm-dashboard-config/",
{"enable_drag_reorder": False},
format="json",
)
response = FarmDashboardConfigView.as_view()(request)
expected = deepcopy(DEFAULT_CONFIG)
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/",
{"row_order": ["overviewKpis"]},
format="json",
)
response = FarmDashboardConfigView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertIn("row_order", response.data)
+1 -1
View File
@@ -3,6 +3,6 @@ from django.urls import path
from .views import FarmDashboardCardsView
urlpatterns = [
path("cards/", FarmDashboardCardsView.as_view(), name="farm-dashboard-cards"),
# path("cards/", FarmDashboardCardsView.as_view(), name="farm-dashboard-cards"),
path("", FarmDashboardCardsView.as_view(), name="farm-dashboard"),
]
+23 -13
View File
@@ -1,43 +1,51 @@
"""
Farm Dashboard API views.
No database connection. All responses use static mock data from mock_data.py.
"""
from rest_framework import status
from rest_framework import serializers
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_view
from config.swagger import code_response
from external_api_adapter import request as external_api_request
from .mock_data import CONFIG
from .mock_data import get_config, update_config
from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer
@extend_schema_view(
get=extend_schema(
tags=["Farm Dashboard"],
responses={200: code_response("FarmDashboardConfigGetResponse", data=serializers.JSONField())},
responses={200: code_response("FarmDashboardConfigGetResponse", data=FarmDashboardConfigSerializer())},
),
patch=extend_schema(
tags=["Farm Dashboard"],
request=OpenApiTypes.OBJECT,
responses={200: code_response("FarmDashboardConfigPatchResponse", data=serializers.JSONField())},
request=FarmDashboardConfigPatchSerializer,
responses={200: code_response("FarmDashboardConfigPatchResponse", data=FarmDashboardConfigSerializer())},
),
)
class FarmDashboardConfigView(APIView):
"""
Farm dashboard config endpoints: GET and PATCH.
GET returns static config (disabled_card_ids, row_order, enable_drag_reorder).
PATCH accepts body but returns same static config; no processing or validation.
No database. No input values used in response.
Farm dashboard config endpoints.
GET returns the current config.
PATCH accepts partial updates and returns the full final config.
"""
permission_classes = [AllowAny]
def get(self, request):
return Response({"code": 200, "msg": "OK", "data": CONFIG}, status=status.HTTP_200_OK)
config = get_config()
return Response({"code": 200, "msg": "OK", "data": config}, status=status.HTTP_200_OK)
def patch(self, request):
return Response({"code": 200, "msg": "OK", "data": CONFIG}, status=status.HTTP_200_OK)
serializer = FarmDashboardConfigPatchSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
config = update_config(serializer.validated_data)
response_serializer = FarmDashboardConfigSerializer(config)
return Response(
{"code": 200, "msg": "OK", "data": response_serializer.data},
status=status.HTTP_200_OK,
)
@extend_schema_view(
@@ -53,5 +61,7 @@ class FarmDashboardCardsView(APIView):
No database. Static mock data only.
"""
def get(self, request):
from external_api_adapter import request as external_api_request
adapter_response = external_api_request("ai", "/dashboard-data/status", method="GET")
return Response(adapter_response.data, status=adapter_response.status_code)
@@ -8,32 +8,602 @@
"sensor_id": "550e8400-e29b-41d4-a716-446655440000",
"all_cards": {
"farmOverviewKpis": {
"healthScore": 82,
"activeAlerts": 2,
"waterNeedMm": 18.4
"kpis": [
{
"id": "farm_health_score",
"title": "امتیاز سلامت مزرعه",
"subtitle": "تحلیل هوشمند",
"stats": "87%",
"avatarColor": "success",
"avatarIcon": "tabler-heartbeat",
"chipText": "خوب",
"chipColor": "success"
},
{
"id": "water_stress_index",
"title": "شاخص تنش آبی",
"subtitle": "فعلی",
"stats": "12%",
"avatarColor": "info",
"avatarIcon": "tabler-droplet",
"chipText": "پایین",
"chipColor": "success"
},
{
"id": "disease_risk",
"title": "ریسک بیماری",
"subtitle": "۷ روز اخیر",
"stats": "پایین",
"avatarColor": "success",
"avatarIcon": "tabler-bug",
"chipText": "5%",
"chipColor": "success"
},
{
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "کل مزرعه",
"stats": "65%",
"avatarColor": "primary",
"avatarIcon": "tabler-plant-2",
"chipText": "بهینه",
"chipColor": "success"
},
{
"id": "yield_prediction",
"title": "پیش‌بینی عملکرد",
"subtitle": "این فصل",
"stats": "42 تن",
"avatarColor": "secondary",
"avatarIcon": "tabler-chart-bar",
"chipText": "+8%",
"chipColor": "success"
},
{
"id": "pest_risk",
"title": "ریسک آفات",
"subtitle": "پیش‌بینی هوشمند",
"stats": "15%",
"avatarColor": "warning",
"avatarIcon": "tabler-bug-off",
"chipText": "تحت نظر",
"chipColor": "warning"
}
]
},
"farmWeatherCard": {
"condition": "صاف",
"temperature": 24,
"unit": "°C",
"humidity": 45,
"windSpeed": 12,
"windUnit": "km/h",
"chartData": {
"labels": [
"۶ صبح",
"۹ صبح",
"۱۲ ظهر",
"۳ بعدازظهر",
"۶ عصر",
"۹ شب",
"۱۲ شب"
],
"series": [
[
18,
22,
26,
28,
25,
20,
18
]
]
}
},
"farmAlertsTracker": {
"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"
}
]
},
"sensorValuesList": {
"items": [
"sensors": [
{
"label": "رطوبت خاک",
"value": 45.2,
"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": "%"
},
{
"label": "دما خاک",
"value": 22.5,
"unit": "°C"
"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"
}
]
},
"sensorRadarChart": {
"labels": [
"دما",
"رطوبت",
"pH",
"هدایت الکتریکی",
"نور",
"باد"
],
"series": [
{
"name": "امروز",
"data": [
75,
65,
80,
70,
85,
60
]
},
{
"name": "ایده‌آل",
"data": [
80,
70,
75,
75,
90,
50
]
}
]
},
"sensorComparisonChart": {
"currentValue": 48,
"vsLastWeek": "+5%",
"vsLastWeekValue": 5,
"categories": [
"دوشنبه",
"سه‌شنبه",
"چهارشنبه",
"پنج‌شنبه",
"جمعه",
"شنبه",
"یکشنبه"
],
"series": [
{
"name": "امروز",
"data": [
42,
45,
48,
52,
50,
48,
46
]
},
{
"name": "هفته قبل",
"data": [
38,
40,
42,
45,
43,
40,
38
]
}
]
},
"anomalyDetectionCard": {
"anomalies": [
{
"sensor": "رطوبت خاک زون ۳",
"value": "38%",
"expected": "45-65%",
"deviation": "-12%",
"severity": "warning"
},
{
"sensor": "pH بخش ۲",
"value": "5.2",
"expected": "6.0-7.0",
"deviation": "-0.8",
"severity": "error"
}
]
},
"farmAlertsTimeline": {
"alerts": [
{
"title": "ریسک کمبود آب",
"description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.",
"time": "۱۵ دقیقه پیش",
"color": "warning"
},
{
"title": "ریسک بیماری قارچی",
"description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.",
"time": "۱ ساعت پیش",
"color": "error"
},
{
"title": "پیشنهاد آبیاری",
"description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.",
"time": "۲ ساعت پیش",
"color": "info"
},
{
"title": "بررسی شوری خاک",
"description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.",
"time": "۴ ساعت پیش",
"color": "success"
}
]
},
"waterNeedPrediction": {
"totalNext7Days": 3290,
"unit": "m³",
"categories": [
"روز ۱",
"روز ۲",
"روز ۳",
"روز ۴",
"روز ۵",
"روز ۶",
"روز ۷"
],
"series": [
{
"name": "نیاز آبی",
"data": [
420,
450,
480,
460,
490,
510,
480
]
}
]
},
"harvestPredictionCard": {
"date": "2025-10-15",
"dateFormatted": "۱۵ اکتبر ۲۰۲۵",
"daysUntil": 58,
"description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.",
"optimalWindowStart": "2025-10-12",
"optimalWindowEnd": "2025-10-18"
},
"yieldPredictionChart": {
"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"
}
]
},
"soilMoistureHeatmap": {
"zones": [
"زون ۱",
"زون ۲",
"زون ۳",
"زون ۴",
"زون ۵",
"زون ۶",
"زون ۷"
],
"hours": [
"۶ ص",
"۸ ص",
"۱۰ ص",
"۱۲ ظ",
"۱۴ ع",
"۱۶ ع",
"۱۸ ع"
],
"series": [
{
"name": "زون ۱",
"data": [
{
"x": "۶ ص",
"y": 52
},
{
"x": "۸ ص",
"y": 48
},
{
"x": "۱۰ ص",
"y": 55
},
{
"x": "۱۲ ظ",
"y": 60
},
{
"x": "۱۴ ع",
"y": 58
},
{
"x": "۱۶ ع",
"y": 54
},
{
"x": "۱۸ ع",
"y": 50
}
]
},
{
"name": "زون ۲",
"data": [
{
"x": "۶ ص",
"y": 45
},
{
"x": "۸ ص",
"y": 42
},
{
"x": "۱۰ ص",
"y": 48
},
{
"x": "۱۲ ظ",
"y": 52
},
{
"x": "۱۴ ع",
"y": 50
},
{
"x": "۱۶ ع",
"y": 47
},
{
"x": "۱۸ ع",
"y": 44
}
]
}
]
},
"ndviHealthCard": {
"ndviIndex": 0.78,
"healthData": [
{
"title": "تنش نیتروژن",
"value": "پایین",
"color": "success",
"icon": "tabler-leaf"
},
{
"title": "سلامت محصول",
"value": "خوب",
"color": "success",
"icon": "tabler-plant"
}
]
},
"recommendationsList": {
"items": [
"recommendations": [
{
"recommendation_title": "تنظیم نوبت آبیاری",
"suggested_action": "آبیاری بعدی را صبح فردا انجام دهید.",
"urgency_level": "high"
"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"
}
]
},
"economicOverview": {
"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": [
"ژانویه",
"فوریه",
"مارس",
"آوریل",
"می",
"ژوئن"
]
}
}
}
@@ -0,0 +1,44 @@
from rest_framework import serializers
class FertilizationFarmDataSerializer(serializers.Serializer):
soilType = serializers.CharField(required=False, allow_blank=True)
organicMatter = serializers.CharField(required=False, allow_blank=True)
waterEC = serializers.CharField(required=False, allow_blank=True)
class FertilizationRecommendRequestSerializer(serializers.Serializer):
crop_id = serializers.CharField(required=False, allow_blank=True)
growth_stage = serializers.CharField(required=False, allow_blank=True)
farm_data = FertilizationFarmDataSerializer(required=False)
soilType = serializers.CharField(required=False, allow_blank=True)
organicMatter = serializers.CharField(required=False, allow_blank=True)
waterEC = serializers.CharField(required=False, allow_blank=True)
class FertilizationPlanSerializer(serializers.Serializer):
npkRatio = serializers.CharField(required=False, allow_blank=True)
amountPerHectare = serializers.CharField(required=False, allow_blank=True)
applicationMethod = serializers.CharField(required=False, allow_blank=True)
applicationInterval = serializers.CharField(required=False, allow_blank=True)
reasoning = serializers.CharField(required=False, allow_blank=True)
class FertilizationRecommendResponseDataSerializer(serializers.Serializer):
plan = FertilizationPlanSerializer(required=False)
class FertilizationTaskSubmitDataSerializer(serializers.Serializer):
task_id = serializers.CharField(required=False, allow_blank=True)
status = serializers.CharField(required=False, allow_blank=True)
class FertilizationTaskProgressSerializer(serializers.Serializer):
message = serializers.CharField(required=False, allow_blank=True)
class FertilizationTaskStatusDataSerializer(serializers.Serializer):
task_id = serializers.CharField(required=False, allow_blank=True)
status = serializers.CharField(required=False, allow_blank=True)
progress = FertilizationTaskProgressSerializer(required=False)
result = FertilizationRecommendResponseDataSerializer(required=False)
+3 -1
View File
@@ -1,8 +1,10 @@
from django.urls import path
from .views import ConfigView, RecommendView
from .views import ConfigView, RecommendTaskStatusView, RecommendView
urlpatterns = [
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"),
# path("recommend/task/", RecommendTaskCreateView.as_view(), name="fertilization-recommendation-task-create"),
path("recommend/<str:task_id>/status/", RecommendTaskStatusView.as_view(), name="fertilization-recommendation-task-status"),
]
+29 -50
View File
@@ -1,78 +1,38 @@
"""
Fertilization Recommendation API views.
No database. All responses are static mock data.
Response format: {"status": "success", "data": <payload>}. HTTP 200 only.
No processing, validation, or use of input parameters in responses.
"""
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from external_api_adapter import request as external_api_request
from .mock_data import CONFIG_RESPONSE_DATA
from .serializers import (
FertilizationRecommendRequestSerializer,
FertilizationRecommendResponseDataSerializer,
FertilizationTaskStatusDataSerializer,
FertilizationTaskSubmitDataSerializer,
)
class ConfigView(APIView):
"""
GET endpoint for fertilization config (farm data, growth stages, crop options).
Purpose:
Returns static farm data (soilType, organicMatter, waterEC), growth
stages list, and crop options for the fertilization recommendation form.
Used when loading the fertilization recommendation page.
Input parameters:
None. Query parameters, if sent, are not read or used.
Response structure:
- status: string, always "success".
- data: object with keys farmData (object), growthStages (array of
{ id, icon }), cropOptions (array of { id, labelKey, icon }).
No processing or validation is performed on inputs.
"""
@extend_schema(
tags=["Fertilization Recommendation"],
responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())},
)
def get(self, request):
return Response(
{"status": "success", "data": CONFIG_RESPONSE_DATA},
status=status.HTTP_200_OK,
)
return Response({"status": "success", "data": CONFIG_RESPONSE_DATA}, status=status.HTTP_200_OK)
class RecommendView(APIView):
"""
POST endpoint for fertilization recommendation.
Purpose:
Returns a static fertilization plan (npkRatio, amountPerHectare,
applicationMethod, applicationInterval, reasoning). Body may contain
crop_id, growth_stage, farm_data; not read or used in response.
Input parameters:
- body (optional): JSON. May contain "crop_id", "growth_stage",
"soilType", "organicMatter", "waterEC". Data type: object.
Location: body. Not read or validated; not used in response.
Response structure:
- status: string, always "success".
- data: object with key "plan" (object with npkRatio, amountPerHectare,
applicationMethod, applicationInterval, reasoning).
No processing or validation is performed on inputs.
"""
@extend_schema(
tags=["Fertilization Recommendation"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("FertilizationRecommendResponse", data=serializers.JSONField())},
request=FertilizationRecommendRequestSerializer,
responses={200: status_response("FertilizationRecommendResponse", data=FertilizationRecommendResponseDataSerializer())},
)
def post(self, request):
adapter_response = external_api_request(
@@ -82,3 +42,22 @@ class RecommendView(APIView):
payload=request.data,
)
return Response(adapter_response.data, status=adapter_response.status_code)
class RecommendTaskStatusView(APIView):
@extend_schema(
tags=["Fertilization Recommendation"],
parameters=[
OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH),
],
responses={200: status_response("FertilizationRecommendTaskStatusResponse", data=FertilizationTaskStatusDataSerializer())},
)
def get(self, request, task_id):
adapter_response = external_api_request(
"ai",
f"/fertilization/status/{task_id}",
method="GET",
)
return Response(adapter_response.data, status=adapter_response.status_code)
+62
View File
@@ -0,0 +1,62 @@
from rest_framework import serializers
class IrrigationFarmDataSerializer(serializers.Serializer):
soilType = serializers.CharField(required=False, allow_blank=True)
waterQuality = serializers.CharField(required=False, allow_blank=True)
climateZone = serializers.CharField(required=False, allow_blank=True)
class IrrigationRecommendRequestSerializer(serializers.Serializer):
crop_id = serializers.CharField(required=False, allow_blank=True)
farm_data = IrrigationFarmDataSerializer(required=False)
soilType = serializers.CharField(required=False, allow_blank=True)
waterQuality = serializers.CharField(required=False, allow_blank=True)
climateZone = serializers.CharField(required=False, allow_blank=True)
class IrrigationPlanSerializer(serializers.Serializer):
frequencyPerWeek = serializers.CharField(required=False, allow_blank=True)
durationMinutes = serializers.CharField(required=False, allow_blank=True)
bestTimeOfDay = serializers.CharField(required=False, allow_blank=True)
moistureLevel = serializers.CharField(required=False, allow_blank=True)
warning = serializers.CharField(required=False, allow_blank=True)
class IrrigationWaterBalanceDaySerializer(serializers.Serializer):
forecast_date = serializers.CharField(required=False, allow_blank=True)
et0_mm = serializers.FloatField(required=False)
etc_mm = serializers.FloatField(required=False)
effective_rainfall_mm = serializers.FloatField(required=False)
gross_irrigation_mm = serializers.FloatField(required=False)
irrigation_timing = serializers.CharField(required=False, allow_blank=True)
class IrrigationCropProfileSerializer(serializers.Serializer):
kc_initial = serializers.FloatField(required=False)
kc_mid = serializers.FloatField(required=False)
kc_end = serializers.FloatField(required=False)
class IrrigationWaterBalanceSerializer(serializers.Serializer):
daily = IrrigationWaterBalanceDaySerializer(many=True, required=False)
crop_profile = IrrigationCropProfileSerializer(required=False)
active_kc = serializers.FloatField(required=False)
class IrrigationRecommendResponseDataSerializer(serializers.Serializer):
plan = IrrigationPlanSerializer(required=False)
raw_response = serializers.CharField(required=False, allow_blank=True)
water_balance = IrrigationWaterBalanceSerializer(required=False)
status = serializers.CharField(required=False, allow_blank=True)
class IrrigationTaskSubmitDataSerializer(serializers.Serializer):
task_id = serializers.CharField(required=False, allow_blank=True)
status = serializers.CharField(required=False, allow_blank=True)
class IrrigationTaskStatusDataSerializer(serializers.Serializer):
task_id = serializers.CharField(required=False, allow_blank=True)
status = serializers.CharField(required=False, allow_blank=True)
result = IrrigationRecommendResponseDataSerializer(required=False)
+3 -1
View File
@@ -1,8 +1,10 @@
from django.urls import path
from .views import ConfigView, RecommendView
from .views import ConfigView, RecommendTaskCreateView, RecommendTaskStatusView, RecommendView
urlpatterns = [
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"),
path("recommend/task/", RecommendTaskCreateView.as_view(), name="irrigation-recommendation-task-create"),
path("recommend/<str:task_id>/status/", RecommendTaskStatusView.as_view(), name="irrigation-recommendation-task-status"),
]
+43 -50
View File
@@ -1,78 +1,38 @@
"""
Irrigation Recommendation API views.
No database. All responses are static mock data.
Response format: {"status": "success", "data": <payload>}. HTTP 200 only.
No processing, validation, or use of input parameters in responses.
"""
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from external_api_adapter import request as external_api_request
from .mock_data import CONFIG_RESPONSE_DATA
from .serializers import (
IrrigationRecommendRequestSerializer,
IrrigationRecommendResponseDataSerializer,
IrrigationTaskStatusDataSerializer,
IrrigationTaskSubmitDataSerializer,
)
class ConfigView(APIView):
"""
GET endpoint for irrigation config (farm info and crop options).
Purpose:
Returns static farm info (soilType, waterQuality, climateZone) and
crop options list for the irrigation recommendation form. Used when
loading the irrigation recommendation page.
Input parameters:
None. Query parameters, if sent, are not read or used.
Response structure:
- status: string, always "success".
- data: object with keys farmInfo (object), cropOptions (array of
{ id, labelKey, icon }).
No processing or validation is performed on inputs.
"""
@extend_schema(
tags=["Irrigation Recommendation"],
responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())},
)
def get(self, request):
return Response(
{"status": "success", "data": CONFIG_RESPONSE_DATA},
status=status.HTTP_200_OK,
)
return Response({"status": "success", "data": CONFIG_RESPONSE_DATA}, status=status.HTTP_200_OK)
class RecommendView(APIView):
"""
POST endpoint for irrigation recommendation.
Purpose:
Returns a static irrigation plan (frequencyPerWeek, durationMinutes,
bestTimeOfDay, moistureLevel, warning). Body may contain crop_id
and farm info; not read or used in response.
Input parameters:
- body (optional): JSON. May contain "crop_id", "soilType", "waterQuality",
"climateZone". Data type: object. Location: body. Not read or validated;
not used in response.
Response structure:
- status: string, always "success".
- data: object with key "plan" (object with frequencyPerWeek,
durationMinutes, bestTimeOfDay, moistureLevel, warning).
No processing or validation is performed on inputs.
"""
@extend_schema(
tags=["Irrigation Recommendation"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("IrrigationRecommendResponse", data=serializers.JSONField())},
request=IrrigationRecommendRequestSerializer,
responses={200: status_response("IrrigationRecommendResponse", data=IrrigationRecommendResponseDataSerializer())},
)
def post(self, request):
adapter_response = external_api_request(
@@ -82,3 +42,36 @@ class RecommendView(APIView):
payload=request.data,
)
return Response(adapter_response.data, status=adapter_response.status_code)
class RecommendTaskCreateView(APIView):
@extend_schema(
tags=["Irrigation Recommendation"],
request=IrrigationRecommendRequestSerializer,
responses={200: status_response("IrrigationRecommendTaskCreateResponse", data=IrrigationTaskSubmitDataSerializer())},
)
def post(self, request):
adapter_response = external_api_request(
"ai",
"/irrigation/recommend",
method="POST",
payload=request.data,
)
return Response(adapter_response.data, status=adapter_response.status_code)
class RecommendTaskStatusView(APIView):
@extend_schema(
tags=["Irrigation Recommendation"],
parameters=[
OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH),
],
responses={200: status_response("IrrigationRecommendTaskStatusResponse", data=IrrigationTaskStatusDataSerializer())},
)
def get(self, request, task_id):
adapter_response = external_api_request(
"ai",
f"/irrigation/recommend/status/{task_id}",
method="GET",
)
return Response(adapter_response.data, status=adapter_response.status_code)
@@ -8,32 +8,602 @@
"sensor_id": "550e8400-e29b-41d4-a716-446655440000",
"all_cards": {
"farmOverviewKpis": {
"healthScore": 82,
"activeAlerts": 2,
"waterNeedMm": 18.4
"kpis": [
{
"id": "farm_health_score",
"title": "امتیاز سلامت مزرعه",
"subtitle": "تحلیل هوشمند",
"stats": "87%",
"avatarColor": "success",
"avatarIcon": "tabler-heartbeat",
"chipText": "خوب",
"chipColor": "success"
},
{
"id": "water_stress_index",
"title": "شاخص تنش آبی",
"subtitle": "فعلی",
"stats": "12%",
"avatarColor": "info",
"avatarIcon": "tabler-droplet",
"chipText": "پایین",
"chipColor": "success"
},
{
"id": "disease_risk",
"title": "ریسک بیماری",
"subtitle": "۷ روز اخیر",
"stats": "پایین",
"avatarColor": "success",
"avatarIcon": "tabler-bug",
"chipText": "5%",
"chipColor": "success"
},
{
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "کل مزرعه",
"stats": "65%",
"avatarColor": "primary",
"avatarIcon": "tabler-plant-2",
"chipText": "بهینه",
"chipColor": "success"
},
{
"id": "yield_prediction",
"title": "پیش‌بینی عملکرد",
"subtitle": "این فصل",
"stats": "42 تن",
"avatarColor": "secondary",
"avatarIcon": "tabler-chart-bar",
"chipText": "+8%",
"chipColor": "success"
},
{
"id": "pest_risk",
"title": "ریسک آفات",
"subtitle": "پیش‌بینی هوشمند",
"stats": "15%",
"avatarColor": "warning",
"avatarIcon": "tabler-bug-off",
"chipText": "تحت نظر",
"chipColor": "warning"
}
]
},
"farmWeatherCard": {
"condition": "صاف",
"temperature": 24,
"unit": "°C",
"humidity": 45,
"windSpeed": 12,
"windUnit": "km/h",
"chartData": {
"labels": [
"۶ صبح",
"۹ صبح",
"۱۲ ظهر",
"۳ بعدازظهر",
"۶ عصر",
"۹ شب",
"۱۲ شب"
],
"series": [
[
18,
22,
26,
28,
25,
20,
18
]
]
}
},
"farmAlertsTracker": {
"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"
}
]
},
"sensorValuesList": {
"items": [
"sensors": [
{
"label": "رطوبت خاک",
"value": 45.2,
"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": "%"
},
{
"label": "دما خاک",
"value": 22.5,
"unit": "°C"
"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"
}
]
},
"sensorRadarChart": {
"labels": [
"دما",
"رطوبت",
"pH",
"هدایت الکتریکی",
"نور",
"باد"
],
"series": [
{
"name": "امروز",
"data": [
75,
65,
80,
70,
85,
60
]
},
{
"name": "ایده‌آل",
"data": [
80,
70,
75,
75,
90,
50
]
}
]
},
"sensorComparisonChart": {
"currentValue": 48,
"vsLastWeek": "+5%",
"vsLastWeekValue": 5,
"categories": [
"دوشنبه",
"سه‌شنبه",
"چهارشنبه",
"پنج‌شنبه",
"جمعه",
"شنبه",
"یکشنبه"
],
"series": [
{
"name": "امروز",
"data": [
42,
45,
48,
52,
50,
48,
46
]
},
{
"name": "هفته قبل",
"data": [
38,
40,
42,
45,
43,
40,
38
]
}
]
},
"anomalyDetectionCard": {
"anomalies": [
{
"sensor": "رطوبت خاک زون ۳",
"value": "38%",
"expected": "45-65%",
"deviation": "-12%",
"severity": "warning"
},
{
"sensor": "pH بخش ۲",
"value": "5.2",
"expected": "6.0-7.0",
"deviation": "-0.8",
"severity": "error"
}
]
},
"farmAlertsTimeline": {
"alerts": [
{
"title": "ریسک کمبود آب",
"description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.",
"time": "۱۵ دقیقه پیش",
"color": "warning"
},
{
"title": "ریسک بیماری قارچی",
"description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.",
"time": "۱ ساعت پیش",
"color": "error"
},
{
"title": "پیشنهاد آبیاری",
"description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.",
"time": "۲ ساعت پیش",
"color": "info"
},
{
"title": "بررسی شوری خاک",
"description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.",
"time": "۴ ساعت پیش",
"color": "success"
}
]
},
"waterNeedPrediction": {
"totalNext7Days": 3290,
"unit": "m³",
"categories": [
"روز ۱",
"روز ۲",
"روز ۳",
"روز ۴",
"روز ۵",
"روز ۶",
"روز ۷"
],
"series": [
{
"name": "نیاز آبی",
"data": [
420,
450,
480,
460,
490,
510,
480
]
}
]
},
"harvestPredictionCard": {
"date": "2025-10-15",
"dateFormatted": "۱۵ اکتبر ۲۰۲۵",
"daysUntil": 58,
"description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.",
"optimalWindowStart": "2025-10-12",
"optimalWindowEnd": "2025-10-18"
},
"yieldPredictionChart": {
"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"
}
]
},
"soilMoistureHeatmap": {
"zones": [
"زون ۱",
"زون ۲",
"زون ۳",
"زون ۴",
"زون ۵",
"زون ۶",
"زون ۷"
],
"hours": [
"۶ ص",
"۸ ص",
"۱۰ ص",
"۱۲ ظ",
"۱۴ ع",
"۱۶ ع",
"۱۸ ع"
],
"series": [
{
"name": "زون ۱",
"data": [
{
"x": "۶ ص",
"y": 52
},
{
"x": "۸ ص",
"y": 48
},
{
"x": "۱۰ ص",
"y": 55
},
{
"x": "۱۲ ظ",
"y": 60
},
{
"x": "۱۴ ع",
"y": 58
},
{
"x": "۱۶ ع",
"y": 54
},
{
"x": "۱۸ ع",
"y": 50
}
]
},
{
"name": "زون ۲",
"data": [
{
"x": "۶ ص",
"y": 45
},
{
"x": "۸ ص",
"y": 42
},
{
"x": "۱۰ ص",
"y": 48
},
{
"x": "۱۲ ظ",
"y": 52
},
{
"x": "۱۴ ع",
"y": 50
},
{
"x": "۱۶ ع",
"y": 47
},
{
"x": "۱۸ ع",
"y": 44
}
]
}
]
},
"ndviHealthCard": {
"ndviIndex": 0.78,
"healthData": [
{
"title": "تنش نیتروژن",
"value": "پایین",
"color": "success",
"icon": "tabler-leaf"
},
{
"title": "سلامت محصول",
"value": "خوب",
"color": "success",
"icon": "tabler-plant"
}
]
},
"recommendationsList": {
"items": [
"recommendations": [
{
"recommendation_title": "تنظیم نوبت آبیاری",
"suggested_action": "آبیاری بعدی را صبح فردا انجام دهید.",
"urgency_level": "high"
"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"
}
]
},
"economicOverview": {
"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": [
"ژانویه",
"فوریه",
"مارس",
"آوریل",
"می",
"ژوئن"
]
}
}
}
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,21 @@
from django.core.management.base import BaseCommand, CommandError
from sensor_hub.seeds import seed_admin_sensor
class Command(BaseCommand):
help = "Create or update the default full sensor for the admin user."
def handle(self, *args, **options):
try:
sensor, created = seed_admin_sensor()
except ValueError as exc:
raise CommandError(str(exc)) from exc
action = "created" if created else "updated"
self.stdout.write(
self.style.SUCCESS(
f"Admin sensor {action}: uuid_sensor={sensor.uuid_sensor}, "
f"name={sensor.name}, owner={sensor.owner.username}"
)
)
+92
View File
@@ -0,0 +1,92 @@
import uuid
from django.db import transaction
from account.seeds import seed_admin_user
from .models import Sensor
ADMIN_SENSOR_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111")
ADMIN_SENSOR_DATA = {
"name": "Admin Smart Farm Sensor",
"is_active": True,
"specifications": {
"model": "CL-SENSE-PRO-X",
"firmware": "2.4.1",
"manufacturer": "CropLogic",
"serial_number": "CL-ADMIN-0001",
"capabilities": [
"temperature",
"humidity",
"soil_moisture",
"soil_temperature",
"light_intensity",
"ph",
"ec",
"wind_speed",
],
"connectivity": {
"protocol": "LoRaWAN",
"sim_enabled": True,
"bluetooth": True,
"wifi_fallback": True,
},
"location": {
"label": "Admin Demo Field",
"lat": 35.6892,
"lng": 51.389,
"altitude_m": 1190,
},
},
"power_source": {
"type": "hybrid",
"battery": {
"capacity_mah": 12000,
"voltage": 12,
"health_percent": 98,
},
"solar": {
"panel_watt": 40,
"controller": "MPPT",
},
"backup": "dc_adapter",
},
"customized_sensors": {
"thresholds": {
"temperature_c": {"min": 10, "max": 36},
"humidity_percent": {"min": 30, "max": 85},
"soil_moisture_percent": {"min": 25, "max": 70},
"ph": {"min": 5.8, "max": 7.2},
"ec_ds_m": {"min": 1.1, "max": 2.4},
},
"report_interval_sec": 300,
"alerts": {
"sms": True,
"email": True,
"push": True,
},
"calibration": {
"last_calibrated_at": "2025-03-01T08:30:00Z",
"technician": "system",
"status": "passed",
},
},
}
@transaction.atomic
def seed_admin_sensor():
owner, _ = seed_admin_user()
sensor, created = Sensor.objects.update_or_create(
uuid_sensor=ADMIN_SENSOR_UUID,
defaults={
"owner": owner,
"name": ADMIN_SENSOR_DATA["name"],
"is_active": ADMIN_SENSOR_DATA["is_active"],
"specifications": ADMIN_SENSOR_DATA["specifications"],
"power_source": ADMIN_SENSOR_DATA["power_source"],
"customized_sensors": ADMIN_SENSOR_DATA["customized_sensors"],
},
)
return sensor, created
+4
View File
@@ -30,3 +30,7 @@ class SensorCreateSerializer(serializers.ModelSerializer):
"power_source",
"customized_sensors",
]
class SensorToggleSerializer(serializers.Serializer):
uuid_sensor = serializers.UUIDField()
+5 -5
View File
@@ -1,10 +1,10 @@
from django.urls import path
from .views import SensorHubView
from .views import SensorActiveView, SensorDeactiveView, SensorDetailView, SensorListCreateView
urlpatterns = [
path("active/", SensorHubView.as_view(), name="sensor-hub-active", kwargs={"action": "active"}),
path("deactive/", SensorHubView.as_view(), name="sensor-hub-deactive", kwargs={"action": "deactive"}),
path("<uuid:uuid>/", SensorHubView.as_view(), name="sensor-hub-detail"),
path("", SensorHubView.as_view(), name="sensor-hub-list"),
path("active/", SensorActiveView.as_view(), name="sensor-hub-active"),
path("deactive/", SensorDeactiveView.as_view(), name="sensor-hub-deactive"),
path("<uuid:uuid>/", SensorDetailView.as_view(), name="sensor-hub-detail"),
path("", SensorListCreateView.as_view(), name="sensor-hub-list"),
]
+81 -98
View File
@@ -1,64 +1,15 @@
from rest_framework import status
from rest_framework import serializers
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 extend_schema, extend_schema_view
from drf_spectacular.utils import extend_schema
from config.swagger import code_response
from .models import Sensor
from .serializers import SensorCreateSerializer, SensorSerializer
from .serializers import SensorCreateSerializer, SensorSerializer, SensorToggleSerializer
@extend_schema_view(
get=extend_schema(
tags=["Sensor Hub"],
responses={
200: code_response("SensorHubGetResponse", data=serializers.JSONField()),
404: code_response("SensorHubNotFoundResponse"),
},
),
post=extend_schema(
tags=["Sensor Hub"],
request=OpenApiTypes.OBJECT,
responses={
201: code_response("SensorCreateResponse", data=serializers.JSONField()),
200: code_response("SensorToggleResponse"),
400: code_response("SensorToggleValidationResponse"),
404: code_response("SensorToggleNotFoundResponse"),
},
),
patch=extend_schema(
tags=["Sensor Hub"],
request=SensorCreateSerializer,
responses={
200: code_response("SensorUpdateResponse", data=SensorSerializer()),
404: code_response("SensorUpdateNotFoundResponse"),
},
),
delete=extend_schema(
tags=["Sensor Hub"],
responses={
200: code_response("SensorDeleteResponse"),
404: code_response("SensorDeleteNotFoundResponse"),
},
),
)
class SensorHubView(APIView):
"""
Sensor-hub CRUD endpoints connected to the database.
Routes:
- GET "" List sensors for authenticated user.
- GET "<uuid>/" Detail of a single sensor.
- POST "" Create a new sensor.
- PATCH "<uuid>/" Update an existing sensor.
- DELETE "<uuid>/" Delete a sensor.
- POST "active/" Activate a sensor (requires uuid_sensor in body).
- POST "deactive/" Deactivate a sensor (requires uuid_sensor in body).
"""
class SensorHubBaseView(APIView):
permission_classes = [IsAuthenticated]
def _get_sensor(self, request, uuid):
@@ -67,74 +18,106 @@ class SensorHubView(APIView):
except Sensor.DoesNotExist:
return None
def get(self, request, *args, **kwargs):
uuid = kwargs.get("uuid")
if uuid is not None:
sensor = self._get_sensor(request, uuid)
if sensor is None:
return Response(
{"code": 404, "msg": "Sensor not found."},
status=status.HTTP_404_NOT_FOUND,
)
data = SensorSerializer(sensor).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
class SensorListCreateView(SensorHubBaseView):
@extend_schema(
tags=["Sensor Hub"],
responses={200: code_response("SensorListResponse", data=SensorSerializer(many=True))},
)
def get(self, request):
sensors = Sensor.objects.filter(owner=request.user)
data = SensorSerializer(sensors, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
def post(self, request, *args, **kwargs):
action = kwargs.get("action")
if action in ("active", "deactive"):
return self._toggle_active(request, is_active=(action == "active"))
@extend_schema(
tags=["Sensor Hub"],
request=SensorCreateSerializer,
responses={201: code_response("SensorCreateResponse", data=SensorSerializer())},
)
def post(self, request):
serializer = SensorCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
sensor = serializer.save(owner=request.user)
data = SensorSerializer(sensor).data
return Response(
{"code": 201, "msg": "success", "data": data},
status=status.HTTP_201_CREATED,
)
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
def patch(self, request, *args, **kwargs):
uuid = kwargs.get("uuid")
class SensorDetailView(SensorHubBaseView):
@extend_schema(
tags=["Sensor Hub"],
responses={
200: code_response("SensorDetailResponse", data=SensorSerializer()),
404: code_response("SensorNotFoundResponse"),
},
)
def get(self, request, uuid):
sensor = self._get_sensor(request, uuid)
if sensor is None:
return Response(
{"code": 404, "msg": "Sensor not found."},
status=status.HTTP_404_NOT_FOUND,
)
return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND)
data = SensorSerializer(sensor).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
@extend_schema(
tags=["Sensor Hub"],
request=SensorCreateSerializer,
responses={
200: code_response("SensorUpdateResponse", data=SensorSerializer()),
404: code_response("SensorUpdateNotFoundResponse"),
},
)
def patch(self, request, uuid):
sensor = self._get_sensor(request, uuid)
if sensor is None:
return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND)
serializer = SensorCreateSerializer(sensor, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
data = SensorSerializer(sensor).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
def delete(self, request, *args, **kwargs):
uuid = kwargs.get("uuid")
@extend_schema(
tags=["Sensor Hub"],
responses={
200: code_response("SensorDeleteResponse"),
404: code_response("SensorDeleteNotFoundResponse"),
},
)
def delete(self, request, uuid):
sensor = self._get_sensor(request, uuid)
if sensor is None:
return Response(
{"code": 404, "msg": "Sensor not found."},
status=status.HTTP_404_NOT_FOUND,
)
return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND)
sensor.delete()
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
def _toggle_active(self, request, is_active):
uuid_sensor = request.data.get("uuid_sensor")
if not uuid_sensor:
return Response(
{"code": 400, "msg": "uuid_sensor is required."},
status=status.HTTP_400_BAD_REQUEST,
)
sensor = self._get_sensor(request, uuid_sensor)
class SensorToggleView(SensorHubBaseView):
action = None
@extend_schema(
tags=["Sensor Hub"],
request=SensorToggleSerializer,
responses={
200: code_response("SensorToggleResponse"),
400: code_response("SensorToggleValidationResponse"),
404: code_response("SensorToggleNotFoundResponse"),
},
)
def post(self, request):
serializer = SensorToggleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
sensor = self._get_sensor(request, serializer.validated_data["uuid_sensor"])
if sensor is None:
return Response(
{"code": 404, "msg": "Sensor not found."},
status=status.HTTP_404_NOT_FOUND,
)
sensor.is_active = is_active
return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND)
sensor.is_active = self.action == "active"
sensor.save(update_fields=["is_active", "updated_at"])
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
class SensorActiveView(SensorToggleView):
action = "active"
class SensorDeactiveView(SensorToggleView):
action = "deactive"