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 can be username, email, or phone_number."""
identifier = serializers.CharField() identifier = serializers.CharField()
password = serializers.CharField() password = serializers.CharField(min_length=8, write_only=True)
# --- RequestOTP (request-otp/) --- # --- RequestOTP (request-otp/) ---
+51 -11
View File
@@ -1,28 +1,68 @@
""" """
Static mock data for Farm Dashboard API. 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) from copy import deepcopy
# row_order must use valid row IDs only: overviewKpis, weatherAlerts, sensorMonitoring, from threading import Lock
# sensorCharts, alertsWater, predictions, soilHeatmap, ndviRecommendations, economic
CONFIG = {
"disabled_card_ids": [ VALID_ROW_IDS = [
"predictions",
],
"row_order": [
"overviewKpis", "overviewKpis",
"weatherAlerts", "weatherAlerts",
"sensorMonitoring", "sensorMonitoring",
"sensorCharts", "sensorCharts",
"alertsWater", "alertsWater",
"predictions",
"soilHeatmap", "soilHeatmap",
"ndviRecommendations", "ndviRecommendations",
"economic", "economic",
], ]
"enable_drag_reorder": False,
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 # 4.1 farmOverviewKpis
FARM_OVERVIEW_KPIS = { FARM_OVERVIEW_KPIS = {
"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 from .views import FarmDashboardCardsView
urlpatterns = [ 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"), path("", FarmDashboardCardsView.as_view(), name="farm-dashboard"),
] ]
+23 -13
View File
@@ -1,43 +1,51 @@
""" """
Farm Dashboard API views. 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 status
from rest_framework import serializers from rest_framework import serializers
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView 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, extend_schema_view
from config.swagger import code_response from config.swagger import code_response
from external_api_adapter import request as external_api_request from .mock_data import get_config, update_config
from .mock_data import CONFIG from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer
@extend_schema_view( @extend_schema_view(
get=extend_schema( get=extend_schema(
tags=["Farm Dashboard"], tags=["Farm Dashboard"],
responses={200: code_response("FarmDashboardConfigGetResponse", data=serializers.JSONField())}, responses={200: code_response("FarmDashboardConfigGetResponse", data=FarmDashboardConfigSerializer())},
), ),
patch=extend_schema( patch=extend_schema(
tags=["Farm Dashboard"], tags=["Farm Dashboard"],
request=OpenApiTypes.OBJECT, request=FarmDashboardConfigPatchSerializer,
responses={200: code_response("FarmDashboardConfigPatchResponse", data=serializers.JSONField())}, responses={200: code_response("FarmDashboardConfigPatchResponse", data=FarmDashboardConfigSerializer())},
), ),
) )
class FarmDashboardConfigView(APIView): class FarmDashboardConfigView(APIView):
""" """
Farm dashboard config endpoints: GET and PATCH. Farm dashboard config endpoints.
GET returns static config (disabled_card_ids, row_order, enable_drag_reorder). GET returns the current config.
PATCH accepts body but returns same static config; no processing or validation. PATCH accepts partial updates and returns the full final config.
No database. No input values used in response.
""" """
permission_classes = [AllowAny]
def get(self, request): 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): 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( @extend_schema_view(
@@ -53,5 +61,7 @@ class FarmDashboardCardsView(APIView):
No database. Static mock data only. No database. Static mock data only.
""" """
def get(self, request): 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") adapter_response = external_api_request("ai", "/dashboard-data/status", method="GET")
return Response(adapter_response.data, status=adapter_response.status_code) return Response(adapter_response.data, status=adapter_response.status_code)
@@ -8,32 +8,602 @@
"sensor_id": "550e8400-e29b-41d4-a716-446655440000", "sensor_id": "550e8400-e29b-41d4-a716-446655440000",
"all_cards": { "all_cards": {
"farmOverviewKpis": { "farmOverviewKpis": {
"healthScore": 82, "kpis": [
"activeAlerts": 2, {
"waterNeedMm": 18.4 "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": { "sensorValuesList": {
"items": [ "sensors": [
{ {
"label": "رطوبت خاک", "title": "28°C",
"value": 45.2, "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": "%" "unit": "%"
}, },
{ {
"label": "دما خاک", "title": "42%",
"value": 22.5, "subtitle": "رطوبت خاک (۱۰ سانتی‌متر)",
"unit": "°C" "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": { "recommendationsList": {
"items": [ "recommendations": [
{ {
"recommendation_title": "تنظیم نوبت آبیاری", "title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح",
"suggested_action": "آبیاری بعدی را صبح فردا انجام دهید.", "subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.",
"urgency_level": "high" "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 django.urls import path
from .views import ConfigView, RecommendView from .views import ConfigView, RecommendTaskStatusView, RecommendView
urlpatterns = [ urlpatterns = [
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"), path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"), 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. 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 import serializers, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes 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 config.swagger import status_response
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
from .mock_data import CONFIG_RESPONSE_DATA from .mock_data import CONFIG_RESPONSE_DATA
from .serializers import (
FertilizationRecommendRequestSerializer,
FertilizationRecommendResponseDataSerializer,
FertilizationTaskStatusDataSerializer,
FertilizationTaskSubmitDataSerializer,
)
class ConfigView(APIView): 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( @extend_schema(
tags=["Fertilization Recommendation"], tags=["Fertilization Recommendation"],
responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())}, responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())},
) )
def get(self, request): def get(self, request):
return Response( return Response({"status": "success", "data": CONFIG_RESPONSE_DATA}, status=status.HTTP_200_OK)
{"status": "success", "data": CONFIG_RESPONSE_DATA},
status=status.HTTP_200_OK,
)
class RecommendView(APIView): 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( @extend_schema(
tags=["Fertilization Recommendation"], tags=["Fertilization Recommendation"],
request=OpenApiTypes.OBJECT, request=FertilizationRecommendRequestSerializer,
responses={200: status_response("FertilizationRecommendResponse", data=serializers.JSONField())}, responses={200: status_response("FertilizationRecommendResponse", data=FertilizationRecommendResponseDataSerializer())},
) )
def post(self, request): def post(self, request):
adapter_response = external_api_request( adapter_response = external_api_request(
@@ -82,3 +42,22 @@ class RecommendView(APIView):
payload=request.data, payload=request.data,
) )
return Response(adapter_response.data, status=adapter_response.status_code) 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 django.urls import path
from .views import ConfigView, RecommendView from .views import ConfigView, RecommendTaskCreateView, RecommendTaskStatusView, RecommendView
urlpatterns = [ urlpatterns = [
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"), path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"), 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. 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 import serializers, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes 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 config.swagger import status_response
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
from .mock_data import CONFIG_RESPONSE_DATA from .mock_data import CONFIG_RESPONSE_DATA
from .serializers import (
IrrigationRecommendRequestSerializer,
IrrigationRecommendResponseDataSerializer,
IrrigationTaskStatusDataSerializer,
IrrigationTaskSubmitDataSerializer,
)
class ConfigView(APIView): 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( @extend_schema(
tags=["Irrigation Recommendation"], tags=["Irrigation Recommendation"],
responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())}, responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())},
) )
def get(self, request): def get(self, request):
return Response( return Response({"status": "success", "data": CONFIG_RESPONSE_DATA}, status=status.HTTP_200_OK)
{"status": "success", "data": CONFIG_RESPONSE_DATA},
status=status.HTTP_200_OK,
)
class RecommendView(APIView): 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( @extend_schema(
tags=["Irrigation Recommendation"], tags=["Irrigation Recommendation"],
request=OpenApiTypes.OBJECT, request=IrrigationRecommendRequestSerializer,
responses={200: status_response("IrrigationRecommendResponse", data=serializers.JSONField())}, responses={200: status_response("IrrigationRecommendResponse", data=IrrigationRecommendResponseDataSerializer())},
) )
def post(self, request): def post(self, request):
adapter_response = external_api_request( adapter_response = external_api_request(
@@ -82,3 +42,36 @@ class RecommendView(APIView):
payload=request.data, payload=request.data,
) )
return Response(adapter_response.data, status=adapter_response.status_code) 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", "sensor_id": "550e8400-e29b-41d4-a716-446655440000",
"all_cards": { "all_cards": {
"farmOverviewKpis": { "farmOverviewKpis": {
"healthScore": 82, "kpis": [
"activeAlerts": 2, {
"waterNeedMm": 18.4 "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": { "sensorValuesList": {
"items": [ "sensors": [
{ {
"label": "رطوبت خاک", "title": "28°C",
"value": 45.2, "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": "%" "unit": "%"
}, },
{ {
"label": "دما خاک", "title": "42%",
"value": 22.5, "subtitle": "رطوبت خاک (۱۰ سانتی‌متر)",
"unit": "°C" "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": { "recommendationsList": {
"items": [ "recommendations": [
{ {
"recommendation_title": "تنظیم نوبت آبیاری", "title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح",
"suggested_action": "آبیاری بعدی را صبح فردا انجام دهید.", "subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.",
"urgency_level": "high" "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", "power_source",
"customized_sensors", "customized_sensors",
] ]
class SensorToggleSerializer(serializers.Serializer):
uuid_sensor = serializers.UUIDField()
+5 -5
View File
@@ -1,10 +1,10 @@
from django.urls import path from django.urls import path
from .views import SensorHubView from .views import SensorActiveView, SensorDeactiveView, SensorDetailView, SensorListCreateView
urlpatterns = [ urlpatterns = [
path("active/", SensorHubView.as_view(), name="sensor-hub-active", kwargs={"action": "active"}), path("active/", SensorActiveView.as_view(), name="sensor-hub-active"),
path("deactive/", SensorHubView.as_view(), name="sensor-hub-deactive", kwargs={"action": "deactive"}), path("deactive/", SensorDeactiveView.as_view(), name="sensor-hub-deactive"),
path("<uuid:uuid>/", SensorHubView.as_view(), name="sensor-hub-detail"), path("<uuid:uuid>/", SensorDetailView.as_view(), name="sensor-hub-detail"),
path("", SensorHubView.as_view(), name="sensor-hub-list"), path("", SensorListCreateView.as_view(), name="sensor-hub-list"),
] ]
+79 -96
View File
@@ -1,64 +1,15 @@
from rest_framework import status from rest_framework import serializers, status
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema, extend_schema_view
from config.swagger import code_response from config.swagger import code_response
from .models import Sensor from .models import Sensor
from .serializers import SensorCreateSerializer, SensorSerializer from .serializers import SensorCreateSerializer, SensorSerializer, SensorToggleSerializer
@extend_schema_view( class SensorHubBaseView(APIView):
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).
"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def _get_sensor(self, request, uuid): def _get_sensor(self, request, uuid):
@@ -67,74 +18,106 @@ class SensorHubView(APIView):
except Sensor.DoesNotExist: except Sensor.DoesNotExist:
return None 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) sensors = Sensor.objects.filter(owner=request.user)
data = SensorSerializer(sensors, many=True).data data = SensorSerializer(sensors, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
def post(self, request, *args, **kwargs): @extend_schema(
action = kwargs.get("action") tags=["Sensor Hub"],
if action in ("active", "deactive"): request=SensorCreateSerializer,
return self._toggle_active(request, is_active=(action == "active")) responses={201: code_response("SensorCreateResponse", data=SensorSerializer())},
)
def post(self, request):
serializer = SensorCreateSerializer(data=request.data) serializer = SensorCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
sensor = serializer.save(owner=request.user) sensor = serializer.save(owner=request.user)
data = SensorSerializer(sensor).data data = SensorSerializer(sensor).data
return Response( return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
{"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) sensor = self._get_sensor(request, uuid)
if sensor is None: if sensor is None:
return Response( return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND)
{"code": 404, "msg": "Sensor not found."}, data = SensorSerializer(sensor).data
status=status.HTTP_404_NOT_FOUND, 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 = SensorCreateSerializer(sensor, data=request.data, partial=True)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
data = SensorSerializer(sensor).data data = SensorSerializer(sensor).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
def delete(self, request, *args, **kwargs): @extend_schema(
uuid = kwargs.get("uuid") tags=["Sensor Hub"],
responses={
200: code_response("SensorDeleteResponse"),
404: code_response("SensorDeleteNotFoundResponse"),
},
)
def delete(self, request, uuid):
sensor = self._get_sensor(request, uuid) sensor = self._get_sensor(request, uuid)
if sensor is None: if sensor is None:
return Response( return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND)
{"code": 404, "msg": "Sensor not found."},
status=status.HTTP_404_NOT_FOUND,
)
sensor.delete() sensor.delete()
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) 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") class SensorToggleView(SensorHubBaseView):
if not uuid_sensor: action = None
return Response(
{"code": 400, "msg": "uuid_sensor is required."}, @extend_schema(
status=status.HTTP_400_BAD_REQUEST, tags=["Sensor Hub"],
request=SensorToggleSerializer,
responses={
200: code_response("SensorToggleResponse"),
400: code_response("SensorToggleValidationResponse"),
404: code_response("SensorToggleNotFoundResponse"),
},
) )
sensor = self._get_sensor(request, uuid_sensor) 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: if sensor is None:
return Response( return Response({"code": 404, "msg": "Sensor not found."}, status=status.HTTP_404_NOT_FOUND)
{"code": 404, "msg": "Sensor not found."},
status=status.HTTP_404_NOT_FOUND, sensor.is_active = self.action == "active"
)
sensor.is_active = is_active
sensor.save(update_fields=["is_active", "updated_at"]) sensor.save(update_fields=["is_active", "updated_at"])
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
class SensorActiveView(SensorToggleView):
action = "active"
class SensorDeactiveView(SensorToggleView):
action = "deactive"