From 32a0e3f3d97e600825ed583de8ebed6341d8ebd5 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Thu, 26 Mar 2026 15:39:31 +0330 Subject: [PATCH] UPDATE --- FARM_DASHBOARD_CONFIG_API.md | 357 +++++++++++ account/management/__init__.py | 1 + account/management/commands/__init__.py | 1 + .../management/commands/seed_admin_user.py | 21 + account/seeds.py | 44 ++ auth/serializers.py | 2 +- dashboard/mock_data.py | 78 ++- dashboard/serializers.py | 59 ++ dashboard/tests.py | 66 ++ dashboard/urls.py | 2 +- dashboard/views.py | 36 +- .../status/get_200_success.json | 596 +++++++++++++++++- fertilization_recommendation/serializers.py | 44 ++ fertilization_recommendation/urls.py | 4 +- fertilization_recommendation/views.py | 79 +-- irrigation_recommendation/serializers.py | 62 ++ irrigation_recommendation/urls.py | 4 +- irrigation_recommendation/views.py | 93 ++- .../status/get_200_success.json | 596 +++++++++++++++++- sensor_hub/management/__init__.py | 1 + sensor_hub/management/commands/__init__.py | 1 + .../management/commands/seed_admin_sensor.py | 21 + sensor_hub/seeds.py | 92 +++ sensor_hub/serializers.py | 4 + sensor_hub/urls.py | 10 +- sensor_hub/views.py | 179 +++--- 26 files changed, 2188 insertions(+), 265 deletions(-) create mode 100644 FARM_DASHBOARD_CONFIG_API.md create mode 100644 account/management/__init__.py create mode 100644 account/management/commands/__init__.py create mode 100644 account/management/commands/seed_admin_user.py create mode 100644 account/seeds.py create mode 100644 dashboard/serializers.py create mode 100644 dashboard/tests.py create mode 100644 fertilization_recommendation/serializers.py create mode 100644 irrigation_recommendation/serializers.py create mode 100644 sensor_hub/management/__init__.py create mode 100644 sensor_hub/management/commands/__init__.py create mode 100644 sensor_hub/management/commands/seed_admin_sensor.py create mode 100644 sensor_hub/seeds.py diff --git a/FARM_DASHBOARD_CONFIG_API.md b/FARM_DASHBOARD_CONFIG_API.md new file mode 100644 index 0000000..0095c21 --- /dev/null +++ b/FARM_DASHBOARD_CONFIG_API.md @@ -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 +``` + +> هدر `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` diff --git a/account/management/__init__.py b/account/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/account/management/__init__.py @@ -0,0 +1 @@ + diff --git a/account/management/commands/__init__.py b/account/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/account/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/account/management/commands/seed_admin_user.py b/account/management/commands/seed_admin_user.py new file mode 100644 index 0000000..4841025 --- /dev/null +++ b/account/management/commands/seed_admin_user.py @@ -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']}" + ) + ) diff --git a/account/seeds.py b/account/seeds.py new file mode 100644 index 0000000..2449ed4 --- /dev/null +++ b/account/seeds.py @@ -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 diff --git a/auth/serializers.py b/auth/serializers.py index 30fe98e..65b95dd 100644 --- a/auth/serializers.py +++ b/auth/serializers.py @@ -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/) --- diff --git a/dashboard/mock_data.py b/dashboard/mock_data.py index e4886ed..968fdbe 100644 --- a/dashboard/mock_data.py +++ b/dashboard/mock_data.py @@ -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": [ diff --git a/dashboard/serializers.py b/dashboard/serializers.py new file mode 100644 index 0000000..dbd4606 --- /dev/null +++ b/dashboard/serializers.py @@ -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 diff --git a/dashboard/tests.py b/dashboard/tests.py new file mode 100644 index 0000000..1da960d --- /dev/null +++ b/dashboard/tests.py @@ -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) diff --git a/dashboard/urls.py b/dashboard/urls.py index b74779d..a466313 100644 --- a/dashboard/urls.py +++ b/dashboard/urls.py @@ -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"), ] diff --git a/dashboard/views.py b/dashboard/views.py index fd28683..4441814 100644 --- a/dashboard/views.py +++ b/dashboard/views.py @@ -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) diff --git a/external_api_adapter/json/ai/dashboard-data/status/get_200_success.json b/external_api_adapter/json/ai/dashboard-data/status/get_200_success.json index 4de043c..fef804e 100644 --- a/external_api_adapter/json/ai/dashboard-data/status/get_200_success.json +++ b/external_api_adapter/json/ai/dashboard-data/status/get_200_success.json @@ -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": [ + "ژانویه", + "فوریه", + "مارس", + "آوریل", + "می", + "ژوئن" + ] } } } diff --git a/fertilization_recommendation/serializers.py b/fertilization_recommendation/serializers.py new file mode 100644 index 0000000..f728f69 --- /dev/null +++ b/fertilization_recommendation/serializers.py @@ -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) diff --git a/fertilization_recommendation/urls.py b/fertilization_recommendation/urls.py index e52d26d..229dfb7 100644 --- a/fertilization_recommendation/urls.py +++ b/fertilization_recommendation/urls.py @@ -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//status/", RecommendTaskStatusView.as_view(), name="fertilization-recommendation-task-status"), ] diff --git a/fertilization_recommendation/views.py b/fertilization_recommendation/views.py index c68100e..ca7c20d 100644 --- a/fertilization_recommendation/views.py +++ b/fertilization_recommendation/views.py @@ -1,78 +1,38 @@ """ Fertilization Recommendation API views. -No database. All responses are static mock data. -Response format: {"status": "success", "data": }. 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) diff --git a/irrigation_recommendation/serializers.py b/irrigation_recommendation/serializers.py new file mode 100644 index 0000000..ca593ad --- /dev/null +++ b/irrigation_recommendation/serializers.py @@ -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) diff --git a/irrigation_recommendation/urls.py b/irrigation_recommendation/urls.py index b3810bc..69fb85c 100644 --- a/irrigation_recommendation/urls.py +++ b/irrigation_recommendation/urls.py @@ -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//status/", RecommendTaskStatusView.as_view(), name="irrigation-recommendation-task-status"), ] diff --git a/irrigation_recommendation/views.py b/irrigation_recommendation/views.py index 8431b77..1434814 100644 --- a/irrigation_recommendation/views.py +++ b/irrigation_recommendation/views.py @@ -1,78 +1,38 @@ """ Irrigation Recommendation API views. -No database. All responses are static mock data. -Response format: {"status": "success", "data": }. 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) diff --git a/json/mock_data/dashboard-data/status/get_200_success.json b/json/mock_data/dashboard-data/status/get_200_success.json index 4de043c..fef804e 100644 --- a/json/mock_data/dashboard-data/status/get_200_success.json +++ b/json/mock_data/dashboard-data/status/get_200_success.json @@ -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": [ + "ژانویه", + "فوریه", + "مارس", + "آوریل", + "می", + "ژوئن" + ] } } } diff --git a/sensor_hub/management/__init__.py b/sensor_hub/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sensor_hub/management/__init__.py @@ -0,0 +1 @@ + diff --git a/sensor_hub/management/commands/__init__.py b/sensor_hub/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sensor_hub/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/sensor_hub/management/commands/seed_admin_sensor.py b/sensor_hub/management/commands/seed_admin_sensor.py new file mode 100644 index 0000000..143b064 --- /dev/null +++ b/sensor_hub/management/commands/seed_admin_sensor.py @@ -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}" + ) + ) diff --git a/sensor_hub/seeds.py b/sensor_hub/seeds.py new file mode 100644 index 0000000..218e03d --- /dev/null +++ b/sensor_hub/seeds.py @@ -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 diff --git a/sensor_hub/serializers.py b/sensor_hub/serializers.py index 6806f91..f332120 100644 --- a/sensor_hub/serializers.py +++ b/sensor_hub/serializers.py @@ -30,3 +30,7 @@ class SensorCreateSerializer(serializers.ModelSerializer): "power_source", "customized_sensors", ] + + +class SensorToggleSerializer(serializers.Serializer): + uuid_sensor = serializers.UUIDField() diff --git a/sensor_hub/urls.py b/sensor_hub/urls.py index 9155a54..aba7fad 100644 --- a/sensor_hub/urls.py +++ b/sensor_hub/urls.py @@ -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("/", 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("/", SensorDetailView.as_view(), name="sensor-hub-detail"), + path("", SensorListCreateView.as_view(), name="sensor-hub-list"), ] diff --git a/sensor_hub/views.py b/sensor_hub/views.py index f306101..c210221 100644 --- a/sensor_hub/views.py +++ b/sensor_hub/views.py @@ -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 "/" → Detail of a single sensor. - - POST "" → Create a new sensor. - - PATCH "/" → Update an existing sensor. - - DELETE "/" → 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"