diff --git a/TABLES.md b/TABLES.md deleted file mode 100644 index 28150e7..0000000 --- a/TABLES.md +++ /dev/null @@ -1,260 +0,0 @@ -# مستندات جداول پایگاه داده — CropLogic AI - -این سند تمام جداول (مدل‌های Django) موجود در پروژه را به همراه توضیح ستون‌ها و روابط بین آن‌ها شرح می‌دهد. - ---- - -## فهرست جداول - -| اپ | جدول | توضیح کوتاه | -|---|---|---| -| `location_data` | `SoilLocation` | موقعیت جغرافیایی (lat/lon) | -| `location_data` | `SoilDepthData` | داده‌های خاک به تفکیک عمق | -| `sensor_data` | `SensorData` | آخرین خوانش سنسور برای یک موقعیت | -| `sensor_data` | `SensorDataHistory` | تاریخچه خوانش‌های سنسور | -| `sensor_data` | `SensorParameter` | تعریف پارامترهای سنسور | -| `sensor_data` | `ParameterUpdateLog` | لاگ تغییرات پارامترهای سنسور | -| `weather` | `WeatherParameter` | تعریف پارامترهای هواشناسی | -| `weather` | `WeatherForecast` | پیش‌بینی آب‌وهوای روزانه | -| `plant` | `Plant` | اطلاعات گیاهان | -| `irrigation` | `IrrigationMethod` | روش‌های آبیاری | - ---- - -## اپ: `location_data` - -### جدول `SoilLocation` - -موقعیت‌های جغرافیایی که داده‌های خاک و سنسور به آن‌ها متصل هستند. - -| ستون | نوع | توضیح | -|---|---|---| -| `id` | PK (auto) | شناسه اتوماتیک | -| `latitude` | DecimalField (9,6) | عرض جغرافیایی | -| `longitude` | DecimalField (9,6) | طول جغرافیایی | -| `task_id` | CharField | شناسه تسک Celery در حال پردازش | -| `created_at` | DateTimeField | زمان ایجاد | -| `updated_at` | DateTimeField | آخرین زمان به‌روزرسانی | - -**محدودیت‌ها:** -- ترکیب `(latitude, longitude)` باید یکتا باشد. - -**روابط:** -- ← `SoilDepthData.soil_location` (یک به چند) -- ← `SensorData.location` (یک به چند) -- ← `WeatherForecast.location` (یک به چند) - ---- - -### جدول `SoilDepthData` - -داده‌های خاک از API SoilGrids برای سه عمق مختلف، مرتبط با هر `SoilLocation`. - -| ستون | نوع | توضیح | -|---|---|---| -| `id` | PK (auto) | شناسه اتوماتیک | -| `soil_location` | FK → SoilLocation | موقعیت مکانی مرتبط | -| `depth_label` | CharField | عمق: `0-5cm` / `5-15cm` / `15-30cm` | -| `bdod` | FloatField | چگالی ظاهری خاک (Bulk Density) | -| `cec` | FloatField | ظرفیت تبادل کاتیونی (CEC) | -| `cfvo` | FloatField | درصد حجمی سنگریزه | -| `clay` | FloatField | درصد رس | -| `nitrogen` | FloatField | نیتروژن کل | -| `ocd` | FloatField | تراکم کربن آلی | -| `ocs` | FloatField | ذخیره کربن آلی | -| `phh2o` | FloatField | pH خاک در آب | -| `sand` | FloatField | درصد شن | -| `silt` | FloatField | درصد سیلت | -| `soc` | FloatField | کربن آلی خاک (SOC) | -| `wv0010` | FloatField | رطوبت حجمی در ۱۰ kPa | -| `wv0033` | FloatField | ظرفیت زراعی — رطوبت در ۳۳ kPa | -| `wv1500` | FloatField | نقطه پژمردگی — رطوبت در ۱۵۰۰ kPa | -| `created_at` | DateTimeField | زمان ایجاد | - -**محدودیت‌ها:** -- ترکیب `(soil_location, depth_label)` باید یکتا باشد. - ---- - -## اپ: `sensor_data` - -### جدول `SensorData` - -آخرین خوانش سنسور فیزیکی برای یک موقعیت. هنگام به‌روزرسانی، نسخه قبلی به `SensorDataHistory` منتقل می‌شود. - -| ستون | نوع | توضیح | -|---|---|---| -| `uuid_sensor` | UUIDField (PK) | شناسه یکتای سنسور | -| `location` | FK → SoilLocation | موقعیت مکانی (ستون DB: `location_id`) | -| `soil_moisture` | FloatField | رطوبت خاک | -| `soil_temperature` | FloatField | دمای خاک | -| `soil_ph` | FloatField | pH خاک | -| `electrical_conductivity` | FloatField | هدایت الکتریکی (EC) | -| `nitrogen` | FloatField | ازت (N) | -| `phosphorus` | FloatField | فسفر (P) | -| `potassium` | FloatField | پتاسیم (K) | -| `plants` | M2M → Plant | گیاهان مرتبط با این سنسور | -| `created_at` | DateTimeField | زمان ایجاد | -| `updated_at` | DateTimeField | آخرین زمان به‌روزرسانی | - ---- - -### جدول `SensorDataHistory` - -تاریخچه کامل خوانش‌های سنسور. هر بار که `SensorData` به‌روز می‌شود، نسخه قبلی اینجا ذخیره می‌شود. - -| ستون | نوع | توضیح | -|---|---|---| -| `id` | PK (auto) | شناسه اتوماتیک | -| `uuid_sensor` | UUIDField | شناسه سنسور اصلی | -| `location_id` | IntegerField | شناسه موقعیت مکانی | -| `soil_moisture` | FloatField | رطوبت خاک | -| `soil_temperature` | FloatField | دمای خاک | -| `soil_ph` | FloatField | pH خاک | -| `electrical_conductivity` | FloatField | هدایت الکتریکی | -| `nitrogen` | FloatField | ازت | -| `phosphorus` | FloatField | فسفر | -| `potassium` | FloatField | پتاسیم | -| `recorded_at` | DateTimeField | زمان ثبت در تاریخچه | - -> **نکته:** این جدول FK مستقیم به SoilLocation ندارد تا در صورت حذف موقعیت، تاریخچه حفظ شود. - ---- - -### جدول `SensorParameter` - -کاتالوگ پارامترهای قابل اندازه‌گیری توسط سنسورها. - -| ستون | نوع | توضیح | -|---|---|---| -| `id` | PK (auto) | شناسه اتوماتیک | -| `code` | CharField (unique) | کد یکتا (مثال: `soil_moisture`) | -| `name_fa` | CharField | نام فارسی پارامتر | -| `unit` | CharField | واحد اندازه‌گیری | -| `created_at` | DateTimeField | زمان ایجاد | - ---- - -### جدول `ParameterUpdateLog` - -لاگ تغییرات (افزودن یا ویرایش) پارامترهای سنسور. - -| ستون | نوع | توضیح | -|---|---|---| -| `id` | PK (auto) | شناسه اتوماتیک | -| `parameter` | FK → SensorParameter | پارامتر مرتبط | -| `action` | CharField | نوع عملیات: `added` یا `modified` | -| `updated_at` | DateTimeField | زمان ثبت لاگ | - ---- - -## اپ: `weather` - -### جدول `WeatherParameter` - -کاتالوگ پارامترهای هواشناسی تعریف‌شده در سیستم. - -| ستون | نوع | توضیح | -|---|---|---| -| `id` | PK (auto) | شناسه اتوماتیک | -| `code` | CharField (unique) | کد یکتا (مثال: `temperature_max`) | -| `name_fa` | CharField | نام فارسی پارامتر | -| `unit` | CharField | واحد اندازه‌گیری | -| `created_at` | DateTimeField | زمان ایجاد | - ---- - -### جدول `WeatherForecast` - -پیش‌بینی روزانه آب‌وهوا (تا ۷ روز آینده) برای هر موقعیت مکانی. - -| ستون | نوع | توضیح | -|---|---|---| -| `id` | PK (auto) | شناسه اتوماتیک | -| `location` | FK → SoilLocation | موقعیت مکانی مرتبط | -| `forecast_date` | DateField | تاریخ پیش‌بینی | -| `temperature_min` | FloatField | حداقل دمای هوا (°C) | -| `temperature_max` | FloatField | حداکثر دمای هوا (°C) | -| `temperature_mean` | FloatField | میانگین دمای هوا (°C) | -| `precipitation` | FloatField | مجموع بارش (mm) | -| `precipitation_probability` | FloatField | احتمال بارش (%) | -| `humidity_mean` | FloatField | میانگین رطوبت نسبی (%) | -| `wind_speed_max` | FloatField | حداکثر سرعت باد (km/h) | -| `et0` | FloatField | تبخیر-تعرق مرجع ET₀ (mm/day) | -| `weather_code` | IntegerField | کد وضعیت آب‌وهوا (WMO) | -| `fetched_at` | DateTimeField | آخرین زمان واکشی از API | -| `created_at` | DateTimeField | زمان ایجاد | - -**محدودیت‌ها:** -- ترکیب `(location, forecast_date)` باید یکتا باشد. - -**Property:** -- `will_rain` → `True` اگر `precipitation > 0` - ---- - -## اپ: `plant` - -### جدول `Plant` - -اطلاعات گیاهان شامل شرایط کاشت، نگهداری و برداشت. - -| ستون | نوع | توضیح | -|---|---|---| -| `id` | PK (auto) | شناسه اتوماتیک | -| `name` | CharField (unique) | نام گیاه | -| `light` | CharField | نور مورد نیاز | -| `watering` | CharField | نیاز آبیاری | -| `soil` | CharField | نوع خاک مناسب | -| `temperature` | CharField | دمای مناسب رشد | -| `planting_season` | CharField | فصل کاشت | -| `harvest_time` | CharField | زمان برداشت | -| `spacing` | CharField | فاصله کاشت | -| `fertilizer` | CharField | کود مناسب | -| `created_at` | DateTimeField | زمان ایجاد | -| `updated_at` | DateTimeField | آخرین زمان به‌روزرسانی | - -**روابط:** -- ← `SensorData.plants` (M2M از طریق جدول واسط) - ---- - -## اپ: `irrigation` - -### جدول `IrrigationMethod` - -مشخصات فنی روش‌های مختلف آبیاری. - -| ستون | نوع | توضیح | -|---|---|---| -| `id` | PK (auto) | شناسه اتوماتیک | -| `name` | CharField (unique) | نام روش آبیاری | -| `category` | CharField | دسته‌بندی (موضعی / تحت فشار / سطحی) | -| `description` | TextField | توضیحات کامل | -| `water_efficiency_percent` | FloatField | راندمان مصرف آب (%) | -| `water_pressure_required` | CharField | فشار مورد نیاز | -| `flow_rate` | CharField | دبی / میزان جریان آب | -| `coverage_area` | CharField | مساحت قابل پوشش | -| `soil_type` | CharField | نوع خاک مناسب | -| `climate_suitability` | CharField | اقلیم مناسب | -| `created_at` | DateTimeField | زمان ایجاد | -| `updated_at` | DateTimeField | آخرین زمان به‌روزرسانی | - ---- - -## نمودار روابط (خلاصه) - -``` -SoilLocation - ├── SoilDepthData (1:N — depth_label: 0-5cm, 5-15cm, 15-30cm) - ├── SensorData (1:N — uuid_sensor PK) - │ └── Plant (M:N — جدول واسط) - └── WeatherForecast (1:N — یکتا per location+date) - -SensorParameter - └── ParameterUpdateLog (1:N — action: added/modified) - -Plant (مستقل — از طریق M2M به SensorData متصل) -IrrigationMethod (مستقل — بدون FK) -WeatherParameter (مستقل — کاتالوگ) -``` diff --git a/config/settings.py b/config/settings.py index 595a391..606c787 100644 --- a/config/settings.py +++ b/config/settings.py @@ -28,7 +28,7 @@ INSTALLED_APPS = [ "rag", "tasks", "location_data", - "sensor_data", + "farm_data.apps.FarmDataConfig", "weather", "plant", "irrigation", @@ -125,8 +125,8 @@ SPECTACULAR_SETTINGS = { {"name": "RAG Recommendations", "description": "توصیه‌های آبیاری و کودهی مبتنی بر RAG"}, {"name": "Tasks", "description": "مدیریت تسک‌های Celery"}, {"name": "Soil Data", "description": "داده‌های خاک (SoilGrids)"}, - {"name": "Sensor Data", "description": "داده‌های سنسور"}, - {"name": "Sensor Parameters", "description": "پارامترهای سنسور"}, + {"name": "Farm Data", "description": "داده‌های مزرعه و سنسورها"}, + {"name": "Farm Parameters", "description": "پارامترهای سنسورهای مزرعه"}, {"name": "Plant", "description": "مدیریت گیاهان و دریافت اطلاعات گیاه"}, {"name": "Irrigation", "description": "مدیریت روش‌های آبیاری"}, {"name": "Irrigation Recommendation", "description": "درخواست و پیگیری توصیه آبیاری"}, diff --git a/config/urls.py b/config/urls.py index bb764f1..bf106d7 100644 --- a/config/urls.py +++ b/config/urls.py @@ -17,7 +17,7 @@ urlpatterns = [ path("api/dashboard-data/", include("dashboard_data.urls")), path("api/tasks/", include("tasks.urls")), path("api/soil-data/", include("location_data.urls")), - path("api/sensor-data/", include("sensor_data.urls")), + path("api/farm-data/", include("farm_data.urls")), path("api/plants/", include("plant.urls")), path("api/irrigation/", include("irrigation.urls")), path("api/fertilization/", include("fertilization.urls")), diff --git a/dashboard_data/cards/sensor_radar_chart.py b/dashboard_data/cards/sensor_radar_chart.py index da6f8db..bb655c6 100644 --- a/dashboard_data/cards/sensor_radar_chart.py +++ b/dashboard_data/cards/sensor_radar_chart.py @@ -41,16 +41,6 @@ def _normalize_to_ideal_score(value: float | None, minimum: float, ideal: float, def _resolve_profile(context: dict[str, Any]) -> tuple[dict[str, dict[str, float]], str]: - location = context.get("location") - if location is not None: - location_profile = getattr(location, "ideal_sensor_profile", None) or {} - if location_profile: - merged = { - metric: {**DEFAULT_IDEAL_SENSOR_PROFILE.get(metric, {}), **location_profile.get(metric, {})} - for metric in set(DEFAULT_IDEAL_SENSOR_PROFILE) | set(location_profile) - } - return merged, "location" - plants = context.get("plants", []) for plant in plants: plant_profile = getattr(plant, "health_profile", None) or {} diff --git a/dashboard_data/cards/soil_moisture_heatmap.py b/dashboard_data/cards/soil_moisture_heatmap.py index 5ba6d51..e9440e3 100644 --- a/dashboard_data/cards/soil_moisture_heatmap.py +++ b/dashboard_data/cards/soil_moisture_heatmap.py @@ -3,7 +3,7 @@ from __future__ import annotations from math import sqrt from typing import Any -from sensor_data.models import SensorData, SensorDataHistory +from farm_data.models import SensorData QUALITY_REAL = "REAL" @@ -61,9 +61,9 @@ def _latest_sensor_measurement(sensor: Any, histories: list[Any]) -> dict[str, A series = _sensor_time_series(sensor, histories) latest = series[-1] if series else {"timestamp": None, "value": None, "quality_flag": QUALITY_MISSING} return { - "sensor_id": str(sensor.uuid_sensor), - "latitude": float(sensor.location.latitude), - "longitude": float(sensor.location.longitude), + "sensor_id": str(sensor.farm_uuid), + "latitude": float(sensor.center_location.latitude), + "longitude": float(sensor.center_location.longitude), "depth": None, "timestamp": latest["timestamp"], "soil_moisture_value": latest["value"], @@ -99,7 +99,7 @@ def _grid_axis(min_value: float, max_value: float) -> list[float]: def _load_sensor_network(current_sensor: Any) -> list[Any]: plant_ids = list(current_sensor.plants.values_list("id", flat=True)) - queryset = SensorData.objects.select_related("location").prefetch_related("plants") + queryset = SensorData.objects.select_related("center_location").prefetch_related("plants") if plant_ids: queryset = queryset.filter(plants__id__in=plant_ids).distinct() return list(queryset) @@ -118,14 +118,8 @@ def build_soil_moisture_heatmap(sensor_id: str, context: dict | None = None, ai_ } sensors = _load_sensor_network(current_sensor) - sensor_ids = [sensor.uuid_sensor for sensor in sensors] - history_rows = SensorDataHistory.objects.filter(uuid_sensor__in=sensor_ids).order_by("-recorded_at")[:200] - history_map: dict[Any, list[Any]] = {} - for row in history_rows: - history_map.setdefault(row.uuid_sensor, []).append(row) - sensor_points = [ - _latest_sensor_measurement(sensor, history_map.get(sensor.uuid_sensor, [])) + _latest_sensor_measurement(sensor, []) for sensor in sensors ] valid_sensor_points = [point for point in sensor_points if point["soil_moisture_value"] is not None] diff --git a/dashboard_data/context.py b/dashboard_data/context.py index 508df78..a8b8daa 100644 --- a/dashboard_data/context.py +++ b/dashboard_data/context.py @@ -4,17 +4,17 @@ from datetime import date def load_dashboard_context(sensor_id: str) -> dict | None: from irrigation.models import IrrigationMethod from location_data.models import SoilDepthData - from sensor_data.models import SensorData, SensorDataHistory + from farm_data.models import SensorData from weather.models import WeatherForecast try: - sensor = SensorData.objects.select_related("location").prefetch_related("plants").get( - uuid_sensor=sensor_id + sensor = SensorData.objects.select_related("center_location").prefetch_related("plants").get( + farm_uuid=sensor_id ) except SensorData.DoesNotExist: return None - location = sensor.location + location = sensor.center_location depths = list( SoilDepthData.objects.filter(soil_location=location).order_by("depth_label") ) @@ -22,9 +22,6 @@ def load_dashboard_context(sensor_id: str) -> dict | None: WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()) .order_by("forecast_date")[:7] ) - history = list( - SensorDataHistory.objects.filter(uuid_sensor=sensor_id).order_by("-recorded_at")[:30] - ) plants = list(sensor.plants.all()) irrigation_methods = list(IrrigationMethod.objects.all()[:5]) @@ -33,8 +30,7 @@ def load_dashboard_context(sensor_id: str) -> dict | None: "location": location, "depths": depths, "forecasts": forecasts, - "history": history, + "history": [], "plants": plants, "irrigation_methods": irrigation_methods, } - diff --git a/dashboard_data/services.py b/dashboard_data/services.py index c9d671d..710ffec 100644 --- a/dashboard_data/services.py +++ b/dashboard_data/services.py @@ -53,7 +53,7 @@ def _farm_profile_from_context(context: dict) -> dict: irrigation_methods = context.get("irrigation_methods", []) return { - "sensor_id": str(getattr(sensor, "uuid_sensor", "")) if sensor else "", + "sensor_id": str(getattr(sensor, "farm_uuid", "")) if sensor else "", "crop_type": getattr(plants[0], "name", None) if plants else None, "region": { "latitude": float(location.latitude) if location else None, diff --git a/entrypoint.sh b/entrypoint.sh index 85ded20..a223195 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,10 +1,47 @@ #!/bin/sh set -e + +wait_for_db() { + python - <<'PY' +import os +import socket +import sys +import time + +host = os.environ.get("DB_HOST", "db") +port = int(os.environ.get("DB_PORT", "3306")) +deadline = time.time() + 90 + +while time.time() < deadline: + try: + with socket.create_connection((host, port), timeout=3): + print(f"Database is reachable at {host}:{port}") + sys.exit(0) + except OSError as exc: + print(f"Waiting for database {host}:{port}... {exc}") + time.sleep(2) + +print(f"Timed out waiting for database {host}:{port}", file=sys.stderr) +sys.exit(1) +PY +} + if [ "${SKIP_MIGRATE}" != "1" ]; then + wait_for_db echo "Running migrations..." python manage.py repair_location_tables python manage.py migrate --noinput echo "Migrations done." fi + +if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then + echo "DEVELOP is set. Seeding demo plant, location_data, weather_data, and farm_data..." + python manage.py seed_plants + python manage.py seed_location_data + python manage.py seed_weather_data + python manage.py seed_farm_data + echo "Demo seeders done." +fi + echo "Starting command: $*" exec "$@" diff --git a/sensor_data/__init__.py b/farm_data/__init__.py similarity index 100% rename from sensor_data/__init__.py rename to farm_data/__init__.py diff --git a/farm_data/admin.py b/farm_data/admin.py new file mode 100644 index 0000000..a3d95c0 --- /dev/null +++ b/farm_data/admin.py @@ -0,0 +1,35 @@ +from django.contrib import admin + +from .models import ParameterUpdateLog, SensorData, SensorParameter + + +@admin.register(SensorData) +class SensorDataAdmin(admin.ModelAdmin): + list_display = ( + "farm_uuid", + "center_location_id", + "weather_forecast_id", + "sensor_keys", + "updated_at", + ) + list_filter = ("updated_at",) + search_fields = ("farm_uuid", "center_location_id") + filter_horizontal = ("plants",) + + @admin.display(description="sensor keys") + def sensor_keys(self, obj): + payload = obj.sensor_payload if isinstance(obj.sensor_payload, dict) else {} + return ", ".join(payload.keys()) + + +@admin.register(SensorParameter) +class SensorParameterAdmin(admin.ModelAdmin): + list_display = ("sensor_key", "code", "name_fa", "unit", "data_type", "created_at") + search_fields = ("sensor_key", "code", "name_fa") + list_filter = ("sensor_key", "data_type") + + +@admin.register(ParameterUpdateLog) +class ParameterUpdateLogAdmin(admin.ModelAdmin): + list_display = ("parameter", "action", "updated_at") + list_filter = ("action", "updated_at") diff --git a/farm_data/apps.py b/farm_data/apps.py new file mode 100644 index 0000000..b418c39 --- /dev/null +++ b/farm_data/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class FarmDataConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "farm_data" + label = "sensor_data" + verbose_name = "farm-data" diff --git a/sensor_data/management/__init__.py b/farm_data/management/__init__.py similarity index 100% rename from sensor_data/management/__init__.py rename to farm_data/management/__init__.py diff --git a/sensor_data/management/commands/__init__.py b/farm_data/management/commands/__init__.py similarity index 100% rename from sensor_data/management/commands/__init__.py rename to farm_data/management/commands/__init__.py diff --git a/farm_data/management/commands/seed_farm_data.py b/farm_data/management/commands/seed_farm_data.py new file mode 100644 index 0000000..5d01937 --- /dev/null +++ b/farm_data/management/commands/seed_farm_data.py @@ -0,0 +1,69 @@ +""" +Management command to seed a fixed demo farm-data record. +Run: python manage.py seed_farm_data +""" +from uuid import UUID + +from django.core.management.base import BaseCommand + +from farm_data.models import SensorData +from location_data.models import SoilLocation +from plant.models import Plant +from weather.models import WeatherForecast + + +DEMO_FARM_UUID = UUID("11111111-1111-1111-1111-111111111111") +DEMO_LATITUDE = "50.000000" +DEMO_LONGITUDE = "50.000000" +DEMO_SENSOR_PAYLOAD = { + "sensor-7-1": { + "soil_moisture": 42.3, + "soil_temperature": 21.4, + "soil_ph": 6.9, + "electrical_conductivity": 1.1, + "nitrogen": 28.0, + "phosphorus": 14.0, + "potassium": 19.0, + } +} +DEMO_PLANT_NAMES = [ + "گوجه‌فرنگی", + "خیار", +] + + +class Command(BaseCommand): + help = "Seed a fixed farm-data row with farm_uuid=11111111-1111-1111-1111-111111111111." + + def handle(self, *args, **options): + location, _ = SoilLocation.objects.get_or_create( + latitude=DEMO_LATITUDE, + longitude=DEMO_LONGITUDE, + ) + weather_forecast = ( + WeatherForecast.objects.filter(location=location) + .order_by("-forecast_date", "-id") + .first() + ) + + farm_data, created = SensorData.objects.update_or_create( + farm_uuid=DEMO_FARM_UUID, + defaults={ + "center_location": location, + "weather_forecast": weather_forecast, + "sensor_payload": DEMO_SENSOR_PAYLOAD, + }, + ) + plants = list(Plant.objects.filter(name__in=DEMO_PLANT_NAMES).order_by("name")) + if plants: + farm_data.plants.set(plants) + + status_text = "Created" if created else "Updated" + weather_text = weather_forecast.id if weather_forecast else "None" + plant_count = len(plants) + self.stdout.write( + self.style.SUCCESS( + f"{status_text} farm-data {farm_data.farm_uuid} for center_location_id={location.id} weather_forecast_id={weather_text} plants={plant_count}" + ) + ) + self.stdout.write(self.style.SUCCESS("\nDone seeding farm_data demo record.")) diff --git a/farm_data/management/commands/seed_sensor_parameters.py b/farm_data/management/commands/seed_sensor_parameters.py new file mode 100644 index 0000000..a343890 --- /dev/null +++ b/farm_data/management/commands/seed_sensor_parameters.py @@ -0,0 +1,70 @@ +""" +Management command to seed the 7 initial sensor parameters. +Run: python manage.py seed_sensor_parameters +""" +from django.core.management.base import BaseCommand + +from farm_data.models import ( + DEFAULT_SENSOR_DATA_TYPE, + DEFAULT_SENSOR_KEY, + ParameterUpdateLog, + SensorParameter, +) + + +INITIAL_PARAMETERS = [ + ("soil_moisture", "رطوبت خاک", "%"), + ("soil_temperature", "دما خاک", "°C"), + ("soil_ph", "pH خاک", ""), + ("electrical_conductivity", "هدایت الکتریکی", "dS/m"), + ("nitrogen", "ازت (N)", "mg/kg"), + ("phosphorus", "فسفر", "mg/kg"), + ("potassium", "پتاسیم", "mg/kg"), +] + + +class Command(BaseCommand): + help = "Seed 7 initial sensor parameters (soil_moisture, soil_temperature, etc.)" + + def add_arguments(self, parser): + parser.add_argument( + "--sensor-key", + default=DEFAULT_SENSOR_KEY, + help='کلید سنسور مثل "sensor-7-1" یا "leaf-sensor"', + ) + + def handle(self, *args, **options): + sensor_key = options["sensor_key"] + created_count = 0 + for code, name_fa, unit in INITIAL_PARAMETERS: + param, created = SensorParameter.objects.get_or_create( + sensor_key=sensor_key, + code=code, + defaults={ + "name_fa": name_fa, + "unit": unit, + "data_type": DEFAULT_SENSOR_DATA_TYPE, + }, + ) + if created: + ParameterUpdateLog.objects.create( + parameter=param, + action="added", + payload={ + "sensor_key": sensor_key, + "code": code, + "name_fa": name_fa, + "unit": unit, + }, + ) + created_count += 1 + self.stdout.write( + self.style.SUCCESS( + f" Created: {sensor_key}.{code} ({name_fa})" + ) + ) + self.stdout.write( + self.style.SUCCESS( + f"\nDone. Created {created_count} new parameters for {sensor_key}." + ) + ) diff --git a/sensor_data/migrations/0001_initial.py b/farm_data/migrations/0001_initial.py similarity index 100% rename from sensor_data/migrations/0001_initial.py rename to farm_data/migrations/0001_initial.py diff --git a/sensor_data/migrations/0002_seed_initial_parameters.py b/farm_data/migrations/0002_seed_initial_parameters.py similarity index 100% rename from sensor_data/migrations/0002_seed_initial_parameters.py rename to farm_data/migrations/0002_seed_initial_parameters.py diff --git a/sensor_data/migrations/0003_sensordata_plants.py b/farm_data/migrations/0003_sensordata_plants.py similarity index 100% rename from sensor_data/migrations/0003_sensordata_plants.py rename to farm_data/migrations/0003_sensordata_plants.py diff --git a/sensor_data/migrations/0004_alter_sensordata_location.py b/farm_data/migrations/0004_alter_sensordata_location.py similarity index 100% rename from sensor_data/migrations/0004_alter_sensordata_location.py rename to farm_data/migrations/0004_alter_sensordata_location.py diff --git a/farm_data/migrations/0005_delete_sensordatahistory.py b/farm_data/migrations/0005_delete_sensordatahistory.py new file mode 100644 index 0000000..c9b05d5 --- /dev/null +++ b/farm_data/migrations/0005_delete_sensordatahistory.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0004_alter_sensordata_location"), + ] + + operations = [ + migrations.DeleteModel( + name="SensorDataHistory", + ), + ] diff --git a/farm_data/migrations/0006_sensor_payload_and_dynamic_parameters.py b/farm_data/migrations/0006_sensor_payload_and_dynamic_parameters.py new file mode 100644 index 0000000..c02d270 --- /dev/null +++ b/farm_data/migrations/0006_sensor_payload_and_dynamic_parameters.py @@ -0,0 +1,139 @@ +from django.db import migrations, models + + +DEFAULT_SENSOR_KEY = "sensor-7-1" + + +def migrate_sensor_fields_to_payload(apps, schema_editor): + SensorData = apps.get_model("sensor_data", "SensorData") + field_names = [ + "soil_moisture", + "soil_temperature", + "soil_ph", + "electrical_conductivity", + "nitrogen", + "phosphorus", + "potassium", + ] + + for sensor in SensorData.objects.all().iterator(): + values = {} + for field_name in field_names: + value = getattr(sensor, field_name, None) + if value is not None: + values[field_name] = value + + sensor.sensor_payload = {DEFAULT_SENSOR_KEY: values} if values else {} + sensor.save(update_fields=["sensor_payload"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0005_delete_sensordatahistory"), + ] + + operations = [ + migrations.AddField( + model_name="sensordata", + name="sensor_payload", + field=models.JSONField( + blank=True, + default=dict, + help_text='اطلاعات سنسورها در فرمت {"sensor-7-1": {...}}', + ), + ), + migrations.AddField( + model_name="sensorparameter", + name="sensor_key", + field=models.CharField( + db_index=True, + default=DEFAULT_SENSOR_KEY, + help_text='کلید سنسور داخل JSON مثل "sensor-7-1" یا "leaf-sensor"', + max_length=64, + ), + ), + migrations.AddField( + model_name="sensorparameter", + name="data_type", + field=models.CharField( + default="float", + help_text="نوع داده پارامتر مثل float, int, string, bool", + max_length=32, + ), + ), + migrations.AddField( + model_name="sensorparameter", + name="metadata", + field=models.JSONField( + blank=True, + default=dict, + help_text="اطلاعات تکمیلی پارامتر مثل بازه مجاز، توضیح یا تنظیمات UI", + ), + ), + migrations.AddField( + model_name="parameterupdatelog", + name="payload", + field=models.JSONField( + blank=True, + default=dict, + help_text="خلاصه تغییرات پارامتر برای audit", + ), + ), + migrations.RunPython( + migrate_sensor_fields_to_payload, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name="sensordata", + name="soil_moisture", + ), + migrations.RemoveField( + model_name="sensordata", + name="soil_temperature", + ), + migrations.RemoveField( + model_name="sensordata", + name="soil_ph", + ), + migrations.RemoveField( + model_name="sensordata", + name="electrical_conductivity", + ), + migrations.RemoveField( + model_name="sensordata", + name="nitrogen", + ), + migrations.RemoveField( + model_name="sensordata", + name="phosphorus", + ), + migrations.RemoveField( + model_name="sensordata", + name="potassium", + ), + migrations.AlterField( + model_name="sensorparameter", + name="code", + field=models.CharField( + db_index=True, + help_text="کد پارامتر (مثلاً soil_moisture)", + max_length=64, + ), + ), + migrations.AlterModelOptions( + name="sensorparameter", + options={ + "ordering": ["sensor_key", "code"], + "verbose_name": "پارامتر سنسور", + "verbose_name_plural": "پارامترهای سنسور", + }, + ), + migrations.AddConstraint( + model_name="sensorparameter", + constraint=models.UniqueConstraint( + fields=("sensor_key", "code"), + name="sensor_parameter_unique_sensor_code", + ), + ), + ] diff --git a/farm_data/migrations/0007_rename_uuid_sensor_to_farm_uuid.py b/farm_data/migrations/0007_rename_uuid_sensor_to_farm_uuid.py new file mode 100644 index 0000000..28d2e82 --- /dev/null +++ b/farm_data/migrations/0007_rename_uuid_sensor_to_farm_uuid.py @@ -0,0 +1,16 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0006_sensor_payload_and_dynamic_parameters"), + ] + + operations = [ + migrations.RenameField( + model_name="sensordata", + old_name="uuid_sensor", + new_name="farm_uuid", + ), + ] diff --git a/farm_data/migrations/0008_rename_location_to_center_location.py b/farm_data/migrations/0008_rename_location_to_center_location.py new file mode 100644 index 0000000..8f05c48 --- /dev/null +++ b/farm_data/migrations/0008_rename_location_to_center_location.py @@ -0,0 +1,29 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0005_merge_20260327_0840"), + ("sensor_data", "0007_rename_uuid_sensor_to_farm_uuid"), + ] + + operations = [ + migrations.RenameField( + model_name="sensordata", + old_name="location", + new_name="center_location", + ), + migrations.AlterField( + model_name="sensordata", + name="center_location", + field=models.ForeignKey( + db_column="center_location_id", + help_text="مرکز زمین مرتبط از جدول location_data.SoilLocation", + on_delete=django.db.models.deletion.CASCADE, + related_name="farm_data", + to="location_data.soillocation", + ), + ), + ] diff --git a/farm_data/migrations/0009_add_weather_forecast_to_sensordata.py b/farm_data/migrations/0009_add_weather_forecast_to_sensordata.py new file mode 100644 index 0000000..da6ab48 --- /dev/null +++ b/farm_data/migrations/0009_add_weather_forecast_to_sensordata.py @@ -0,0 +1,45 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def link_latest_weather_forecast(apps, schema_editor): + SensorData = apps.get_model("sensor_data", "SensorData") + WeatherForecast = apps.get_model("weather", "WeatherForecast") + + for farm_data in SensorData.objects.all().iterator(): + forecast = ( + WeatherForecast.objects.filter(location_id=farm_data.center_location_id) + .order_by("-forecast_date", "-id") + .first() + ) + if forecast: + farm_data.weather_forecast_id = forecast.id + farm_data.save(update_fields=["weather_forecast"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0008_rename_location_to_center_location"), + ("weather", "0003_seed_weather_forecasts"), + ] + + operations = [ + migrations.AddField( + model_name="sensordata", + name="weather_forecast", + field=models.ForeignKey( + blank=True, + db_column="weather_forecast_id", + help_text="رکورد آب وهوای مرتبط با مرکز زمین", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="farm_data_entries", + to="weather.weatherforecast", + ), + ), + migrations.RunPython( + link_latest_weather_forecast, + migrations.RunPython.noop, + ), + ] diff --git a/farm_data/migrations/0010_rename_tables_to_farm_data.py b/farm_data/migrations/0010_rename_tables_to_farm_data.py new file mode 100644 index 0000000..d480f6d --- /dev/null +++ b/farm_data/migrations/0010_rename_tables_to_farm_data.py @@ -0,0 +1,44 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0009_add_weather_forecast_to_sensordata"), + ] + + operations = [ + migrations.AlterField( + model_name="sensordata", + name="farm_uuid", + field=models.UUIDField( + editable=False, + help_text="شناسه یکتای farm که از API دریافت می‌شود", + primary_key=True, + serialize=False, + ), + ), + migrations.AlterField( + model_name="sensordata", + name="plants", + field=models.ManyToManyField( + blank=True, + db_table="farm_data_sensordata_plants", + help_text="گیاهان مرتبط با این farm", + related_name="farm_data", + to="plant.plant", + ), + ), + migrations.AlterModelTable( + name="sensordata", + table="farm_data_sensordata", + ), + migrations.AlterModelTable( + name="sensorparameter", + table="farm_data_sensorparameter", + ), + migrations.AlterModelTable( + name="parameterupdatelog", + table="farm_data_parameterupdatelog", + ), + ] diff --git a/sensor_data/migrations/__init__.py b/farm_data/migrations/__init__.py similarity index 100% rename from sensor_data/migrations/__init__.py rename to farm_data/migrations/__init__.py diff --git a/farm_data/models.py b/farm_data/models.py new file mode 100644 index 0000000..f2b4dff --- /dev/null +++ b/farm_data/models.py @@ -0,0 +1,226 @@ +from django.db import models + + +DEFAULT_SENSOR_KEY = "sensor-7-1" +DEFAULT_SENSOR_DATA_TYPE = "float" + + +class SensorPayloadMixin: + """دسترسی سازگار به مقادیر سنسور از payload پویا.""" + + sensor_payload: dict + + def _payload(self) -> dict: + if isinstance(self.sensor_payload, dict): + return self.sensor_payload + return {} + + def get_sensor_block(self, sensor_key: str | None = None) -> dict: + payload = self._payload() + if sensor_key: + block = payload.get(sensor_key, {}) + return block if isinstance(block, dict) else {} + + for block in payload.values(): + if isinstance(block, dict): + return block + return {} + + def get_metric(self, metric_name: str, sensor_key: str | None = None): + block = self.get_sensor_block(sensor_key) + if metric_name in block: + return block.get(metric_name) + + for candidate in self._payload().values(): + if isinstance(candidate, dict) and metric_name in candidate: + return candidate.get(metric_name) + return None + + @property + def soil_moisture(self): + return self.get_metric("soil_moisture") + + @property + def soil_temperature(self): + return self.get_metric("soil_temperature") + + @property + def soil_ph(self): + return self.get_metric("soil_ph") + + @property + def electrical_conductivity(self): + return self.get_metric("electrical_conductivity") + + @property + def nitrogen(self): + return self.get_metric("nitrogen") + + @property + def phosphorus(self): + return self.get_metric("phosphorus") + + @property + def potassium(self): + return self.get_metric("potassium") + + +class SensorData(SensorPayloadMixin, models.Model): + """ + داده‌های مزرعه/سنسور برای مرکز زمین. + مقادیر سنسورها به‌صورت JSON ذخیره می‌شوند تا بتوان چند نوع سنسور + و پارامترهای دلخواه را در یک رکورد نگه داشت. + نمونه: + { + "sensor-7-1": { + "soil_moisture": 22.4, + "soil_temperature": 18.1 + }, + "leaf-sensor": { + "leaf_wetness": 11 + } + } + """ + + farm_uuid = models.UUIDField( + primary_key=True, + editable=False, + help_text="شناسه یکتای farm که از API دریافت می‌شود", + ) + center_location = models.ForeignKey( + "location_data.SoilLocation", + on_delete=models.CASCADE, + related_name="farm_data", + db_column="center_location_id", + help_text="مرکز زمین مرتبط از جدول location_data.SoilLocation", + ) + weather_forecast = models.ForeignKey( + "weather.WeatherForecast", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="farm_data_entries", + db_column="weather_forecast_id", + help_text="رکورد آب وهوای مرتبط با مرکز زمین", + ) + sensor_payload = models.JSONField( + default=dict, + blank=True, + help_text='اطلاعات سنسورها در فرمت {"sensor-7-1": {...}}', + ) + plants = models.ManyToManyField( + "plant.Plant", + blank=True, + db_table="farm_data_sensordata_plants", + related_name="farm_data", + help_text="گیاهان مرتبط با این farm", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_data_sensordata" + ordering = ["-updated_at"] + verbose_name = "farm-data" + verbose_name_plural = "farm-data" + + def __str__(self): + return ( + f"SensorData({self.farm_uuid}, center_location={self.center_location_id}, " + f"weather_forecast={self.weather_forecast_id})" + ) + + @property + def location(self): + return self.center_location + + @location.setter + def location(self, value): + self.center_location = value + + @property + def location_id(self): + return self.center_location_id + + +class SensorParameter(models.Model): + """ + تعریف پارامترهای سنسور برای هر نوع سنسور. + با این ساختار می‌توان برای sensor-7-1 یا هر سنسور جدید، + پارامترهای اختصاصی تعریف کرد. + """ + + sensor_key = models.CharField( + max_length=64, + db_index=True, + default=DEFAULT_SENSOR_KEY, + help_text='کلید سنسور داخل JSON مثل "sensor-7-1" یا "leaf-sensor"', + ) + code = models.CharField( + max_length=64, + db_index=True, + help_text="کد پارامتر (مثلاً soil_moisture)", + ) + name_fa = models.CharField(max_length=128, help_text="نام فارسی") + unit = models.CharField(max_length=32, blank=True, help_text="واحد اندازه‌گیری") + data_type = models.CharField( + max_length=32, + default=DEFAULT_SENSOR_DATA_TYPE, + help_text="نوع داده پارامتر مثل float, int, string, bool", + ) + metadata = models.JSONField( + default=dict, + blank=True, + help_text="اطلاعات تکمیلی پارامتر مثل بازه مجاز، توضیح یا تنظیمات UI", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "farm_data_sensorparameter" + ordering = ["sensor_key", "code"] + constraints = [ + models.UniqueConstraint( + fields=["sensor_key", "code"], + name="sensor_parameter_unique_sensor_code", + ) + ] + verbose_name = "پارامتر سنسور" + verbose_name_plural = "پارامترهای سنسور" + + def __str__(self): + return f"{self.sensor_key}.{self.code} ({self.name_fa})" + + +class ParameterUpdateLog(models.Model): + """ + لاگ آپدیت لیست پارامترها. + """ + + ACTION_ADDED = "added" + ACTION_MODIFIED = "modified" + ACTION_CHOICES = [ + (ACTION_ADDED, "اضافه شده"), + (ACTION_MODIFIED, "ویرایش شده"), + ] + + parameter = models.ForeignKey( + SensorParameter, + on_delete=models.CASCADE, + related_name="update_logs", + ) + action = models.CharField(max_length=16, choices=ACTION_CHOICES) + payload = models.JSONField( + default=dict, + blank=True, + help_text="خلاصه تغییرات پارامتر برای audit", + ) + updated_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "farm_data_parameterupdatelog" + ordering = ["-updated_at"] + verbose_name = "لاگ آپدیت پارامتر" + verbose_name_plural = "لاگ آپدیت پارامترها" + + def __str__(self): + return f"{self.parameter.code} - {self.action} - {self.updated_at}" diff --git a/farm_data/postman/farm_data.json b/farm_data/postman/farm_data.json new file mode 100644 index 0000000..2f0f7e3 --- /dev/null +++ b/farm_data/postman/farm_data.json @@ -0,0 +1,53 @@ +{ + "info": { + "name": "Farm Data", + "description": "API داده‌های farm: ایجاد/آپدیت رکورد farm و مدیریت پارامترهای سنسور", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + {"key": "baseUrl", "value": "http://localhost:8020"}, + {"key": "farm_uuid", "value": "00000000-0000-0000-0000-000000000000"} + ], + "item": [ + { + "name": "Upsert Farm Data (POST)", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Accept", "value": "application/json"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"farm_uuid\": \"{{farm_uuid}}\",\n \"farm_boundary\": {\n \"corners\": [\n {\"lat\": 35.7000, \"lon\": 51.3900},\n {\"lat\": 35.7000, \"lon\": 51.4100},\n {\"lat\": 35.7200, \"lon\": 51.4100},\n {\"lat\": 35.7200, \"lon\": 51.3900}\n ]\n },\n \"sensor_payload\": {\n \"sensor-7-1\": {\n \"soil_moisture\": 25.5,\n \"soil_temperature\": 22.3,\n \"soil_ph\": 7.2,\n \"electrical_conductivity\": 1.8,\n \"nitrogen\": 120.0,\n \"phosphorus\": 45.0,\n \"potassium\": 180.0\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/farm-data/", + "host": ["{{baseUrl}}"], + "path": ["api", "farm-data", ""] + } + }, + "description": "ایجاد یا آپدیت داده farm. مختصات گوشه‌های زمین را می‌گیرد، مرکز را خودش محاسبه می‌کند، location را می‌سازد و weather را از همان location پیدا می‌کند." + }, + { + "name": "Add Parameter", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Accept", "value": "application/json"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"sensor_key\": \"sensor-7-1\",\n \"code\": \"soil_moisture\",\n \"name_fa\": \"رطوبت خاک\",\n \"unit\": \"%\",\n \"data_type\": \"float\",\n \"metadata\": {\n \"min\": 0,\n \"max\": 100\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/farm-data/parameters/", + "host": ["{{baseUrl}}"], + "path": ["api", "farm-data", "parameters", ""] + } + }, + "description": "اضافه کردن یا ویرایش پارامتر جدید. در ParameterUpdateLog ثبت می‌شود." + } + ] +} diff --git a/farm_data/serializers.py b/farm_data/serializers.py new file mode 100644 index 0000000..5b913f3 --- /dev/null +++ b/farm_data/serializers.py @@ -0,0 +1,159 @@ +from rest_framework import serializers + +from location_data.serializers import SoilDepthDataSerializer +from plant.serializers import PlantSerializer +from weather.models import WeatherForecast + +from .models import DEFAULT_SENSOR_DATA_TYPE, DEFAULT_SENSOR_KEY, SensorData + + +class SensorDataUpdateSerializer(serializers.Serializer): + """ورودی آپدیت داده سنسور در ساختار JSON.""" + + farm_uuid = serializers.UUIDField(required=True) + farm_boundary = serializers.JSONField(required=True) + sensor_key = serializers.CharField(required=False, default=DEFAULT_SENSOR_KEY) + sensor_payload = serializers.JSONField(required=False) + plant_ids = serializers.ListField( + child=serializers.IntegerField(), + required=False, + help_text="لیست شناسه گیاهان مرتبط", + ) + + def to_internal_value(self, data): + if not isinstance(data, dict): + raise serializers.ValidationError("بدنه درخواست باید JSON object باشد.") + + payload = dict(data) + known_fields = { + "farm_uuid", + "farm_boundary", + "sensor_key", + "sensor_payload", + "plant_ids", + } + flat_metrics = { + key: value + for key, value in payload.items() + if key not in known_fields + } + + if flat_metrics: + sensor_key = payload.get("sensor_key", DEFAULT_SENSOR_KEY) + nested_payload = payload.get("sensor_payload") or {} + if nested_payload and not isinstance(nested_payload, dict): + raise serializers.ValidationError( + {"sensor_payload": "sensor_payload باید object باشد."} + ) + merged_payload = dict(nested_payload) + current_sensor_payload = merged_payload.get(sensor_key, {}) + if current_sensor_payload and not isinstance(current_sensor_payload, dict): + raise serializers.ValidationError( + {"sensor_payload": f"مقدار {sensor_key} باید object باشد."} + ) + merged_sensor_payload = dict(current_sensor_payload) + merged_sensor_payload.update(flat_metrics) + merged_payload[sensor_key] = merged_sensor_payload + payload["sensor_payload"] = merged_payload + + for key in flat_metrics: + payload.pop(key, None) + + return super().to_internal_value(payload) + + def validate_sensor_payload(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError("sensor_payload باید object باشد.") + for sensor_key, sensor_values in value.items(): + if not isinstance(sensor_values, dict): + raise serializers.ValidationError( + f"مقدار سنسور {sensor_key} باید object باشد." + ) + return value + + def validate(self, attrs): + if "sensor_payload" not in attrs and "plant_ids" not in attrs: + raise serializers.ValidationError( + "حداقل یکی از sensor_payload یا plant_ids باید ارسال شود." + ) + return attrs + + +class SensorDataResponseSerializer(serializers.ModelSerializer): + """سریالایزر خروجی برای SensorData.""" + + plant_ids = serializers.PrimaryKeyRelatedField( + source="plants", + many=True, + read_only=True, + ) + + class Meta: + model = SensorData + fields = [ + "farm_uuid", + "center_location_id", + "weather_forecast_id", + "sensor_payload", + "plant_ids", + "created_at", + "updated_at", + ] + + +class SensorParameterSerializer(serializers.Serializer): + """سریالایزر ورودی برای تعریف پارامترهای سنسورهای مختلف.""" + + sensor_key = serializers.CharField(max_length=64, required=False, default=DEFAULT_SENSOR_KEY) + code = serializers.CharField(max_length=64) + name_fa = serializers.CharField(max_length=128) + unit = serializers.CharField(max_length=32, required=False, allow_blank=True) + data_type = serializers.CharField( + max_length=32, + required=False, + default=DEFAULT_SENSOR_DATA_TYPE, + ) + metadata = serializers.JSONField(required=False, default=dict) + + +class FarmCenterLocationSerializer(serializers.Serializer): + id = serializers.IntegerField() + lat = serializers.DecimalField(max_digits=9, decimal_places=6) + lon = serializers.DecimalField(max_digits=9, decimal_places=6) + farm_boundary = serializers.JSONField() + + +class WeatherForecastDetailSerializer(serializers.ModelSerializer): + class Meta: + model = WeatherForecast + fields = [ + "id", + "forecast_date", + "temperature_min", + "temperature_max", + "temperature_mean", + "precipitation", + "precipitation_probability", + "humidity_mean", + "wind_speed_max", + "et0", + "weather_code", + ] + + +class FarmSoilPayloadSerializer(serializers.Serializer): + resolved_metrics = serializers.JSONField() + metric_sources = serializers.JSONField() + depths = SoilDepthDataSerializer(many=True) + + +class FarmDetailSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + center_location = FarmCenterLocationSerializer() + weather = WeatherForecastDetailSerializer(allow_null=True) + sensor_payload = serializers.JSONField() + soil = FarmSoilPayloadSerializer() + plant_ids = serializers.ListField(child=serializers.IntegerField()) + plants = PlantSerializer(many=True) + created_at = serializers.DateTimeField() + updated_at = serializers.DateTimeField() diff --git a/farm_data/services.py b/farm_data/services.py new file mode 100644 index 0000000..38e5183 --- /dev/null +++ b/farm_data/services.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP + +from django.db import transaction + +from location_data.models import SoilLocation +from location_data.serializers import SoilDepthDataSerializer +from plant.serializers import PlantSerializer +from weather.models import WeatherForecast + +from .models import SensorData +from .serializers import WeatherForecastDetailSerializer + + +DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"] +DECIMAL_PRECISION = Decimal("0.000001") + + +def get_farm_details(farm_uuid: str): + farm = ( + SensorData.objects.select_related("center_location", "weather_forecast") + .prefetch_related("plants", "center_location__depths") + .filter(farm_uuid=farm_uuid) + .first() + ) + if farm is None: + return None + + center_location = farm.center_location + weather = farm.weather_forecast + if weather is None: + weather = ( + center_location.weather_forecasts.order_by("-forecast_date", "-id").first() + ) + + depths = list(center_location.depths.all()) + depths.sort(key=lambda item: DEPTH_PRIORITY.index(item.depth_label) if item.depth_label in DEPTH_PRIORITY else 99) + + soil_metrics = _surface_soil_metrics(depths) + sensor_metrics = _flatten_sensor_metrics(farm.sensor_payload) + + resolved_metrics = dict(soil_metrics) + metric_sources = {key: "soil" for key in soil_metrics} + for key, value in sensor_metrics.items(): + resolved_metrics[key] = value + metric_sources[key] = "sensor" + + return { + "farm_uuid": farm.farm_uuid, + "center_location": { + "id": center_location.id, + "lat": center_location.latitude, + "lon": center_location.longitude, + "farm_boundary": center_location.farm_boundary, + }, + "weather": WeatherForecastDetailSerializer(weather).data if weather else None, + "sensor_payload": farm.sensor_payload or {}, + "soil": { + "resolved_metrics": resolved_metrics, + "metric_sources": metric_sources, + "depths": SoilDepthDataSerializer(depths, many=True).data, + }, + "plant_ids": list(farm.plants.values_list("id", flat=True)), + "plants": PlantSerializer(farm.plants.all(), many=True).data, + "created_at": farm.created_at, + "updated_at": farm.updated_at, + } + + +def resolve_center_location_from_boundary(farm_boundary: dict | list) -> SoilLocation: + """ + مرز مزرعه را می‌گیرد، مرکز را محاسبه می‌کند و رکورد SoilLocation را + ایجاد/به‌روزرسانی می‌کند. + """ + points = _extract_boundary_points(farm_boundary) + if not points: + raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.") + + normalized_points = _normalize_points(points) + if len(normalized_points) < 3: + raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.") + + lat_sum = sum(lat for lat, _ in normalized_points) + lon_sum = sum(lon for _, lon in normalized_points) + count = Decimal(len(normalized_points)) + center_lat = (lat_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP) + center_lon = (lon_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP) + + with transaction.atomic(): + location, _ = SoilLocation.objects.update_or_create( + latitude=center_lat, + longitude=center_lon, + defaults={"farm_boundary": _serialize_boundary(farm_boundary)}, + ) + return location + + +def resolve_weather_for_location(location: SoilLocation) -> WeatherForecast | None: + return ( + WeatherForecast.objects.filter(location=location) + .order_by("-forecast_date", "-id") + .first() + ) + + +def _flatten_sensor_metrics(sensor_payload: dict | None) -> dict: + if not isinstance(sensor_payload, dict): + return {} + + flattened = {} + for sensor_values in sensor_payload.values(): + if not isinstance(sensor_values, dict): + continue + flattened.update(sensor_values) + return flattened + + +def _surface_soil_metrics(depths) -> dict: + if not depths: + return {} + + primary_depth = depths[0] + fields = [ + "bdod", + "cec", + "cfvo", + "clay", + "nitrogen", + "ocd", + "ocs", + "phh2o", + "sand", + "silt", + "soc", + "wv0010", + "wv0033", + "wv1500", + ] + return { + field: getattr(primary_depth, field) + for field in fields + if getattr(primary_depth, field) is not None + } + + +def _extract_boundary_points(boundary: dict | list) -> list: + if isinstance(boundary, dict): + if boundary.get("type") == "Polygon": + coordinates = boundary.get("coordinates") or [] + if coordinates and isinstance(coordinates[0], list): + return coordinates[0] + return [] + if "corners" in boundary: + return boundary.get("corners") or [] + if isinstance(boundary, list): + return boundary + return [] + + +def _normalize_points(points: list) -> list[tuple[Decimal, Decimal]]: + normalized: list[tuple[Decimal, Decimal]] = [] + for point in points: + lat = lon = None + if isinstance(point, dict): + lat = point.get("lat", point.get("latitude")) + lon = point.get("lon", point.get("longitude")) + elif isinstance(point, (list, tuple)) and len(point) >= 2: + lon, lat = point[0], point[1] + + if lat is None or lon is None: + continue + + lat_decimal = Decimal(str(lat)) + lon_decimal = Decimal(str(lon)) + normalized.append((lat_decimal, lon_decimal)) + + if len(normalized) > 1 and normalized[0] == normalized[-1]: + normalized = normalized[:-1] + return normalized + + +def _serialize_boundary(boundary: dict | list) -> dict: + if isinstance(boundary, dict) and boundary.get("type") == "Polygon": + return boundary + raw_points = boundary.get("corners") if isinstance(boundary, dict) else boundary + normalized = _normalize_points(raw_points or []) + coordinates = [[float(lon), float(lat)] for lat, lon in normalized] + if coordinates and coordinates[0] != coordinates[-1]: + coordinates.append(coordinates[0]) + return { + "type": "Polygon", + "coordinates": [coordinates], + } diff --git a/farm_data/tests/__init__.py b/farm_data/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/farm_data/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/farm_data/tests/test_farm_detail_api.py b/farm_data/tests/test_farm_detail_api.py new file mode 100644 index 0000000..67b3322 --- /dev/null +++ b/farm_data/tests/test_farm_detail_api.py @@ -0,0 +1,195 @@ +from datetime import date +import uuid + +from django.test import TestCase +from rest_framework.test import APIClient + +from location_data.models import SoilDepthData, SoilLocation +from farm_data.models import SensorData +from plant.models import Plant +from weather.models import WeatherForecast + + +def square_boundary_for_center(lat: float, lon: float, delta: float = 0.01) -> dict: + return { + "type": "Polygon", + "coordinates": [ + [ + [lon - delta, lat - delta], + [lon + delta, lat - delta], + [lon + delta, lat + delta], + [lon - delta, lat + delta], + [lon - delta, lat - delta], + ] + ], + } + + +class FarmDetailApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.location = SoilLocation.objects.create( + latitude="35.700000", + longitude="51.400000", + farm_boundary={"type": "Polygon", "coordinates": []}, + ) + SoilDepthData.objects.create( + soil_location=self.location, + depth_label="0-5cm", + clay=22.0, + nitrogen=10.0, + sand=40.0, + ) + SoilDepthData.objects.create( + soil_location=self.location, + depth_label="5-15cm", + clay=18.0, + nitrogen=8.0, + ) + self.weather = WeatherForecast.objects.create( + location=self.location, + forecast_date=date(2026, 4, 10), + temperature_min=12.0, + temperature_max=23.0, + temperature_mean=18.0, + precipitation=1.2, + humidity_mean=52.0, + ) + self.plant1 = Plant.objects.create(name="گوجه‌فرنگی") + self.plant2 = Plant.objects.create(name="خیار") + self.farm_uuid = uuid.uuid4() + self.farm = SensorData.objects.create( + farm_uuid=self.farm_uuid, + center_location=self.location, + weather_forecast=self.weather, + sensor_payload={ + "sensor-7-1": { + "soil_moisture": 33.5, + "nitrogen": 99.0, + } + }, + ) + self.farm.plants.set([self.plant2, self.plant1]) + + def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self): + response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + + self.assertEqual(payload["farm_uuid"], str(self.farm_uuid)) + self.assertEqual(payload["center_location"]["id"], self.location.id) + self.assertEqual(payload["weather"]["id"], self.weather.id) + self.assertEqual( + payload["sensor_payload"]["sensor-7-1"]["soil_moisture"], + 33.5, + ) + + resolved_metrics = payload["soil"]["resolved_metrics"] + metric_sources = payload["soil"]["metric_sources"] + + self.assertEqual(resolved_metrics["nitrogen"], 99.0) + self.assertEqual(metric_sources["nitrogen"], "sensor") + self.assertEqual(resolved_metrics["clay"], 22.0) + self.assertEqual(metric_sources["clay"], "soil") + self.assertEqual(len(payload["soil"]["depths"]), 2) + self.assertCountEqual(payload["plant_ids"], [self.plant1.id, self.plant2.id]) + self.assertEqual(len(payload["plants"]), 2) + returned_plants = {item["id"]: item for item in payload["plants"]} + self.assertEqual(returned_plants[self.plant1.id]["name"], self.plant1.name) + self.assertEqual(returned_plants[self.plant2.id]["name"], self.plant2.name) + self.assertIn("light", returned_plants[self.plant1.id]) + + def test_returns_404_when_farm_is_missing(self): + response = self.client.get(f"/api/farm-data/{uuid.uuid4()}/detail/") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "farm یافت نشد.") + + +class FarmDataUpsertApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.location = SoilLocation.objects.create( + latitude="35.710000", + longitude="51.410000", + ) + self.boundary = square_boundary_for_center(35.71, 51.41) + self.weather = WeatherForecast.objects.create( + location=self.location, + forecast_date=date(2026, 4, 11), + temperature_min=11.0, + temperature_max=24.0, + temperature_mean=17.5, + ) + + def test_post_creates_farm_data_with_explicit_farm_uuid(self): + farm_uuid = uuid.uuid4() + + response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": self.boundary, + "sensor_payload": { + "sensor-7-1": { + "soil_moisture": 31.2, + "nitrogen": 18.0, + } + }, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["data"]["farm_uuid"], str(farm_uuid)) + self.assertEqual(response.json()["data"]["center_location_id"], self.location.id) + self.assertEqual(response.json()["data"]["weather_forecast_id"], self.weather.id) + + farm = SensorData.objects.get(farm_uuid=farm_uuid) + self.assertEqual(farm.center_location_id, self.location.id) + self.assertEqual(farm.weather_forecast_id, self.weather.id) + self.assertEqual( + farm.sensor_payload["sensor-7-1"]["soil_moisture"], + 31.2, + ) + + def test_post_requires_farm_uuid_in_request_body(self): + response = self.client.post( + "/api/farm-data/", + data={ + "farm_boundary": self.boundary, + "sensor_payload": {"sensor-7-1": {"soil_moisture": 31.2}}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("farm_uuid", response.json()["data"]) + + def test_post_creates_center_location_from_boundary_when_missing(self): + farm_uuid = uuid.uuid4() + + response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": { + "corners": [ + {"lat": 50.0, "lon": 50.0}, + {"lat": 50.0, "lon": 50.02}, + {"lat": 50.02, "lon": 50.02}, + {"lat": 50.02, "lon": 50.0}, + ] + }, + "sensor_payload": {"sensor-7-1": {"soil_moisture": 40.0}}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + farm = SensorData.objects.get(farm_uuid=farm_uuid) + self.assertIsNotNone(farm.center_location_id) + self.assertEqual(str(farm.center_location.latitude), "50.010000") + self.assertEqual(str(farm.center_location.longitude), "50.010000") + self.assertIsNone(farm.weather_forecast_id) diff --git a/farm_data/urls.py b/farm_data/urls.py new file mode 100644 index 0000000..f6292d7 --- /dev/null +++ b/farm_data/urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from .views import FarmDetailView, FarmDataUpsertView, SensorParameterCreateView + +urlpatterns = [ + path( + "/detail/", + FarmDetailView.as_view(), + name="farm-detail", + ), + path( + "", + FarmDataUpsertView.as_view(), + name="farm-data-upsert", + ), + path( + "parameters/", + SensorParameterCreateView.as_view(), + name="farm-parameter-create", + ), +] diff --git a/farm_data/views.py b/farm_data/views.py new file mode 100644 index 0000000..b68fc3a --- /dev/null +++ b/farm_data/views.py @@ -0,0 +1,357 @@ +from copy import deepcopy + +from django.db import transaction +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import serializers as drf_serializers +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import build_envelope_serializer, build_response +from .models import ParameterUpdateLog, SensorData, SensorParameter +from .serializers import ( + FarmDetailSerializer, + SensorDataResponseSerializer, + SensorDataUpdateSerializer, + SensorParameterSerializer, +) +from .services import ( + get_farm_details, + resolve_center_location_from_boundary, + resolve_weather_for_location, +) + + +SensorDataEnvelopeSerializer = build_envelope_serializer( + "SensorDataEnvelopeSerializer", + SensorDataResponseSerializer, +) +SensorDataValidationErrorSerializer = build_envelope_serializer( + "SensorDataValidationErrorSerializer", + data_required=False, + allow_null=True, +) +SensorDataNotFoundSerializer = build_envelope_serializer( + "SensorDataNotFoundSerializer", + data_required=False, + allow_null=True, +) +FarmDetailEnvelopeSerializer = build_envelope_serializer( + "FarmDetailEnvelopeSerializer", + FarmDetailSerializer, +) +SensorParameterResponseSerializer = build_envelope_serializer( + "SensorParameterEnvelopeSerializer", + inline_serializer( + name="SensorParameterPayloadSerializer", + fields={ + "id": drf_serializers.IntegerField(), + "sensor_key": drf_serializers.CharField(), + "code": drf_serializers.CharField(), + "name_fa": drf_serializers.CharField(), + "unit": drf_serializers.CharField(), + "data_type": drf_serializers.CharField(), + "metadata": drf_serializers.JSONField(), + "created_at": drf_serializers.DateTimeField(), + "action": drf_serializers.CharField(), + }, + ), +) + + +class FarmDataUpsertView(APIView): + """ + ایجاد یا آپدیت داده farm. + """ + + @extend_schema( + tags=["Farm Data"], + summary="ایجاد یا آپدیت داده farm", + description=( + "داده farm را با `POST /api/farm-data/` ایجاد یا آپدیت می‌کند. " + "`farm_uuid` باید از API ارسال شود و هرگز خودکار ساخته نمی‌شود. " + "مرز مزرعه را می‌گیرد، مرکز زمین را خودش محاسبه و در location_data ذخیره می‌کند. " + "رکورد آب‌وهوا هم از همان مرکز زمین به‌صورت خودکار پیدا می‌شود. " + 'خوانش‌ها داخل `sensor_payload` مثل `{"sensor-7-1": {...}}` نگه‌داری می‌شوند.' + ), + request=SensorDataUpdateSerializer, + responses={ + 200: build_response( + SensorDataEnvelopeSerializer, + "داده farm با موفقیت به‌روزرسانی شد.", + ), + 201: build_response( + SensorDataEnvelopeSerializer, + "داده farm با موفقیت ایجاد شد.", + ), + 400: build_response( + SensorDataValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_boundary": { + "type": "Polygon", + "coordinates": [ + [ + [51.3900, 35.7000], + [51.4100, 35.7000], + [51.4100, 35.7200], + [51.3900, 35.7200], + [51.3900, 35.7000], + ] + ], + }, + "sensor_payload": { + "sensor-7-1": { + "soil_moisture": 45.2, + "soil_temperature": 22.5, + "soil_ph": 6.8, + "electrical_conductivity": 1.2, + "nitrogen": 30.0, + "phosphorus": 15.0, + "potassium": 20.0, + } + }, + }, + request_only=True, + ), + OpenApiExample( + "نمونه چند سنسور", + value={ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_boundary": { + "corners": [ + {"lat": 35.7000, "lon": 51.3900}, + {"lat": 35.7000, "lon": 51.4100}, + {"lat": 35.7200, "lon": 51.4100}, + {"lat": 35.7200, "lon": 51.3900}, + ] + }, + "sensor_payload": { + "sensor-7-1": { + "soil_moisture": 45.2, + "soil_temperature": 22.5, + }, + "leaf-sensor": { + "leaf_wetness": 11.0, + "leaf_temperature": 19.3, + }, + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = SensorDataUpdateSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + farm_uuid = serializer.validated_data["farm_uuid"] + farm_boundary = serializer.validated_data["farm_boundary"] + plant_ids = serializer.validated_data.get("plant_ids") + sensor_payload = serializer.validated_data.get("sensor_payload", {}) + try: + center_location = resolve_center_location_from_boundary(farm_boundary) + except ValueError as exc: + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": {"farm_boundary": [str(exc)]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + weather_forecast = resolve_weather_for_location(center_location) + + with transaction.atomic(): + farm_data, created = SensorData.objects.get_or_create( + farm_uuid=farm_uuid, + defaults={ + "center_location": center_location, + "weather_forecast": weather_forecast, + "sensor_payload": sensor_payload, + }, + ) + + if not created and sensor_payload: + merged_payload = deepcopy(farm_data.sensor_payload or {}) + for sensor_key, sensor_values in sensor_payload.items(): + current_values = merged_payload.get(sensor_key, {}) + if not isinstance(current_values, dict): + current_values = {} + current_values.update(sensor_values) + merged_payload[sensor_key] = current_values + farm_data.sensor_payload = merged_payload + elif created: + farm_data.sensor_payload = sensor_payload + + farm_data.center_location = center_location + farm_data.weather_forecast = weather_forecast + if not created: + farm_data.save( + update_fields=[ + "center_location", + "weather_forecast", + "sensor_payload", + "updated_at", + ] + ) + else: + farm_data.save() + + if plant_ids is not None: + farm_data.plants.set(plant_ids) + + response_status = ( + status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + return Response( + { + "code": 201 if created else 200, + "msg": "success", + "data": SensorDataResponseSerializer(farm_data).data, + }, + status=response_status, + ) + + +class FarmDetailView(APIView): + @extend_schema( + tags=["Farm Data"], + summary="دریافت همه اطلاعات farm", + description=( + "اطلاعات تجمیعی farm را برمی‌گرداند. " + "برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند." + ), + responses={ + 200: build_response( + FarmDetailEnvelopeSerializer, + "اطلاعات farm با موفقیت بازگردانده شد.", + ), + 404: build_response( + SensorDataNotFoundSerializer, + "farm موردنظر یافت نشد.", + ), + }, + ) + def get(self, request, farm_uuid): + data = get_farm_details(str(farm_uuid)) + if data is None: + return Response( + {"code": 404, "msg": "farm یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + ) + + +class SensorParameterCreateView(APIView): + """ + اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog. + """ + + @extend_schema( + tags=["Farm Parameters"], + summary="افزودن/ویرایش پارامتر سنسور", + description="پارامتر جدید اضافه یا پارامتر موجود را ویرایش می‌کند و در لاگ ثبت می‌شود.", + request=SensorParameterSerializer, + responses={ + 201: build_response( + SensorParameterResponseSerializer, + "پارامتر سنسور با موفقیت ایجاد یا ویرایش شد.", + ), + 400: build_response( + SensorDataValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "sensor_key": "sensor-7-1", + "code": "soil_moisture", + "name_fa": "رطوبت خاک", + "unit": "%", + "data_type": "float", + "metadata": {"min": 0, "max": 100}, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = SensorParameterSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + sensor_key = serializer.validated_data.get("sensor_key") + code = serializer.validated_data["code"] + name_fa = serializer.validated_data["name_fa"] + unit = serializer.validated_data.get("unit", "") + data_type = serializer.validated_data.get("data_type", "") + metadata = serializer.validated_data.get("metadata", {}) + + with transaction.atomic(): + parameter, created = SensorParameter.objects.update_or_create( + sensor_key=sensor_key, + code=code, + defaults={ + "name_fa": name_fa, + "unit": unit, + "data_type": data_type, + "metadata": metadata, + }, + ) + action = ( + ParameterUpdateLog.ACTION_ADDED + if created + else ParameterUpdateLog.ACTION_MODIFIED + ) + ParameterUpdateLog.objects.create( + parameter=parameter, + action=action, + payload={ + "sensor_key": parameter.sensor_key, + "code": parameter.code, + "name_fa": parameter.name_fa, + "unit": parameter.unit, + "data_type": parameter.data_type, + "metadata": parameter.metadata, + }, + ) + + return Response( + { + "code": 201, + "msg": "success", + "data": { + "id": parameter.id, + "sensor_key": parameter.sensor_key, + "code": parameter.code, + "name_fa": parameter.name_fa, + "unit": parameter.unit, + "data_type": parameter.data_type, + "metadata": parameter.metadata, + "created_at": parameter.created_at, + "action": action, + }, + }, + status=status.HTTP_201_CREATED, + ) diff --git a/json/mock_data/dashboard-data/generate/post_202.json b/json/mock_data/dashboard-data/generate/post_202.json deleted file mode 100644 index 34b0f71..0000000 --- a/json/mock_data/dashboard-data/generate/post_202.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "code": 202, - "msg": "dashboard task queued", - "data": { - "task_id": "dashboard-task-123", - "status_url": "/api/dashboard-data/dashboard-task-123/status/" - } -} diff --git a/json/mock_data/dashboard-data/generate/post_400.json b/json/mock_data/dashboard-data/generate/post_400.json deleted file mode 100644 index 5df03b8..0000000 --- a/json/mock_data/dashboard-data/generate/post_400.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 400, - "msg": "پارامتر sensor_id الزامی است.", - "data": null -} diff --git a/json/mock_data/dashboard-data/status/get_200_failure.json b/json/mock_data/dashboard-data/status/get_200_failure.json deleted file mode 100644 index bc4f2eb..0000000 --- a/json/mock_data/dashboard-data/status/get_200_failure.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "dashboard-task-123", - "status": "FAILURE", - "error": "خطا در ساخت کارت‌های داشبورد." - } -} diff --git a/json/mock_data/dashboard-data/status/get_200_pending.json b/json/mock_data/dashboard-data/status/get_200_pending.json deleted file mode 100644 index 74dafc6..0000000 --- a/json/mock_data/dashboard-data/status/get_200_pending.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "dashboard-task-123", - "status": "PENDING", - "message": "تسک در صف یا یافت نشد." - } -} diff --git a/json/mock_data/dashboard-data/status/get_200_progress.json b/json/mock_data/dashboard-data/status/get_200_progress.json deleted file mode 100644 index e3515e5..0000000 --- a/json/mock_data/dashboard-data/status/get_200_progress.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "dashboard-task-123", - "status": "PROGRESS", - "progress": { - "current": 5, - "total": 15, - "card": "sensorValuesList", - "message": "processing sensorValuesList" - } - } -} diff --git a/json/mock_data/dashboard-data/status/get_200_success.json b/json/mock_data/dashboard-data/status/get_200_success.json deleted file mode 100644 index 4de043c..0000000 --- a/json/mock_data/dashboard-data/status/get_200_success.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "dashboard-task-123", - "status": "SUCCESS", - "result": { - "sensor_id": "550e8400-e29b-41d4-a716-446655440000", - "all_cards": { - "farmOverviewKpis": { - "healthScore": 82, - "activeAlerts": 2, - "waterNeedMm": 18.4 - }, - "sensorValuesList": { - "items": [ - { - "label": "رطوبت خاک", - "value": 45.2, - "unit": "%" - }, - { - "label": "دما خاک", - "value": 22.5, - "unit": "°C" - } - ] - }, - "recommendationsList": { - "items": [ - { - "recommendation_title": "تنظیم نوبت آبیاری", - "suggested_action": "آبیاری بعدی را صبح فردا انجام دهید.", - "urgency_level": "high" - } - ] - } - } - } - } -} diff --git a/json/mock_data/fertilization/recommend/post_202.json b/json/mock_data/fertilization/recommend/post_202.json deleted file mode 100644 index e8dc23f..0000000 --- a/json/mock_data/fertilization/recommend/post_202.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "code": 202, - "msg": "تسک توصیه کودهی در صف قرار گرفت.", - "data": { - "task_id": "fert-task-123", - "status_url": "/api/fertilization/recommend/fert-task-123/status/" - } -} diff --git a/json/mock_data/fertilization/recommend/post_400.json b/json/mock_data/fertilization/recommend/post_400.json deleted file mode 100644 index 9fdc597..0000000 --- a/json/mock_data/fertilization/recommend/post_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "sensor_uuid": [ - "This field is required." - ] - } -} diff --git a/json/mock_data/fertilization/status/get_200_failure.json b/json/mock_data/fertilization/status/get_200_failure.json deleted file mode 100644 index fb8bad0..0000000 --- a/json/mock_data/fertilization/status/get_200_failure.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "fert-task-123", - "status": "FAILURE", - "error": "خطا در دریافت توصیه کودهی." - } -} diff --git a/json/mock_data/fertilization/status/get_200_pending.json b/json/mock_data/fertilization/status/get_200_pending.json deleted file mode 100644 index 110be1a..0000000 --- a/json/mock_data/fertilization/status/get_200_pending.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "fert-task-123", - "status": "PENDING", - "message": "تسک در صف یا یافت نشد." - } -} diff --git a/json/mock_data/fertilization/status/get_200_progress.json b/json/mock_data/fertilization/status/get_200_progress.json deleted file mode 100644 index ffdf909..0000000 --- a/json/mock_data/fertilization/status/get_200_progress.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "fert-task-123", - "status": "PROGRESS", - "progress": { - "message": "در حال پردازش توصیه کودهی..." - } - } -} diff --git a/json/mock_data/fertilization/status/get_200_success.json b/json/mock_data/fertilization/status/get_200_success.json deleted file mode 100644 index 7b46ba1..0000000 --- a/json/mock_data/fertilization/status/get_200_success.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "fert-task-123", - "status": "SUCCESS", - "result": { - "plan": { - "npkRatio": "20-20-20", - "amountPerHectare": "150 kg/ha", - "applicationMethod": "کودآبیاری در دو نوبت", - "applicationInterval": "هر ۱۰ روز", - "reasoning": "نیتروژن و پتاسیم خاک در محدوده متوسط است و گیاه در فاز رویشی نیاز تغذیه‌ای بالاتری دارد." - }, - "raw_response": "{\"plan\":{\"npkRatio\":\"20-20-20\"}}", - "status": "completed" - } - } -} diff --git a/json/mock_data/index.json b/json/mock_data/index.json deleted file mode 100644 index d051fb6..0000000 --- a/json/mock_data/index.json +++ /dev/null @@ -1,604 +0,0 @@ -[ - { - "method": "POST", - "path": "/api/dashboard-data/generate/", - "status_code": 202, - "description": "Dashboard data task queued", - "file": "json/mock_data/dashboard-data/generate/post_202.json" - }, - { - "method": "POST", - "path": "/api/dashboard-data/generate/", - "status_code": 400, - "description": "Missing sensor_id", - "file": "json/mock_data/dashboard-data/generate/post_400.json" - }, - { - "method": "GET", - "path": "/api/dashboard-data/{task_id}/status/", - "status_code": 200, - "description": "Pending dashboard task", - "file": "json/mock_data/dashboard-data/status/get_200_pending.json" - }, - { - "method": "GET", - "path": "/api/dashboard-data/{task_id}/status/", - "status_code": 200, - "description": "Dashboard task in progress", - "file": "json/mock_data/dashboard-data/status/get_200_progress.json" - }, - { - "method": "GET", - "path": "/api/dashboard-data/{task_id}/status/", - "status_code": 200, - "description": "Successful dashboard task", - "file": "json/mock_data/dashboard-data/status/get_200_success.json" - }, - { - "method": "GET", - "path": "/api/dashboard-data/{task_id}/status/", - "status_code": 200, - "description": "Failed dashboard task", - "file": "json/mock_data/dashboard-data/status/get_200_failure.json" - }, - { - "method": "POST", - "path": "/api/fertilization/recommend/", - "status_code": 202, - "description": "Fertilization task queued", - "file": "json/mock_data/fertilization/recommend/post_202.json" - }, - { - "method": "POST", - "path": "/api/fertilization/recommend/", - "status_code": 400, - "description": "Validation error", - "file": "json/mock_data/fertilization/recommend/post_400.json" - }, - { - "method": "GET", - "path": "/api/fertilization/recommend/{task_id}/status/", - "status_code": 200, - "description": "Fertilization status pending", - "file": "json/mock_data/fertilization/status/get_200_pending.json" - }, - { - "method": "GET", - "path": "/api/fertilization/recommend/{task_id}/status/", - "status_code": 200, - "description": "Fertilization status progress", - "file": "json/mock_data/fertilization/status/get_200_progress.json" - }, - { - "method": "GET", - "path": "/api/fertilization/recommend/{task_id}/status/", - "status_code": 200, - "description": "Fertilization status success", - "file": "json/mock_data/fertilization/status/get_200_success.json" - }, - { - "method": "GET", - "path": "/api/fertilization/recommend/{task_id}/status/", - "status_code": 200, - "description": "Fertilization status failure", - "file": "json/mock_data/fertilization/status/get_200_failure.json" - }, - { - "method": "GET", - "path": "/api/irrigation/", - "status_code": 200, - "description": "List irrigation methods", - "file": "json/mock_data/irrigation/methods/get_200.json" - }, - { - "method": "POST", - "path": "/api/irrigation/", - "status_code": 201, - "description": "Create irrigation method", - "file": "json/mock_data/irrigation/methods/post_201.json" - }, - { - "method": "POST", - "path": "/api/irrigation/", - "status_code": 400, - "description": "Irrigation create validation error", - "file": "json/mock_data/irrigation/methods/post_400.json" - }, - { - "method": "POST", - "path": "/api/irrigation/recommend/", - "status_code": 202, - "description": "Irrigation recommendation task queued", - "file": "json/mock_data/irrigation/recommend/post_202.json" - }, - { - "method": "POST", - "path": "/api/irrigation/recommend/", - "status_code": 400, - "description": "Irrigation recommendation validation error", - "file": "json/mock_data/irrigation/recommend/post_400.json" - }, - { - "method": "GET", - "path": "/api/irrigation/recommend/{task_id}/status/", - "status_code": 200, - "description": "Irrigation recommendation status pending", - "file": "json/mock_data/irrigation/recommend/status/get_200_pending.json" - }, - { - "method": "GET", - "path": "/api/irrigation/recommend/{task_id}/status/", - "status_code": 200, - "description": "Irrigation recommendation status progress", - "file": "json/mock_data/irrigation/recommend/status/get_200_progress.json" - }, - { - "method": "GET", - "path": "/api/irrigation/recommend/{task_id}/status/", - "status_code": 200, - "description": "Irrigation recommendation status success", - "file": "json/mock_data/irrigation/recommend/status/get_200_success.json" - }, - { - "method": "GET", - "path": "/api/irrigation/recommend/{task_id}/status/", - "status_code": 200, - "description": "Irrigation recommendation status failure", - "file": "json/mock_data/irrigation/recommend/status/get_200_failure.json" - }, - { - "method": "GET", - "path": "/api/irrigation/{pk}/", - "status_code": 200, - "description": "Irrigation method get success", - "file": "json/mock_data/irrigation/method-detail/get_200.json" - }, - { - "method": "GET", - "path": "/api/irrigation/{pk}/", - "status_code": 404, - "description": "Irrigation method get not found", - "file": "json/mock_data/irrigation/method-detail/get_404.json" - }, - { - "method": "PUT", - "path": "/api/irrigation/{pk}/", - "status_code": 200, - "description": "Irrigation method put success", - "file": "json/mock_data/irrigation/method-detail/put_200.json" - }, - { - "method": "PUT", - "path": "/api/irrigation/{pk}/", - "status_code": 400, - "description": "Irrigation method put validation error", - "file": "json/mock_data/irrigation/method-detail/put_400.json" - }, - { - "method": "PUT", - "path": "/api/irrigation/{pk}/", - "status_code": 404, - "description": "Irrigation method put not found", - "file": "json/mock_data/irrigation/method-detail/put_404.json" - }, - { - "method": "PATCH", - "path": "/api/irrigation/{pk}/", - "status_code": 200, - "description": "Irrigation method patch success", - "file": "json/mock_data/irrigation/method-detail/patch_200.json" - }, - { - "method": "PATCH", - "path": "/api/irrigation/{pk}/", - "status_code": 400, - "description": "Irrigation method patch validation error", - "file": "json/mock_data/irrigation/method-detail/patch_400.json" - }, - { - "method": "PATCH", - "path": "/api/irrigation/{pk}/", - "status_code": 404, - "description": "Irrigation method patch not found", - "file": "json/mock_data/irrigation/method-detail/patch_404.json" - }, - { - "method": "DELETE", - "path": "/api/irrigation/{pk}/", - "status_code": 200, - "description": "Delete irrigation method", - "file": "json/mock_data/irrigation/method-detail/delete_200.json" - }, - { - "method": "DELETE", - "path": "/api/irrigation/{pk}/", - "status_code": 404, - "description": "Delete irrigation method not found", - "file": "json/mock_data/irrigation/method-detail/delete_404.json" - }, - { - "method": "GET", - "path": "/api/soil-data/", - "status_code": 200, - "description": "Soil data served from database", - "file": "json/mock_data/soil-data/get_200_database.json" - }, - { - "method": "GET", - "path": "/api/soil-data/", - "status_code": 202, - "description": "Soil data fetch task queued", - "file": "json/mock_data/soil-data/get_202_queued.json" - }, - { - "method": "GET", - "path": "/api/soil-data/", - "status_code": 400, - "description": "Soil data validation error", - "file": "json/mock_data/soil-data/get_400.json" - }, - { - "method": "POST", - "path": "/api/soil-data/", - "status_code": 200, - "description": "Soil data POST served from database", - "file": "json/mock_data/soil-data/post_200_database.json" - }, - { - "method": "POST", - "path": "/api/soil-data/", - "status_code": 202, - "description": "Soil data POST task queued", - "file": "json/mock_data/soil-data/post_202_queued.json" - }, - { - "method": "POST", - "path": "/api/soil-data/", - "status_code": 400, - "description": "Soil data POST validation error", - "file": "json/mock_data/soil-data/post_400.json" - }, - { - "method": "GET", - "path": "/api/soil-data/tasks/{task_id}/status/", - "status_code": 200, - "description": "Soil task status pending", - "file": "json/mock_data/soil-data/status/get_200_pending.json" - }, - { - "method": "GET", - "path": "/api/soil-data/tasks/{task_id}/status/", - "status_code": 200, - "description": "Soil task status progress", - "file": "json/mock_data/soil-data/status/get_200_progress.json" - }, - { - "method": "GET", - "path": "/api/soil-data/tasks/{task_id}/status/", - "status_code": 200, - "description": "Soil task status success", - "file": "json/mock_data/soil-data/status/get_200_success.json" - }, - { - "method": "GET", - "path": "/api/soil-data/tasks/{task_id}/status/", - "status_code": 200, - "description": "Soil task status failure", - "file": "json/mock_data/soil-data/status/get_200_failure.json" - }, - { - "method": "GET", - "path": "/api/plants/", - "status_code": 200, - "description": "List plants", - "file": "json/mock_data/plant/list-get_200.json" - }, - { - "method": "POST", - "path": "/api/plants/", - "status_code": 201, - "description": "Create plant", - "file": "json/mock_data/plant/create-post_201.json" - }, - { - "method": "POST", - "path": "/api/plants/", - "status_code": 400, - "description": "Plant create validation error", - "file": "json/mock_data/plant/create-post_400.json" - }, - { - "method": "GET", - "path": "/api/plants/{pk}/", - "status_code": 200, - "description": "Plant detail get success", - "file": "json/mock_data/plant/detail-get_200.json" - }, - { - "method": "GET", - "path": "/api/plants/{pk}/", - "status_code": 404, - "description": "Plant detail get not found", - "file": "json/mock_data/plant/detail-get_404.json" - }, - { - "method": "PUT", - "path": "/api/plants/{pk}/", - "status_code": 200, - "description": "Plant detail put success", - "file": "json/mock_data/plant/detail-put_200.json" - }, - { - "method": "PUT", - "path": "/api/plants/{pk}/", - "status_code": 400, - "description": "Plant detail put validation error", - "file": "json/mock_data/plant/detail-put_400.json" - }, - { - "method": "PUT", - "path": "/api/plants/{pk}/", - "status_code": 404, - "description": "Plant detail put not found", - "file": "json/mock_data/plant/detail-put_404.json" - }, - { - "method": "PATCH", - "path": "/api/plants/{pk}/", - "status_code": 200, - "description": "Plant detail patch success", - "file": "json/mock_data/plant/detail-patch_200.json" - }, - { - "method": "PATCH", - "path": "/api/plants/{pk}/", - "status_code": 400, - "description": "Plant detail patch validation error", - "file": "json/mock_data/plant/detail-patch_400.json" - }, - { - "method": "PATCH", - "path": "/api/plants/{pk}/", - "status_code": 404, - "description": "Plant detail patch not found", - "file": "json/mock_data/plant/detail-patch_404.json" - }, - { - "method": "DELETE", - "path": "/api/plants/{pk}/", - "status_code": 200, - "description": "Delete plant success", - "file": "json/mock_data/plant/detail-delete_200.json" - }, - { - "method": "DELETE", - "path": "/api/plants/{pk}/", - "status_code": 404, - "description": "Delete plant not found", - "file": "json/mock_data/plant/detail-delete_404.json" - }, - { - "method": "POST", - "path": "/api/plants/fetch-info/", - "status_code": 200, - "description": "Fetch plant info success", - "file": "json/mock_data/plant/fetch-info-post_200.json" - }, - { - "method": "POST", - "path": "/api/plants/fetch-info/", - "status_code": 400, - "description": "Fetch plant info missing name", - "file": "json/mock_data/plant/fetch-info-post_400.json" - }, - { - "method": "POST", - "path": "/api/plants/fetch-info/", - "status_code": 503, - "description": "Fetch plant info service unavailable", - "file": "json/mock_data/plant/fetch-info-post_503.json" - }, - { - "method": "POST", - "path": "/api/rag/chat/", - "status_code": 200, - "description": "RAG chat streaming response", - "file": "json/mock_data/rag/chat-post_200_stream.json" - }, - { - "method": "POST", - "path": "/api/rag/chat/", - "status_code": 400, - "description": "Missing query", - "file": "json/mock_data/rag/chat-post_400_missing_query.json" - }, - { - "method": "POST", - "path": "/api/rag/chat/", - "status_code": 400, - "description": "Invalid service id", - "file": "json/mock_data/rag/chat-post_400_invalid_service.json" - }, - { - "method": "POST", - "path": "/api/rag/chat/", - "status_code": 400, - "description": "Missing user_id for service", - "file": "json/mock_data/rag/chat-post_400_missing_user.json" - }, - { - "method": "POST", - "path": "/api/rag/recommend/irrigation/", - "status_code": 202, - "description": "RAG irrigation task queued", - "file": "json/mock_data/rag/irrigation/post_202.json" - }, - { - "method": "POST", - "path": "/api/rag/recommend/irrigation/", - "status_code": 400, - "description": "RAG irrigation validation error", - "file": "json/mock_data/rag/irrigation/post_400.json" - }, - { - "method": "GET", - "path": "/api/rag/recommend/irrigation/{task_id}/status/", - "status_code": 200, - "description": "RAG irrigation status pending", - "file": "json/mock_data/rag/irrigation/status/get_200_pending.json" - }, - { - "method": "GET", - "path": "/api/rag/recommend/irrigation/{task_id}/status/", - "status_code": 200, - "description": "RAG irrigation status progress", - "file": "json/mock_data/rag/irrigation/status/get_200_progress.json" - }, - { - "method": "GET", - "path": "/api/rag/recommend/irrigation/{task_id}/status/", - "status_code": 200, - "description": "RAG irrigation status success", - "file": "json/mock_data/rag/irrigation/status/get_200_success.json" - }, - { - "method": "GET", - "path": "/api/rag/recommend/irrigation/{task_id}/status/", - "status_code": 200, - "description": "RAG irrigation status failure", - "file": "json/mock_data/rag/irrigation/status/get_200_failure.json" - }, - { - "method": "POST", - "path": "/api/rag/recommend/fertilization/", - "status_code": 202, - "description": "RAG fertilization task queued", - "file": "json/mock_data/rag/fertilization/post_202.json" - }, - { - "method": "POST", - "path": "/api/rag/recommend/fertilization/", - "status_code": 400, - "description": "RAG fertilization validation error", - "file": "json/mock_data/rag/fertilization/post_400.json" - }, - { - "method": "GET", - "path": "/api/rag/recommend/fertilization/{task_id}/status/", - "status_code": 200, - "description": "RAG fertilization status pending", - "file": "json/mock_data/rag/fertilization/status/get_200_pending.json" - }, - { - "method": "GET", - "path": "/api/rag/recommend/fertilization/{task_id}/status/", - "status_code": 200, - "description": "RAG fertilization status progress", - "file": "json/mock_data/rag/fertilization/status/get_200_progress.json" - }, - { - "method": "GET", - "path": "/api/rag/recommend/fertilization/{task_id}/status/", - "status_code": 200, - "description": "RAG fertilization status success", - "file": "json/mock_data/rag/fertilization/status/get_200_success.json" - }, - { - "method": "GET", - "path": "/api/rag/recommend/fertilization/{task_id}/status/", - "status_code": 200, - "description": "RAG fertilization status failure", - "file": "json/mock_data/rag/fertilization/status/get_200_failure.json" - }, - { - "method": "PUT", - "path": "/api/sensor-data/{uuid_sensor}/", - "status_code": 200, - "description": "Sensor update put success", - "file": "json/mock_data/sensor-data/update-put_200.json" - }, - { - "method": "PUT", - "path": "/api/sensor-data/{uuid_sensor}/", - "status_code": 400, - "description": "Sensor update put validation error", - "file": "json/mock_data/sensor-data/update-put_400.json" - }, - { - "method": "PUT", - "path": "/api/sensor-data/{uuid_sensor}/", - "status_code": 404, - "description": "Sensor update put location not found", - "file": "json/mock_data/sensor-data/update-put_404.json" - }, - { - "method": "PATCH", - "path": "/api/sensor-data/{uuid_sensor}/", - "status_code": 200, - "description": "Sensor update patch success", - "file": "json/mock_data/sensor-data/update-patch_200.json" - }, - { - "method": "PATCH", - "path": "/api/sensor-data/{uuid_sensor}/", - "status_code": 400, - "description": "Sensor update patch validation error", - "file": "json/mock_data/sensor-data/update-patch_400.json" - }, - { - "method": "PATCH", - "path": "/api/sensor-data/{uuid_sensor}/", - "status_code": 404, - "description": "Sensor update patch location not found", - "file": "json/mock_data/sensor-data/update-patch_404.json" - }, - { - "method": "POST", - "path": "/api/sensor-data/parameters/", - "status_code": 201, - "description": "Create sensor parameter", - "file": "json/mock_data/sensor-data/parameters-post_201.json" - }, - { - "method": "POST", - "path": "/api/sensor-data/parameters/", - "status_code": 400, - "description": "Sensor parameter validation error", - "file": "json/mock_data/sensor-data/parameters-post_400.json" - }, - { - "method": "POST", - "path": "/api/tasks/", - "status_code": 200, - "description": "Task trigger success", - "file": "json/mock_data/tasks/post_200.json" - }, - { - "method": "GET", - "path": "/api/tasks/{task_id}/status/", - "status_code": 200, - "description": "Task status pending", - "file": "json/mock_data/tasks/status/get_200_pending.json" - }, - { - "method": "GET", - "path": "/api/tasks/{task_id}/status/", - "status_code": 200, - "description": "Task status progress", - "file": "json/mock_data/tasks/status/get_200_progress.json" - }, - { - "method": "GET", - "path": "/api/tasks/{task_id}/status/", - "status_code": 200, - "description": "Task status success", - "file": "json/mock_data/tasks/status/get_200_success.json" - }, - { - "method": "GET", - "path": "/api/tasks/{task_id}/status/", - "status_code": 200, - "description": "Task status failure", - "file": "json/mock_data/tasks/status/get_200_failure.json" - } -] diff --git a/json/mock_data/irrigation/method-detail/delete_200.json b/json/mock_data/irrigation/method-detail/delete_200.json deleted file mode 100644 index ed52092..0000000 --- a/json/mock_data/irrigation/method-detail/delete_200.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 200, - "msg": "روش آبیاری با موفقیت حذف شد.", - "data": null -} diff --git a/json/mock_data/irrigation/method-detail/delete_404.json b/json/mock_data/irrigation/method-detail/delete_404.json deleted file mode 100644 index 54dcfff..0000000 --- a/json/mock_data/irrigation/method-detail/delete_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "روش آبیاری یافت نشد.", - "data": null -} diff --git a/json/mock_data/irrigation/method-detail/get_200.json b/json/mock_data/irrigation/method-detail/get_200.json deleted file mode 100644 index 5988c67..0000000 --- a/json/mock_data/irrigation/method-detail/get_200.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "id": 1, - "name": "آبیاری قطره‌ای", - "category": "موضعی", - "description": "آبیاری با دبی کم و راندمان بالا", - "water_efficiency_percent": 90.0, - "water_pressure_required": "۱-۲ اتمسفر", - "flow_rate": "۲-۸ لیتر در ساعت", - "coverage_area": "بسته به طراحی سیستم", - "soil_type": "اکثر خاک‌ها", - "climate_suitability": "گرم و خشک", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/irrigation/method-detail/get_404.json b/json/mock_data/irrigation/method-detail/get_404.json deleted file mode 100644 index 54dcfff..0000000 --- a/json/mock_data/irrigation/method-detail/get_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "روش آبیاری یافت نشد.", - "data": null -} diff --git a/json/mock_data/irrigation/method-detail/patch_200.json b/json/mock_data/irrigation/method-detail/patch_200.json deleted file mode 100644 index 5988c67..0000000 --- a/json/mock_data/irrigation/method-detail/patch_200.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "id": 1, - "name": "آبیاری قطره‌ای", - "category": "موضعی", - "description": "آبیاری با دبی کم و راندمان بالا", - "water_efficiency_percent": 90.0, - "water_pressure_required": "۱-۲ اتمسفر", - "flow_rate": "۲-۸ لیتر در ساعت", - "coverage_area": "بسته به طراحی سیستم", - "soil_type": "اکثر خاک‌ها", - "climate_suitability": "گرم و خشک", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/irrigation/method-detail/patch_400.json b/json/mock_data/irrigation/method-detail/patch_400.json deleted file mode 100644 index 2e4dd22..0000000 --- a/json/mock_data/irrigation/method-detail/patch_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "name": [ - "This field may not be blank." - ] - } -} diff --git a/json/mock_data/irrigation/method-detail/patch_404.json b/json/mock_data/irrigation/method-detail/patch_404.json deleted file mode 100644 index 54dcfff..0000000 --- a/json/mock_data/irrigation/method-detail/patch_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "روش آبیاری یافت نشد.", - "data": null -} diff --git a/json/mock_data/irrigation/method-detail/put_200.json b/json/mock_data/irrigation/method-detail/put_200.json deleted file mode 100644 index 5988c67..0000000 --- a/json/mock_data/irrigation/method-detail/put_200.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "id": 1, - "name": "آبیاری قطره‌ای", - "category": "موضعی", - "description": "آبیاری با دبی کم و راندمان بالا", - "water_efficiency_percent": 90.0, - "water_pressure_required": "۱-۲ اتمسفر", - "flow_rate": "۲-۸ لیتر در ساعت", - "coverage_area": "بسته به طراحی سیستم", - "soil_type": "اکثر خاک‌ها", - "climate_suitability": "گرم و خشک", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/irrigation/method-detail/put_400.json b/json/mock_data/irrigation/method-detail/put_400.json deleted file mode 100644 index 2e4dd22..0000000 --- a/json/mock_data/irrigation/method-detail/put_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "name": [ - "This field may not be blank." - ] - } -} diff --git a/json/mock_data/irrigation/method-detail/put_404.json b/json/mock_data/irrigation/method-detail/put_404.json deleted file mode 100644 index 54dcfff..0000000 --- a/json/mock_data/irrigation/method-detail/put_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "روش آبیاری یافت نشد.", - "data": null -} diff --git a/json/mock_data/irrigation/methods/get_200.json b/json/mock_data/irrigation/methods/get_200.json deleted file mode 100644 index a2e511e..0000000 --- a/json/mock_data/irrigation/methods/get_200.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": [ - { - "id": 1, - "name": "آبیاری قطره‌ای", - "category": "موضعی", - "description": "آبیاری با دبی کم و راندمان بالا", - "water_efficiency_percent": 90.0, - "water_pressure_required": "۱-۲ اتمسفر", - "flow_rate": "۲-۸ لیتر در ساعت", - "coverage_area": "بسته به طراحی سیستم", - "soil_type": "اکثر خاک‌ها", - "climate_suitability": "گرم و خشک", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } - ] -} diff --git a/json/mock_data/irrigation/methods/post_201.json b/json/mock_data/irrigation/methods/post_201.json deleted file mode 100644 index 2fba4f5..0000000 --- a/json/mock_data/irrigation/methods/post_201.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "code": 201, - "msg": "success", - "data": { - "id": 1, - "name": "آبیاری قطره‌ای", - "category": "موضعی", - "description": "آبیاری با دبی کم و راندمان بالا", - "water_efficiency_percent": 90.0, - "water_pressure_required": "۱-۲ اتمسفر", - "flow_rate": "۲-۸ لیتر در ساعت", - "coverage_area": "بسته به طراحی سیستم", - "soil_type": "اکثر خاک‌ها", - "climate_suitability": "گرم و خشک", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/irrigation/methods/post_400.json b/json/mock_data/irrigation/methods/post_400.json deleted file mode 100644 index f72f28c..0000000 --- a/json/mock_data/irrigation/methods/post_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "name": [ - "This field is required." - ] - } -} diff --git a/json/mock_data/irrigation/recommend/post_202.json b/json/mock_data/irrigation/recommend/post_202.json deleted file mode 100644 index a5f230d..0000000 --- a/json/mock_data/irrigation/recommend/post_202.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "code": 202, - "msg": "تسک توصیه آبیاری در صف قرار گرفت.", - "data": { - "task_id": "irr-task-123", - "status_url": "/api/irrigation/recommend/irr-task-123/status/" - } -} diff --git a/json/mock_data/irrigation/recommend/post_400.json b/json/mock_data/irrigation/recommend/post_400.json deleted file mode 100644 index 9fdc597..0000000 --- a/json/mock_data/irrigation/recommend/post_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "sensor_uuid": [ - "This field is required." - ] - } -} diff --git a/json/mock_data/irrigation/recommend/status/get_200_failure.json b/json/mock_data/irrigation/recommend/status/get_200_failure.json deleted file mode 100644 index 5f4e1f7..0000000 --- a/json/mock_data/irrigation/recommend/status/get_200_failure.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "irr-task-123", - "status": "FAILURE", - "error": "خطا در دریافت توصیه آبیاری." - } -} diff --git a/json/mock_data/irrigation/recommend/status/get_200_pending.json b/json/mock_data/irrigation/recommend/status/get_200_pending.json deleted file mode 100644 index 498b382..0000000 --- a/json/mock_data/irrigation/recommend/status/get_200_pending.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "irr-task-123", - "status": "PENDING", - "message": "تسک در صف یا یافت نشد." - } -} diff --git a/json/mock_data/irrigation/recommend/status/get_200_progress.json b/json/mock_data/irrigation/recommend/status/get_200_progress.json deleted file mode 100644 index e8837dd..0000000 --- a/json/mock_data/irrigation/recommend/status/get_200_progress.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "irr-task-123", - "status": "PROGRESS", - "progress": { - "message": "در حال پردازش توصیه آبیاری..." - } - } -} diff --git a/json/mock_data/irrigation/recommend/status/get_200_success.json b/json/mock_data/irrigation/recommend/status/get_200_success.json deleted file mode 100644 index 46f9c90..0000000 --- a/json/mock_data/irrigation/recommend/status/get_200_success.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "irr-task-123", - "status": "SUCCESS", - "result": { - "plan": { - "frequencyPerWeek": 3, - "durationMinutes": 42, - "bestTimeOfDay": "صبح زود", - "moistureLevel": 68, - "warning": "در صورت بارش موثر، نوبت سوم این هفته را حذف کنید." - }, - "raw_response": "{\"plan\":{\"frequencyPerWeek\":3,\"durationMinutes\":42}}", - "water_balance": { - "daily": [ - { - "forecast_date": "2025-03-25", - "et0_mm": 4.7, - "etc_mm": 5.6, - "effective_rainfall_mm": 0.0, - "gross_irrigation_mm": 6.2, - "irrigation_timing": "06:00-08:00" - } - ], - "crop_profile": { - "kc_initial": 0.6, - "kc_mid": 1.15, - "kc_end": 0.8 - }, - "active_kc": 1.15 - }, - "status": "completed" - } - } -} diff --git a/json/mock_data/plant/create-post_201.json b/json/mock_data/plant/create-post_201.json deleted file mode 100644 index 32c4614..0000000 --- a/json/mock_data/plant/create-post_201.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "code": 201, - "msg": "success", - "data": { - "id": 1, - "name": "گوجه‌فرنگی", - "light": "آفتاب کامل", - "watering": "منظم، هفته‌ای ۲ تا ۳ بار", - "soil": "لومی، غنی از مواد آلی", - "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", - "planting_season": "بهار", - "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", - "spacing": "۴۵ تا ۶۰ سانتی‌متر", - "fertilizer": "کود NPK متعادل", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/plant/create-post_400.json b/json/mock_data/plant/create-post_400.json deleted file mode 100644 index f72f28c..0000000 --- a/json/mock_data/plant/create-post_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "name": [ - "This field is required." - ] - } -} diff --git a/json/mock_data/plant/detail-delete_200.json b/json/mock_data/plant/detail-delete_200.json deleted file mode 100644 index b127160..0000000 --- a/json/mock_data/plant/detail-delete_200.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 200, - "msg": "گیاه با موفقیت حذف شد.", - "data": null -} diff --git a/json/mock_data/plant/detail-delete_404.json b/json/mock_data/plant/detail-delete_404.json deleted file mode 100644 index 497519d..0000000 --- a/json/mock_data/plant/detail-delete_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "گیاه یافت نشد.", - "data": null -} diff --git a/json/mock_data/plant/detail-get_200.json b/json/mock_data/plant/detail-get_200.json deleted file mode 100644 index af5f8ad..0000000 --- a/json/mock_data/plant/detail-get_200.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "id": 1, - "name": "گوجه‌فرنگی", - "light": "آفتاب کامل", - "watering": "منظم، هفته‌ای ۲ تا ۳ بار", - "soil": "لومی، غنی از مواد آلی", - "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", - "planting_season": "بهار", - "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", - "spacing": "۴۵ تا ۶۰ سانتی‌متر", - "fertilizer": "کود NPK متعادل", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/plant/detail-get_404.json b/json/mock_data/plant/detail-get_404.json deleted file mode 100644 index 497519d..0000000 --- a/json/mock_data/plant/detail-get_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "گیاه یافت نشد.", - "data": null -} diff --git a/json/mock_data/plant/detail-patch_200.json b/json/mock_data/plant/detail-patch_200.json deleted file mode 100644 index af5f8ad..0000000 --- a/json/mock_data/plant/detail-patch_200.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "id": 1, - "name": "گوجه‌فرنگی", - "light": "آفتاب کامل", - "watering": "منظم، هفته‌ای ۲ تا ۳ بار", - "soil": "لومی، غنی از مواد آلی", - "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", - "planting_season": "بهار", - "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", - "spacing": "۴۵ تا ۶۰ سانتی‌متر", - "fertilizer": "کود NPK متعادل", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/plant/detail-patch_400.json b/json/mock_data/plant/detail-patch_400.json deleted file mode 100644 index 2e4dd22..0000000 --- a/json/mock_data/plant/detail-patch_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "name": [ - "This field may not be blank." - ] - } -} diff --git a/json/mock_data/plant/detail-patch_404.json b/json/mock_data/plant/detail-patch_404.json deleted file mode 100644 index 497519d..0000000 --- a/json/mock_data/plant/detail-patch_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "گیاه یافت نشد.", - "data": null -} diff --git a/json/mock_data/plant/detail-put_200.json b/json/mock_data/plant/detail-put_200.json deleted file mode 100644 index af5f8ad..0000000 --- a/json/mock_data/plant/detail-put_200.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "id": 1, - "name": "گوجه‌فرنگی", - "light": "آفتاب کامل", - "watering": "منظم، هفته‌ای ۲ تا ۳ بار", - "soil": "لومی، غنی از مواد آلی", - "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", - "planting_season": "بهار", - "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", - "spacing": "۴۵ تا ۶۰ سانتی‌متر", - "fertilizer": "کود NPK متعادل", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/plant/detail-put_400.json b/json/mock_data/plant/detail-put_400.json deleted file mode 100644 index 2e4dd22..0000000 --- a/json/mock_data/plant/detail-put_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "name": [ - "This field may not be blank." - ] - } -} diff --git a/json/mock_data/plant/detail-put_404.json b/json/mock_data/plant/detail-put_404.json deleted file mode 100644 index 497519d..0000000 --- a/json/mock_data/plant/detail-put_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "گیاه یافت نشد.", - "data": null -} diff --git a/json/mock_data/plant/fetch-info-post_200.json b/json/mock_data/plant/fetch-info-post_200.json deleted file mode 100644 index af5f8ad..0000000 --- a/json/mock_data/plant/fetch-info-post_200.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "id": 1, - "name": "گوجه‌فرنگی", - "light": "آفتاب کامل", - "watering": "منظم، هفته‌ای ۲ تا ۳ بار", - "soil": "لومی، غنی از مواد آلی", - "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", - "planting_season": "بهار", - "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", - "spacing": "۴۵ تا ۶۰ سانتی‌متر", - "fertilizer": "کود NPK متعادل", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/plant/fetch-info-post_400.json b/json/mock_data/plant/fetch-info-post_400.json deleted file mode 100644 index e4bbdd2..0000000 --- a/json/mock_data/plant/fetch-info-post_400.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 400, - "msg": "نام گیاه الزامی است.", - "data": null -} diff --git a/json/mock_data/plant/fetch-info-post_503.json b/json/mock_data/plant/fetch-info-post_503.json deleted file mode 100644 index f4911a7..0000000 --- a/json/mock_data/plant/fetch-info-post_503.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 503, - "msg": "سرویس API هنوز پیاده‌سازی نشده است.", - "data": null -} diff --git a/json/mock_data/plant/list-get_200.json b/json/mock_data/plant/list-get_200.json deleted file mode 100644 index e6586a2..0000000 --- a/json/mock_data/plant/list-get_200.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": [ - { - "id": 1, - "name": "گوجه‌فرنگی", - "light": "آفتاب کامل", - "watering": "منظم، هفته‌ای ۲ تا ۳ بار", - "soil": "لومی، غنی از مواد آلی", - "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", - "planting_season": "بهار", - "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", - "spacing": "۴۵ تا ۶۰ سانتی‌متر", - "fertilizer": "کود NPK متعادل", - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } - ] -} diff --git a/json/mock_data/rag/chat-post_200_stream.json b/json/mock_data/rag/chat-post_200_stream.json deleted file mode 100644 index ead2147..0000000 --- a/json/mock_data/rag/chat-post_200_stream.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "content_type": "text/plain; charset=utf-8", - "body": "سلام، برای بازیابی رطوبت خاک بهتر است آبیاری صبح‌گاهی را تنظیم کنید." -} diff --git a/json/mock_data/rag/chat-post_400_invalid_service.json b/json/mock_data/rag/chat-post_400_invalid_service.json deleted file mode 100644 index c484816..0000000 --- a/json/mock_data/rag/chat-post_400_invalid_service.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "code": 400, - "msg": "service_id نامعتبر است: unknown_service" -} diff --git a/json/mock_data/rag/chat-post_400_missing_query.json b/json/mock_data/rag/chat-post_400_missing_query.json deleted file mode 100644 index b491272..0000000 --- a/json/mock_data/rag/chat-post_400_missing_query.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "code": 400, - "msg": "پارامتر query الزامی است." -} diff --git a/json/mock_data/rag/chat-post_400_missing_user.json b/json/mock_data/rag/chat-post_400_missing_user.json deleted file mode 100644 index 3615785..0000000 --- a/json/mock_data/rag/chat-post_400_missing_user.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "code": 400, - "msg": "برای این service_id، پارامتر user_id الزامی است." -} diff --git a/json/mock_data/rag/fertilization/post_202.json b/json/mock_data/rag/fertilization/post_202.json deleted file mode 100644 index 8fe7cc7..0000000 --- a/json/mock_data/rag/fertilization/post_202.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "code": 202, - "msg": "تسک توصیه کودهی در صف قرار گرفت.", - "data": { - "task_id": "rag-fert-123", - "status_url": "/api/rag/recommend/fertilization/rag-fert-123/status/" - } -} diff --git a/json/mock_data/rag/fertilization/post_400.json b/json/mock_data/rag/fertilization/post_400.json deleted file mode 100644 index 6bea070..0000000 --- a/json/mock_data/rag/fertilization/post_400.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 400, - "msg": "پارامتر sensor_uuid الزامی است.", - "data": null -} diff --git a/json/mock_data/rag/fertilization/status/get_200_failure.json b/json/mock_data/rag/fertilization/status/get_200_failure.json deleted file mode 100644 index 971dd04..0000000 --- a/json/mock_data/rag/fertilization/status/get_200_failure.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "rag-fert-123", - "status": "FAILURE", - "error": "خطا در دریافت توصیه کودهی." - } -} diff --git a/json/mock_data/rag/fertilization/status/get_200_pending.json b/json/mock_data/rag/fertilization/status/get_200_pending.json deleted file mode 100644 index 9c9eca6..0000000 --- a/json/mock_data/rag/fertilization/status/get_200_pending.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "rag-fert-123", - "status": "PENDING", - "message": "تسک در صف یا یافت نشد." - } -} diff --git a/json/mock_data/rag/fertilization/status/get_200_progress.json b/json/mock_data/rag/fertilization/status/get_200_progress.json deleted file mode 100644 index 4cd6d57..0000000 --- a/json/mock_data/rag/fertilization/status/get_200_progress.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "rag-fert-123", - "status": "PROGRESS", - "progress": { - "message": "در حال پردازش توصیه کودهی..." - } - } -} diff --git a/json/mock_data/rag/fertilization/status/get_200_success.json b/json/mock_data/rag/fertilization/status/get_200_success.json deleted file mode 100644 index cea132a..0000000 --- a/json/mock_data/rag/fertilization/status/get_200_success.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "rag-fert-123", - "status": "SUCCESS", - "result": { - "plan": { - "npkRatio": "20-20-20", - "amountPerHectare": "150 kg/ha", - "applicationMethod": "کودآبیاری در دو نوبت", - "applicationInterval": "هر ۱۰ روز", - "reasoning": "نیتروژن و پتاسیم خاک در محدوده متوسط است و گیاه در فاز رویشی نیاز تغذیه‌ای بالاتری دارد." - }, - "raw_response": "{\"plan\":{\"npkRatio\":\"20-20-20\"}}", - "status": "completed" - } - } -} diff --git a/json/mock_data/rag/irrigation/post_202.json b/json/mock_data/rag/irrigation/post_202.json deleted file mode 100644 index bcc6db9..0000000 --- a/json/mock_data/rag/irrigation/post_202.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "code": 202, - "msg": "تسک توصیه آبیاری در صف قرار گرفت.", - "data": { - "task_id": "rag-irr-123", - "status_url": "/api/rag/recommend/irrigation/rag-irr-123/status/" - } -} diff --git a/json/mock_data/rag/irrigation/post_400.json b/json/mock_data/rag/irrigation/post_400.json deleted file mode 100644 index 6bea070..0000000 --- a/json/mock_data/rag/irrigation/post_400.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 400, - "msg": "پارامتر sensor_uuid الزامی است.", - "data": null -} diff --git a/json/mock_data/rag/irrigation/status/get_200_failure.json b/json/mock_data/rag/irrigation/status/get_200_failure.json deleted file mode 100644 index bffa098..0000000 --- a/json/mock_data/rag/irrigation/status/get_200_failure.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "rag-irr-123", - "status": "FAILURE", - "error": "خطا در دریافت توصیه آبیاری." - } -} diff --git a/json/mock_data/rag/irrigation/status/get_200_pending.json b/json/mock_data/rag/irrigation/status/get_200_pending.json deleted file mode 100644 index 1074e86..0000000 --- a/json/mock_data/rag/irrigation/status/get_200_pending.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "rag-irr-123", - "status": "PENDING", - "message": "تسک در صف یا یافت نشد." - } -} diff --git a/json/mock_data/rag/irrigation/status/get_200_progress.json b/json/mock_data/rag/irrigation/status/get_200_progress.json deleted file mode 100644 index 3b88990..0000000 --- a/json/mock_data/rag/irrigation/status/get_200_progress.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "rag-irr-123", - "status": "PROGRESS", - "progress": { - "message": "در حال پردازش توصیه آبیاری..." - } - } -} diff --git a/json/mock_data/rag/irrigation/status/get_200_success.json b/json/mock_data/rag/irrigation/status/get_200_success.json deleted file mode 100644 index 5679648..0000000 --- a/json/mock_data/rag/irrigation/status/get_200_success.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "rag-irr-123", - "status": "SUCCESS", - "result": { - "plan": { - "frequencyPerWeek": 3, - "durationMinutes": 42, - "bestTimeOfDay": "صبح زود", - "moistureLevel": 68, - "warning": "در صورت بارش موثر، نوبت سوم این هفته را حذف کنید." - }, - "raw_response": "{\"plan\":{\"frequencyPerWeek\":3,\"durationMinutes\":42}}", - "water_balance": { - "daily": [ - { - "forecast_date": "2025-03-25", - "et0_mm": 4.7, - "etc_mm": 5.6, - "effective_rainfall_mm": 0.0, - "gross_irrigation_mm": 6.2, - "irrigation_timing": "06:00-08:00" - } - ], - "crop_profile": { - "kc_initial": 0.6, - "kc_mid": 1.15, - "kc_end": 0.8 - }, - "active_kc": 1.15 - }, - "status": "completed" - } - } -} diff --git a/json/mock_data/sensor-data/parameters-post_201.json b/json/mock_data/sensor-data/parameters-post_201.json deleted file mode 100644 index 9de5e9d..0000000 --- a/json/mock_data/sensor-data/parameters-post_201.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "code": 201, - "msg": "success", - "data": { - "id": 3, - "code": "soil_moisture", - "name_fa": "رطوبت خاک", - "unit": "%", - "created_at": "2025-03-24T10:00:00Z", - "action": "added" - } -} diff --git a/json/mock_data/sensor-data/parameters-post_400.json b/json/mock_data/sensor-data/parameters-post_400.json deleted file mode 100644 index 98b89aa..0000000 --- a/json/mock_data/sensor-data/parameters-post_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "code": [ - "This field is required." - ] - } -} diff --git a/json/mock_data/sensor-data/update-patch_200.json b/json/mock_data/sensor-data/update-patch_200.json deleted file mode 100644 index 5bc26e3..0000000 --- a/json/mock_data/sensor-data/update-patch_200.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", - "location_id": 12, - "soil_moisture": 45.2, - "soil_temperature": 22.5, - "soil_ph": 6.8, - "electrical_conductivity": 1.2, - "nitrogen": 30.0, - "phosphorus": 15.0, - "potassium": 20.0, - "plant_ids": [ - 1 - ], - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/sensor-data/update-patch_400.json b/json/mock_data/sensor-data/update-patch_400.json deleted file mode 100644 index cf343c5..0000000 --- a/json/mock_data/sensor-data/update-patch_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "location_id": [ - "This field is required." - ] - } -} diff --git a/json/mock_data/sensor-data/update-patch_404.json b/json/mock_data/sensor-data/update-patch_404.json deleted file mode 100644 index 107ea33..0000000 --- a/json/mock_data/sensor-data/update-patch_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "location_id یافت نشد.", - "data": null -} diff --git a/json/mock_data/sensor-data/update-put_200.json b/json/mock_data/sensor-data/update-put_200.json deleted file mode 100644 index 5bc26e3..0000000 --- a/json/mock_data/sensor-data/update-put_200.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", - "location_id": 12, - "soil_moisture": 45.2, - "soil_temperature": 22.5, - "soil_ph": 6.8, - "electrical_conductivity": 1.2, - "nitrogen": 30.0, - "phosphorus": 15.0, - "potassium": 20.0, - "plant_ids": [ - 1 - ], - "created_at": "2025-03-20T10:00:00Z", - "updated_at": "2025-03-24T10:00:00Z" - } -} diff --git a/json/mock_data/sensor-data/update-put_400.json b/json/mock_data/sensor-data/update-put_400.json deleted file mode 100644 index cf343c5..0000000 --- a/json/mock_data/sensor-data/update-put_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "location_id": [ - "This field is required." - ] - } -} diff --git a/json/mock_data/sensor-data/update-put_404.json b/json/mock_data/sensor-data/update-put_404.json deleted file mode 100644 index 107ea33..0000000 --- a/json/mock_data/sensor-data/update-put_404.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "code": 404, - "msg": "location_id یافت نشد.", - "data": null -} diff --git a/json/mock_data/soil-data/get_200_database.json b/json/mock_data/soil-data/get_200_database.json deleted file mode 100644 index 87becb1..0000000 --- a/json/mock_data/soil-data/get_200_database.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "source": "database", - "id": 12, - "lon": "51.389000", - "lat": "35.689200", - "depths": [ - { - "depth_label": "0-5cm", - "bdod": 1.31, - "cec": 18.4, - "cfvo": 2.0, - "clay": 24.0, - "nitrogen": 0.18, - "ocd": 32.0, - "ocs": 4.1, - "phh2o": 7.2, - "sand": 34.0, - "silt": 42.0, - "soc": 1.6, - "wv0010": 0.31, - "wv0033": 0.22, - "wv1500": 0.11 - }, - { - "depth_label": "5-15cm", - "bdod": 1.35, - "cec": 17.2, - "cfvo": 2.3, - "clay": 26.0, - "nitrogen": 0.16, - "ocd": 28.0, - "ocs": 3.7, - "phh2o": 7.1, - "sand": 36.0, - "silt": 38.0, - "soc": 1.4, - "wv0010": 0.29, - "wv0033": 0.2, - "wv1500": 0.1 - }, - { - "depth_label": "15-30cm", - "bdod": 1.39, - "cec": 15.8, - "cfvo": 2.8, - "clay": 28.0, - "nitrogen": 0.13, - "ocd": 22.0, - "ocs": 3.2, - "phh2o": 7.0, - "sand": 38.0, - "silt": 34.0, - "soc": 1.1, - "wv0010": 0.26, - "wv0033": 0.18, - "wv1500": 0.09 - } - ] - } -} diff --git a/json/mock_data/soil-data/get_202_queued.json b/json/mock_data/soil-data/get_202_queued.json deleted file mode 100644 index 5a06843..0000000 --- a/json/mock_data/soil-data/get_202_queued.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "code": 202, - "msg": "تسک در صف. وضعیت را با task_id بررسی کنید.", - "data": { - "source": "task", - "task_id": "soil-task-123", - "lon": 51.389, - "lat": 35.6892, - "status_url": "/api/soil-data/tasks/soil-task-123/status/" - } -} diff --git a/json/mock_data/soil-data/get_400.json b/json/mock_data/soil-data/get_400.json deleted file mode 100644 index bfc19ab..0000000 --- a/json/mock_data/soil-data/get_400.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "lat": [ - "This field is required." - ], - "lon": [ - "This field is required." - ] - } -} diff --git a/json/mock_data/soil-data/post_200_database.json b/json/mock_data/soil-data/post_200_database.json deleted file mode 100644 index 87becb1..0000000 --- a/json/mock_data/soil-data/post_200_database.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "source": "database", - "id": 12, - "lon": "51.389000", - "lat": "35.689200", - "depths": [ - { - "depth_label": "0-5cm", - "bdod": 1.31, - "cec": 18.4, - "cfvo": 2.0, - "clay": 24.0, - "nitrogen": 0.18, - "ocd": 32.0, - "ocs": 4.1, - "phh2o": 7.2, - "sand": 34.0, - "silt": 42.0, - "soc": 1.6, - "wv0010": 0.31, - "wv0033": 0.22, - "wv1500": 0.11 - }, - { - "depth_label": "5-15cm", - "bdod": 1.35, - "cec": 17.2, - "cfvo": 2.3, - "clay": 26.0, - "nitrogen": 0.16, - "ocd": 28.0, - "ocs": 3.7, - "phh2o": 7.1, - "sand": 36.0, - "silt": 38.0, - "soc": 1.4, - "wv0010": 0.29, - "wv0033": 0.2, - "wv1500": 0.1 - }, - { - "depth_label": "15-30cm", - "bdod": 1.39, - "cec": 15.8, - "cfvo": 2.8, - "clay": 28.0, - "nitrogen": 0.13, - "ocd": 22.0, - "ocs": 3.2, - "phh2o": 7.0, - "sand": 38.0, - "silt": 34.0, - "soc": 1.1, - "wv0010": 0.26, - "wv0033": 0.18, - "wv1500": 0.09 - } - ] - } -} diff --git a/json/mock_data/soil-data/post_202_queued.json b/json/mock_data/soil-data/post_202_queued.json deleted file mode 100644 index 5a06843..0000000 --- a/json/mock_data/soil-data/post_202_queued.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "code": 202, - "msg": "تسک در صف. وضعیت را با task_id بررسی کنید.", - "data": { - "source": "task", - "task_id": "soil-task-123", - "lon": 51.389, - "lat": 35.6892, - "status_url": "/api/soil-data/tasks/soil-task-123/status/" - } -} diff --git a/json/mock_data/soil-data/post_400.json b/json/mock_data/soil-data/post_400.json deleted file mode 100644 index 4d32daf..0000000 --- a/json/mock_data/soil-data/post_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 400, - "msg": "داده نامعتبر.", - "data": { - "lat": [ - "A valid number is required." - ] - } -} diff --git a/json/mock_data/soil-data/status/get_200_failure.json b/json/mock_data/soil-data/status/get_200_failure.json deleted file mode 100644 index a0c0697..0000000 --- a/json/mock_data/soil-data/status/get_200_failure.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "soil-task-123", - "status": "FAILURE", - "error": "خطا در واکشی داده خاک." - } -} diff --git a/json/mock_data/soil-data/status/get_200_pending.json b/json/mock_data/soil-data/status/get_200_pending.json deleted file mode 100644 index b4965ce..0000000 --- a/json/mock_data/soil-data/status/get_200_pending.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "soil-task-123", - "status": "PENDING", - "message": "تسک در صف یا یافت نشد." - } -} diff --git a/json/mock_data/soil-data/status/get_200_progress.json b/json/mock_data/soil-data/status/get_200_progress.json deleted file mode 100644 index 29ecfe5..0000000 --- a/json/mock_data/soil-data/status/get_200_progress.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "soil-task-123", - "status": "PROGRESS", - "progress": { - "step": "fetch", - "percent": 60 - } - } -} diff --git a/json/mock_data/soil-data/status/get_200_success.json b/json/mock_data/soil-data/status/get_200_success.json deleted file mode 100644 index 04d0827..0000000 --- a/json/mock_data/soil-data/status/get_200_success.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "soil-task-123", - "status": "SUCCESS", - "result": { - "source": "database", - "id": 12, - "lon": "51.389000", - "lat": "35.689200", - "depths": [ - { - "depth_label": "0-5cm", - "bdod": 1.31, - "cec": 18.4, - "cfvo": 2.0, - "clay": 24.0, - "nitrogen": 0.18, - "ocd": 32.0, - "ocs": 4.1, - "phh2o": 7.2, - "sand": 34.0, - "silt": 42.0, - "soc": 1.6, - "wv0010": 0.31, - "wv0033": 0.22, - "wv1500": 0.11 - }, - { - "depth_label": "5-15cm", - "bdod": 1.35, - "cec": 17.2, - "cfvo": 2.3, - "clay": 26.0, - "nitrogen": 0.16, - "ocd": 28.0, - "ocs": 3.7, - "phh2o": 7.1, - "sand": 36.0, - "silt": 38.0, - "soc": 1.4, - "wv0010": 0.29, - "wv0033": 0.2, - "wv1500": 0.1 - }, - { - "depth_label": "15-30cm", - "bdod": 1.39, - "cec": 15.8, - "cfvo": 2.8, - "clay": 28.0, - "nitrogen": 0.13, - "ocd": 22.0, - "ocs": 3.2, - "phh2o": 7.0, - "sand": 38.0, - "silt": 34.0, - "soc": 1.1, - "wv0010": 0.26, - "wv0033": 0.18, - "wv1500": 0.09 - } - ] - } - } -} diff --git a/json/mock_data/tasks/post_200.json b/json/mock_data/tasks/post_200.json deleted file mode 100644 index 8fc9fe2..0000000 --- a/json/mock_data/tasks/post_200.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "sample-task-123" - } -} diff --git a/json/mock_data/tasks/status/get_200_failure.json b/json/mock_data/tasks/status/get_200_failure.json deleted file mode 100644 index 2c34884..0000000 --- a/json/mock_data/tasks/status/get_200_failure.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "sample-task-123", - "status": "FAILURE", - "error": "Sample task failed." - } -} diff --git a/json/mock_data/tasks/status/get_200_pending.json b/json/mock_data/tasks/status/get_200_pending.json deleted file mode 100644 index c6ce7cb..0000000 --- a/json/mock_data/tasks/status/get_200_pending.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "sample-task-123", - "status": "PENDING", - "message": "تسک در صف یا یافت نشد." - } -} diff --git a/json/mock_data/tasks/status/get_200_progress.json b/json/mock_data/tasks/status/get_200_progress.json deleted file mode 100644 index abb1b76..0000000 --- a/json/mock_data/tasks/status/get_200_progress.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "sample-task-123", - "status": "PROGRESS", - "progress": { - "current": 1, - "total": 3, - "message": "در حال پردازش..." - } - } -} diff --git a/json/mock_data/tasks/status/get_200_success.json b/json/mock_data/tasks/status/get_200_success.json deleted file mode 100644 index 5b55965..0000000 --- a/json/mock_data/tasks/status/get_200_success.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "code": 200, - "msg": "success", - "data": { - "task_id": "sample-task-123", - "status": "SUCCESS", - "result": "done" - } -} diff --git a/location_data/management/commands/seed_location_data.py b/location_data/management/commands/seed_location_data.py new file mode 100644 index 0000000..4295906 --- /dev/null +++ b/location_data/management/commands/seed_location_data.py @@ -0,0 +1,107 @@ +""" +Management command to seed a fixed demo farm center location and soil depths. +Run: python manage.py seed_location_data +""" +from django.core.management.base import BaseCommand + +from location_data.models import SoilDepthData, SoilLocation + + +DEMO_LATITUDE = "50.000000" +DEMO_LONGITUDE = "50.000000" +DEMO_BOUNDARY = { + "type": "Polygon", + "coordinates": [ + [ + [49.995, 49.995], + [50.005, 49.995], + [50.005, 50.005], + [49.995, 50.005], + [49.995, 49.995], + ] + ], +} +DEMO_SOIL_DEPTHS = { + SoilDepthData.DEPTH_0_5: { + "bdod": 1.22, + "cec": 18.4, + "cfvo": 3.0, + "clay": 24.0, + "nitrogen": 0.21, + "ocd": 26.0, + "ocs": 4.1, + "phh2o": 6.7, + "sand": 38.0, + "silt": 38.0, + "soc": 1.8, + "wv0010": 0.32, + "wv0033": 0.24, + "wv1500": 0.12, + }, + SoilDepthData.DEPTH_5_15: { + "bdod": 1.28, + "cec": 17.2, + "cfvo": 4.0, + "clay": 26.0, + "nitrogen": 0.18, + "ocd": 23.0, + "ocs": 3.6, + "phh2o": 6.8, + "sand": 36.0, + "silt": 38.0, + "soc": 1.5, + "wv0010": 0.29, + "wv0033": 0.22, + "wv1500": 0.11, + }, + SoilDepthData.DEPTH_15_30: { + "bdod": 1.34, + "cec": 15.9, + "cfvo": 5.0, + "clay": 28.0, + "nitrogen": 0.14, + "ocd": 19.0, + "ocs": 2.9, + "phh2o": 6.9, + "sand": 34.0, + "silt": 38.0, + "soc": 1.2, + "wv0010": 0.26, + "wv0033": 0.19, + "wv1500": 0.09, + }, +} + + +class Command(BaseCommand): + help = "Seed a fixed center location at 50.00, 50.00 plus three soil depth rows." + + def handle(self, *args, **options): + location, created = SoilLocation.objects.update_or_create( + latitude=DEMO_LATITUDE, + longitude=DEMO_LONGITUDE, + defaults={ + "task_id": "", + "farm_boundary": DEMO_BOUNDARY, + }, + ) + + status_text = "Created" if created else "Updated" + self.stdout.write( + self.style.SUCCESS( + f"{status_text} SoilLocation id={location.id} at ({location.latitude}, {location.longitude})" + ) + ) + + for depth_label, values in DEMO_SOIL_DEPTHS.items(): + _, depth_created = SoilDepthData.objects.update_or_create( + soil_location=location, + depth_label=depth_label, + defaults=values, + ) + depth_status = "Created" if depth_created else "Updated" + self.stdout.write( + self.style.SUCCESS(f" {depth_status} SoilDepthData {depth_label}") + ) + + self.stdout.write(self.style.SUCCESS("\nDone seeding location_data demo records.")) diff --git a/location_data/migrations/0006_remove_soillocation_ideal_sensor_profile.py b/location_data/migrations/0006_remove_soillocation_ideal_sensor_profile.py new file mode 100644 index 0000000..96dce3c --- /dev/null +++ b/location_data/migrations/0006_remove_soillocation_ideal_sensor_profile.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0005_merge_20260327_0840"), + ] + + operations = [ + migrations.RemoveField( + model_name="soillocation", + name="ideal_sensor_profile", + ), + ] diff --git a/location_data/models.py b/location_data/models.py index be47c82..4c1b1c4 100644 --- a/location_data/models.py +++ b/location_data/models.py @@ -3,7 +3,7 @@ from django.db import models class SoilLocation(models.Model): """ - مختصات جغرافیایی برای داده‌های خاک. + مرکز زمین برای داده‌های خاک و مزرعه. هر مختصات سه سطر در SoilDepthData دارد (۰–۵، ۵–۱۵، ۱۵–۳۰ سانتی‌متر). """ @@ -11,27 +11,20 @@ class SoilLocation(models.Model): max_digits=9, decimal_places=6, db_index=True, - help_text="عرض جغرافیایی (lat)", + help_text="عرض جغرافیایی مرکز زمین (lat)", ) longitude = models.DecimalField( max_digits=9, decimal_places=6, db_index=True, - help_text="طول جغرافیایی (lon)", + help_text="طول جغرافیایی مرکز زمین (lon)", ) task_id = models.CharField( max_length=255, blank=True, help_text="شناسه تسک Celery در حال پردازش", ) - ideal_sensor_profile = models.JSONField( - default=dict, - blank=True, - help_text=( - "پروفایل ایده‌آل سنسورها برای این مزرعه/لوکیشن. " - 'نمونه: {"moisture": {"ideal": 0.65, "min": 0.50, "max": 0.80}}' - ), - ) + farm_boundary = models.JSONField( default=dict, blank=True, @@ -51,10 +44,20 @@ class SoilLocation(models.Model): ) ] ordering = ["-updated_at"] + verbose_name = "مرکز زمین" + verbose_name_plural = "مراکز زمین" def __str__(self): return f"SoilLocation({self.latitude}, {self.longitude})" + @property + def center_latitude(self): + return self.latitude + + @property + def center_longitude(self): + return self.longitude + @property def is_complete(self): """آیا هر سه عمق ذخیره شده‌اند؟""" diff --git a/logs/app.log.2026-04-02 b/logs/app.log.2026-04-02 new file mode 100644 index 0000000..6dfa7c3 --- /dev/null +++ b/logs/app.log.2026-04-02 @@ -0,0 +1,23 @@ +2026-04-02 11:49:29,344 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-04-02 12:11:41,087 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-04-02 12:14:51,907 [INFO] rag.api_provider: gapgpt +2026-04-02 12:14:51,907 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1 +2026-04-02 12:16:13,420 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. +2026-04-02 12:16:15,842 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-04-02 12:16:41,558 [INFO] rag.api_provider: embedding provider=gapgpt +2026-04-02 12:16:41,559 [INFO] rag.api_provider: embedding base_url=https://api.gapgpt.app/v1 api_key=sk-Z...ihn5 +2026-04-02 12:23:15,783 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-04-02 12:45:46,635 [INFO] rag.api_provider: embedding provider=gapgpt +2026-04-02 12:45:46,635 [INFO] rag.api_provider: embedding base_url=https://api.gapgpt.app/v1 api_key=sk-Z...ihn5 +2026-04-02 12:46:00,212 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" +2026-04-02 12:46:00,214 [INFO] openai._base_client: Retrying request to /embeddings in 0.836547 seconds +2026-04-02 12:46:01,336 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" +2026-04-02 12:46:01,337 [INFO] openai._base_client: Retrying request to /embeddings in 1.855433 seconds +2026-04-02 12:46:03,485 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" +2026-04-02 12:46:09,716 [INFO] rag.api_provider: embedding provider=gapgpt +2026-04-02 12:46:09,716 [INFO] rag.api_provider: embedding base_url=https://api.gapgpt.app/v1 api_key=sk-Z...ihn5 +2026-04-02 12:46:10,114 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" +2026-04-02 12:46:10,114 [INFO] openai._base_client: Retrying request to /embeddings in 0.908246 seconds +2026-04-02 12:46:11,326 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" +2026-04-02 12:46:11,326 [INFO] openai._base_client: Retrying request to /embeddings in 1.841081 seconds +2026-04-02 12:46:13,570 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" diff --git a/logs/app.log.2026-04-05 b/logs/app.log.2026-04-05 new file mode 100644 index 0000000..e6ada83 --- /dev/null +++ b/logs/app.log.2026-04-05 @@ -0,0 +1,9 @@ +2026-04-05 18:53:10,339 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-04-05 18:53:20,171 [INFO] django.server: "GET /api/docs/ HTTP/1.1" 200 4633 +2026-04-05 18:53:20,293 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui.css HTTP/1.1" 200 152072 +2026-04-05 18:53:20,298 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-standalone-preset.js HTTP/1.1" 200 230007 +2026-04-05 18:53:20,345 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-bundle.js HTTP/1.1" 200 1426050 +2026-04-05 18:53:20,679 [INFO] django.server: "GET /api/schema/ HTTP/1.1" 200 146697 +2026-04-05 18:53:20,690 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/favicon-32x32.png HTTP/1.1" 200 628 +2026-04-05 19:26:12,454 [INFO] django.utils.autoreload: /app/location_data/urls.py changed, reloading. +2026-04-05 19:26:14,602 [INFO] django.utils.autoreload: Watching for file changes with StatReloader diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py index 0e062fc..aa7553d 100644 --- a/rag/services/irrigation.py +++ b/rag/services/irrigation.py @@ -6,7 +6,7 @@ import json import logging from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile, resolve_kc -from sensor_data.models import SensorData +from farm_data.models import SensorData from rag.api_provider import get_chat_client from rag.chat import build_rag_context, _load_service_tone from rag.config import load_rag_config, RAGConfig, get_service_config @@ -77,7 +77,7 @@ def get_irrigation_recommendation( user_query = query or "توصیه آبیاری برای مزرعه من چیست؟" - sensor = SensorData.objects.select_related("location").prefetch_related("plants").filter(uuid_sensor=sensor_uuid).first() + sensor = SensorData.objects.select_related("center_location").prefetch_related("plants").filter(farm_uuid=sensor_uuid).first() plant = None if sensor is not None and plant_name: plant = sensor.plants.filter(name=plant_name).first() @@ -89,7 +89,7 @@ def get_irrigation_recommendation( daily_water_needs = [] if sensor is not None: forecasts = list( - WeatherForecast.objects.filter(location=sensor.location, forecast_date__isnull=False) + WeatherForecast.objects.filter(location=sensor.center_location, forecast_date__isnull=False) .order_by("forecast_date")[:7] ) efficiency_percent = None @@ -100,7 +100,7 @@ def get_irrigation_recommendation( efficiency_percent = getattr(method, "water_efficiency_percent", None) if method else None daily_water_needs = calculate_forecast_water_needs( forecasts=forecasts, - latitude_deg=float(sensor.location.latitude), + latitude_deg=float(sensor.center_location.latitude), crop_profile=crop_profile, growth_stage=growth_stage, irrigation_efficiency_percent=efficiency_percent, diff --git a/rag/user_data.py b/rag/user_data.py index 5ba9498..4c2eca3 100644 --- a/rag/user_data.py +++ b/rag/user_data.py @@ -1,6 +1,6 @@ """ -ساخت دیتای خاک و هواشناسی کاربر از sensor_data، location_data و weather — Schema-agnostic -هر سنسور = یک کاربر. شناسایی با uuid_sensor. +ساخت دیتای خاک و هواشناسی کاربر از farm_data، location_data و weather — Schema-agnostic +هر سنسور = یک کاربر. شناسایی با farm_uuid. مدل‌های Django داخل توابع import می‌شوند تا از AppRegistryNotReady جلوگیری شود. """ @@ -43,12 +43,12 @@ def build_user_soil_text(sensor_uuid: str) -> str | None: Returns: متن متنی قابل چانک، یا None اگر سنسور یافت نشد. """ - from sensor_data.models import SensorData + from farm_data.models import SensorData from location_data.models import SoilDepthData try: - sensor = SensorData.objects.select_related("location").get( - uuid_sensor=sensor_uuid + sensor = SensorData.objects.select_related("center_location").get( + farm_uuid=sensor_uuid ) except SensorData.DoesNotExist: return None @@ -56,17 +56,17 @@ def build_user_soil_text(sensor_uuid: str) -> str | None: parts: list[str] = [] # شناسه سنسور - parts.append(f"سنسور: {sensor.uuid_sensor}") + parts.append(f"سنسور: {sensor.farm_uuid}") # موقعیت مزرعه - loc = sensor.location + loc = sensor.center_location parts.append( f"موقعیت مزرعه: عرض {loc.latitude}، طول {loc.longitude}" ) # خوانش‌های سنسور (schema-agnostic) sensor_fields = _model_to_data_fields( - sensor, exclude={"uuid_sensor", "location_id", "location"} + sensor, exclude={"farm_uuid", "center_location_id", "center_location", "location"} ) if sensor_fields: sensor_lines = [f" {k}: {v}" for k, v in sorted(sensor_fields.items())] @@ -94,12 +94,12 @@ def build_user_soil_text(sensor_uuid: str) -> str | None: def get_all_sensor_uuids() -> list[str]: - """لیست همه uuid_sensor های موجود.""" - from sensor_data.models import SensorData + """لیست همه farm_uuid های موجود.""" + from farm_data.models import SensorData return [ str(u) for u in - SensorData.objects.values_list("uuid_sensor", flat=True).distinct() + SensorData.objects.values_list("farm_uuid", flat=True).distinct() ] @@ -111,17 +111,17 @@ def build_user_weather_text(sensor_uuid: str) -> str | None: Returns: متن فارسی ساختاریافته، یا None اگر داده‌ای نباشد. """ - from sensor_data.models import SensorData + from farm_data.models import SensorData from weather.models import WeatherForecast try: - sensor = SensorData.objects.select_related("location").get( - uuid_sensor=sensor_uuid + sensor = SensorData.objects.select_related("center_location").get( + farm_uuid=sensor_uuid ) except SensorData.DoesNotExist: return None - loc = sensor.location + loc = sensor.center_location forecasts = ( WeatherForecast.objects.filter( location=loc, diff --git a/scripts/fix_farm_data_tables.sh b/scripts/fix_farm_data_tables.sh new file mode 100644 index 0000000..ffcf84e --- /dev/null +++ b/scripts/fix_farm_data_tables.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Fix: جداول farm_data وجود ندارند اما migrationهای legacy با label قدیمی ثبت شده‌اند. +# اجرا: docker compose run --rm web sh /app/scripts/fix_farm_data_tables.sh +set -e +cd /app +echo "Resetting legacy sensor_data migrations (fake unapply - tables may not exist)..." +python manage.py migrate sensor_data zero --noinput --fake +echo "Re-applying legacy sensor_data migrations (--fake-initial if tables already exist)..." +python manage.py migrate sensor_data --noinput --fake-initial +echo "Done. Running seed_sensor_parameters..." +python manage.py seed_sensor_parameters +echo "All done." diff --git a/scripts/fix_sensor_data_tables.sh b/scripts/fix_sensor_data_tables.sh deleted file mode 100644 index 143503b..0000000 --- a/scripts/fix_sensor_data_tables.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -# Fix: جداول sensor_data وجود ندارند اما migrationها به‌عنوان اعمال‌شده ثبت شده‌اند. -# اجرا: docker compose run --rm web sh /app/scripts/fix_sensor_data_tables.sh -set -e -cd /app -echo "Resetting sensor_data migrations (fake unapply - tables may not exist)..." -python manage.py migrate sensor_data zero --noinput --fake -echo "Re-applying sensor_data migrations (--fake-initial if tables already exist)..." -python manage.py migrate sensor_data --noinput --fake-initial -echo "Done. Running seed_sensor_parameters..." -python manage.py seed_sensor_parameters -echo "All done." diff --git a/scripts/generate_mock_data.py b/scripts/generate_mock_data.py index ebe4523..b4a7ad6 100644 --- a/scripts/generate_mock_data.py +++ b/scripts/generate_mock_data.py @@ -176,15 +176,20 @@ SOIL_TASK_DATA = { } SENSOR_DATA = { - "uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", - "location_id": 12, - "soil_moisture": 45.2, - "soil_temperature": 22.5, - "soil_ph": 6.8, - "electrical_conductivity": 1.2, - "nitrogen": 30.0, - "phosphorus": 15.0, - "potassium": 20.0, + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "center_location_id": 12, + "weather_forecast_id": 21, + "sensor_payload": { + "sensor-7-1": { + "soil_moisture": 45.2, + "soil_temperature": 22.5, + "soil_ph": 6.8, + "electrical_conductivity": 1.2, + "nitrogen": 30.0, + "phosphorus": 15.0, + "potassium": 20.0, + } + }, "plant_ids": [1], "created_at": "2025-03-20T10:00:00Z", "updated_at": "2025-03-24T10:00:00Z", @@ -192,9 +197,12 @@ SENSOR_DATA = { SENSOR_PARAMETER = { "id": 3, + "sensor_key": "sensor-7-1", "code": "soil_moisture", "name_fa": "رطوبت خاک", "unit": "%", + "data_type": "float", + "metadata": {"min": 0, "max": 100}, "created_at": "2025-03-24T10:00:00Z", "action": "added", } @@ -754,43 +762,42 @@ def main(): payload, ) - for method in ("put", "patch"): - register( - f"sensor-data/update-{method}_200.json", - method.upper(), - "/api/sensor-data/{uuid_sensor}/", - 200, - f"Sensor update {method} success", - ok_response(SENSOR_DATA), - ) - register( - f"sensor-data/update-{method}_400.json", - method.upper(), - "/api/sensor-data/{uuid_sensor}/", - 400, - f"Sensor update {method} validation error", - error_response(400, "داده نامعتبر.", {"location_id": ["This field is required."]}), - ) - register( - f"sensor-data/update-{method}_404.json", - method.upper(), - "/api/sensor-data/{uuid_sensor}/", - 404, - f"Sensor update {method} location not found", - error_response(404, "location_id یافت نشد.", None), - ) register( - "sensor-data/parameters-post_201.json", + "farm-data/upsert-post_201.json", "POST", - "/api/sensor-data/parameters/", + "/api/farm-data/", + 201, + "Farm data created", + ok_response(SENSOR_DATA, code=201), + ) + register( + "farm-data/upsert-post_400.json", + "POST", + "/api/farm-data/", + 400, + "Farm data validation error", + error_response(400, "داده نامعتبر.", {"farm_uuid": ["This field is required."]}), + ) + register( + "farm-data/upsert-post_404.json", + "POST", + "/api/farm-data/", + 400, + "Farm data invalid boundary", + error_response(400, "داده نامعتبر.", {"farm_boundary": ["farm_boundary باید حداقل 3 گوشه معتبر داشته باشد."]}), + ) + register( + "farm-data/parameters-post_201.json", + "POST", + "/api/farm-data/parameters/", 201, "Create sensor parameter", ok_response(SENSOR_PARAMETER, code=201), ) register( - "sensor-data/parameters-post_400.json", + "farm-data/parameters-post_400.json", "POST", - "/api/sensor-data/parameters/", + "/api/farm-data/parameters/", 400, "Sensor parameter validation error", error_response(400, "داده نامعتبر.", {"code": ["This field is required."]}), diff --git a/sensor_data/admin.py b/sensor_data/admin.py deleted file mode 100644 index 462e210..0000000 --- a/sensor_data/admin.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.contrib import admin - -from .models import ParameterUpdateLog, SensorData, SensorDataHistory, SensorParameter - - -@admin.register(SensorData) -class SensorDataAdmin(admin.ModelAdmin): - list_display = ( - "uuid_sensor", - "location_id", - "soil_moisture", - "soil_temperature", - "soil_ph", - "electrical_conductivity", - "nitrogen", - "phosphorus", - "potassium", - "updated_at", - ) - list_filter = ("updated_at",) - search_fields = ("uuid_sensor", "location_id") - filter_horizontal = ("plants",) - - -@admin.register(SensorDataHistory) -class SensorDataHistoryAdmin(admin.ModelAdmin): - list_display = ( - "id", - "uuid_sensor", - "location_id", - "soil_moisture", - "soil_temperature", - "soil_ph", - "recorded_at", - ) - list_filter = ("recorded_at",) - search_fields = ("uuid_sensor", "location_id") - - -@admin.register(SensorParameter) -class SensorParameterAdmin(admin.ModelAdmin): - list_display = ("code", "name_fa", "unit", "created_at") - search_fields = ("code", "name_fa") - - -@admin.register(ParameterUpdateLog) -class ParameterUpdateLogAdmin(admin.ModelAdmin): - list_display = ("parameter", "action", "updated_at") - list_filter = ("action", "updated_at") diff --git a/sensor_data/apps.py b/sensor_data/apps.py deleted file mode 100644 index bf7b3fc..0000000 --- a/sensor_data/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class SensorDataConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "sensor_data" - verbose_name = "Sensor Data" diff --git a/sensor_data/management/commands/seed_sensor_parameters.py b/sensor_data/management/commands/seed_sensor_parameters.py deleted file mode 100644 index 0d1310d..0000000 --- a/sensor_data/management/commands/seed_sensor_parameters.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Management command to seed the 7 initial sensor parameters. -Run: python manage.py seed_sensor_parameters -""" -from django.core.management.base import BaseCommand - -from sensor_data.models import ParameterUpdateLog, SensorParameter - - -INITIAL_PARAMETERS = [ - ("soil_moisture", "رطوبت خاک", "%"), - ("soil_temperature", "دما خاک", "°C"), - ("soil_ph", "pH خاک", ""), - ("electrical_conductivity", "هدایت الکتریکی", "dS/m"), - ("nitrogen", "ازت (N)", "mg/kg"), - ("phosphorus", "فسفر", "mg/kg"), - ("potassium", "پتاسیم", "mg/kg"), -] - - -class Command(BaseCommand): - help = "Seed 7 initial sensor parameters (soil_moisture, soil_temperature, etc.)" - - def handle(self, *args, **options): - created_count = 0 - for code, name_fa, unit in INITIAL_PARAMETERS: - param, created = SensorParameter.objects.get_or_create( - code=code, - defaults={"name_fa": name_fa, "unit": unit}, - ) - if created: - ParameterUpdateLog.objects.create( - parameter=param, - action="added", - ) - created_count += 1 - self.stdout.write(self.style.SUCCESS(f" Created: {code} ({name_fa})")) - self.stdout.write( - self.style.SUCCESS(f"\nDone. Created {created_count} new parameters.") - ) diff --git a/sensor_data/models.py b/sensor_data/models.py deleted file mode 100644 index b91fe66..0000000 --- a/sensor_data/models.py +++ /dev/null @@ -1,129 +0,0 @@ -import uuid - -from django.db import models - - -class SensorData(models.Model): - """ - داده‌های خوانش سنسور برای یک location. - هنگام آپدیت، نسخه قبلی در SensorDataHistory ذخیره می‌شود. - """ - - uuid_sensor = models.UUIDField( - primary_key=True, - default=uuid.uuid4, - editable=False, - help_text="شناسه یکتای سنسور", - ) - location = models.ForeignKey( - "location_data.SoilLocation", - on_delete=models.CASCADE, - related_name="sensor_data", - db_column="location_id", - help_text="همان location_id از location_data", - ) - soil_moisture = models.FloatField(null=True, blank=True, help_text="رطوبت خاک") - soil_temperature = models.FloatField(null=True, blank=True, help_text="دما خاک") - soil_ph = models.FloatField(null=True, blank=True, help_text="pH خاک") - electrical_conductivity = models.FloatField( - null=True, blank=True, help_text="هدایت الکتریکی" - ) - nitrogen = models.FloatField(null=True, blank=True, help_text="ازت (N)") - phosphorus = models.FloatField(null=True, blank=True, help_text="فسفر") - potassium = models.FloatField(null=True, blank=True, help_text="پتاسیم") - plants = models.ManyToManyField( - "plant.Plant", - blank=True, - related_name="sensor_data", - help_text="گیاهان مرتبط با این سنسور", - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ["-updated_at"] - verbose_name = "داده سنسور" - verbose_name_plural = "داده‌های سنسور" - - def __str__(self): - return f"SensorData({self.uuid_sensor}, location={self.location_id})" - - -class SensorDataHistory(models.Model): - """ - تاریخچه خوانش‌های سنسور. کپی از SensorData هنگام آپدیت. - """ - - uuid_sensor = models.UUIDField(help_text="شناسه سنسور") - location_id = models.IntegerField(help_text="location_id از location_data") - soil_moisture = models.FloatField(null=True, blank=True) - soil_temperature = models.FloatField(null=True, blank=True) - soil_ph = models.FloatField(null=True, blank=True) - electrical_conductivity = models.FloatField(null=True, blank=True) - nitrogen = models.FloatField(null=True, blank=True) - phosphorus = models.FloatField(null=True, blank=True) - potassium = models.FloatField(null=True, blank=True) - recorded_at = models.DateTimeField( - auto_now_add=True, help_text="زمان ثبت در تاریخچه" - ) - - class Meta: - ordering = ["-recorded_at"] - verbose_name = "تاریخچه داده سنسور" - verbose_name_plural = "تاریخچه داده‌های سنسور" - - def __str__(self): - return f"SensorDataHistory({self.uuid_sensor}, {self.recorded_at})" - - -class SensorParameter(models.Model): - """ - تعریف پارامترهای سنسور (مثلاً رطوبت خاک، pH، ...). - """ - - code = models.CharField( - max_length=64, - unique=True, - db_index=True, - help_text="کد یکتا (مثلاً soil_moisture)", - ) - name_fa = models.CharField(max_length=128, help_text="نام فارسی") - unit = models.CharField(max_length=32, blank=True, help_text="واحد اندازه‌گیری") - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - ordering = ["code"] - verbose_name = "پارامتر سنسور" - verbose_name_plural = "پارامترهای سنسور" - - def __str__(self): - return f"{self.code} ({self.name_fa})" - - -class ParameterUpdateLog(models.Model): - """ - لاگ آپدیت لیست پارامترها. - """ - - ACTION_ADDED = "added" - ACTION_MODIFIED = "modified" - ACTION_CHOICES = [ - (ACTION_ADDED, "اضافه شده"), - (ACTION_MODIFIED, "ویرایش شده"), - ] - - parameter = models.ForeignKey( - SensorParameter, - on_delete=models.CASCADE, - related_name="update_logs", - ) - action = models.CharField(max_length=16, choices=ACTION_CHOICES) - updated_at = models.DateTimeField(auto_now_add=True) - - class Meta: - ordering = ["-updated_at"] - verbose_name = "لاگ آپدیت پارامتر" - verbose_name_plural = "لاگ آپدیت پارامترها" - - def __str__(self): - return f"{self.parameter.code} - {self.action} - {self.updated_at}" diff --git a/sensor_data/postman/sensor_data.json b/sensor_data/postman/sensor_data.json deleted file mode 100644 index 1ac164d..0000000 --- a/sensor_data/postman/sensor_data.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "info": { - "name": "Sensor Data", - "description": "API داده‌های سنسور: آپدیت خوانش سنسور و مدیریت پارامترها", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "variable": [ - {"key": "baseUrl", "value": "http://localhost:8020"}, - {"key": "uuid_sensor", "value": "00000000-0000-0000-0000-000000000000"} - ], - "item": [ - { - "name": "Update Sensor Data (PUT)", - "request": { - "method": "PUT", - "header": [ - {"key": "Content-Type", "value": "application/json"}, - {"key": "Accept", "value": "application/json"} - ], - "body": { - "mode": "raw", - "raw": "{\n \"location_id\": 1,\n \"soil_moisture\": 25.5,\n \"soil_temperature\": 22.3,\n \"soil_ph\": 7.2,\n \"electrical_conductivity\": 1.8,\n \"nitrogen\": 120.0,\n \"phosphorus\": 45.0,\n \"potassium\": 180.0\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/sensor-data/{{uuid_sensor}}/", - "host": ["{{baseUrl}}"], - "path": ["api", "sensor-data", "{{uuid_sensor}}", ""] - } - }, - "description": "آپدیت کامل داده سنسور. نسخه جدید در تاریخچه ذخیره می‌شود. location_id باید به SoilLocation ارجاع دهد." - }, - { - "name": "Update Sensor Data (PATCH)", - "request": { - "method": "PATCH", - "header": [ - {"key": "Content-Type", "value": "application/json"}, - {"key": "Accept", "value": "application/json"} - ], - "body": { - "mode": "raw", - "raw": "{\n \"location_id\": 1,\n \"soil_moisture\": 28.0,\n \"soil_ph\": 7.5\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/sensor-data/{{uuid_sensor}}/", - "host": ["{{baseUrl}}"], - "path": ["api", "sensor-data", "{{uuid_sensor}}", ""] - } - }, - "description": "آپدیت جزئی داده سنسور. فقط فیلدهای ارسالی به‌روزرسانی می‌شوند." - }, - { - "name": "Add Parameter", - "request": { - "method": "POST", - "header": [ - {"key": "Content-Type", "value": "application/json"}, - {"key": "Accept", "value": "application/json"} - ], - "body": { - "mode": "raw", - "raw": "{\n \"code\": \"soil_moisture\",\n \"name_fa\": \"رطوبت خاک\",\n \"unit\": \"%\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/sensor-data/parameters/", - "host": ["{{baseUrl}}"], - "path": ["api", "sensor-data", "parameters", ""] - } - }, - "description": "اضافه کردن یا ویرایش پارامتر جدید. در ParameterUpdateLog ثبت می‌شود." - } - ] -} diff --git a/sensor_data/serializers.py b/sensor_data/serializers.py deleted file mode 100644 index a6b1a2a..0000000 --- a/sensor_data/serializers.py +++ /dev/null @@ -1,56 +0,0 @@ -from rest_framework import serializers - -from .models import SensorData, SensorParameter - - -class SensorDataUpdateSerializer(serializers.Serializer): - """سریالایزر ورودی برای آپدیت داده سنسور.""" - - location_id = serializers.IntegerField(required=True) - soil_moisture = serializers.FloatField(required=False, allow_null=True) - soil_temperature = serializers.FloatField(required=False, allow_null=True) - soil_ph = serializers.FloatField(required=False, allow_null=True) - electrical_conductivity = serializers.FloatField(required=False, allow_null=True) - nitrogen = serializers.FloatField(required=False, allow_null=True) - phosphorus = serializers.FloatField(required=False, allow_null=True) - potassium = serializers.FloatField(required=False, allow_null=True) - plant_ids = serializers.ListField( - child=serializers.IntegerField(), - required=False, - help_text="لیست شناسه گیاهان مرتبط", - ) - - -class SensorDataResponseSerializer(serializers.ModelSerializer): - """سریالایزر خروجی برای SensorData.""" - - plant_ids = serializers.PrimaryKeyRelatedField( - source="plants", - many=True, - read_only=True, - ) - - class Meta: - model = SensorData - fields = [ - "uuid_sensor", - "location_id", - "soil_moisture", - "soil_temperature", - "soil_ph", - "electrical_conductivity", - "nitrogen", - "phosphorus", - "potassium", - "plant_ids", - "created_at", - "updated_at", - ] - - -class SensorParameterSerializer(serializers.Serializer): - """سریالایزر ورودی برای اضافه کردن پارامتر جدید.""" - - code = serializers.CharField(max_length=64) - name_fa = serializers.CharField(max_length=128) - unit = serializers.CharField(max_length=32, required=False, allow_blank=True) diff --git a/sensor_data/urls.py b/sensor_data/urls.py deleted file mode 100644 index 4d81ff2..0000000 --- a/sensor_data/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.urls import path - -from .views import SensorDataUpdateView, SensorParameterCreateView - -urlpatterns = [ - path( - "/", - SensorDataUpdateView.as_view(), - name="sensor-data-update", - ), - path( - "parameters/", - SensorParameterCreateView.as_view(), - name="sensor-parameter-create", - ), -] diff --git a/sensor_data/views.py b/sensor_data/views.py deleted file mode 100644 index 634f009..0000000 --- a/sensor_data/views.py +++ /dev/null @@ -1,244 +0,0 @@ -from django.db import transaction -from drf_spectacular.utils import ( - OpenApiExample, - OpenApiResponse, - extend_schema, - inline_serializer, -) -from rest_framework import serializers as drf_serializers -from rest_framework import status -from rest_framework.response import Response -from rest_framework.views import APIView - -from config.openapi import build_envelope_serializer, build_response -from location_data.models import SoilLocation - -from .models import ParameterUpdateLog, SensorData, SensorDataHistory, SensorParameter -from .serializers import ( - SensorDataResponseSerializer, - SensorDataUpdateSerializer, - SensorParameterSerializer, -) - - -SensorDataEnvelopeSerializer = build_envelope_serializer( - "SensorDataEnvelopeSerializer", - SensorDataResponseSerializer, -) -SensorDataValidationErrorSerializer = build_envelope_serializer( - "SensorDataValidationErrorSerializer", - data_required=False, - allow_null=True, -) -SensorDataNotFoundSerializer = build_envelope_serializer( - "SensorDataNotFoundSerializer", - data_required=False, - allow_null=True, -) -SensorParameterResponseSerializer = build_envelope_serializer( - "SensorParameterEnvelopeSerializer", - inline_serializer( - name="SensorParameterPayloadSerializer", - fields={ - "id": drf_serializers.IntegerField(), - "code": drf_serializers.CharField(), - "name_fa": drf_serializers.CharField(), - "unit": drf_serializers.CharField(), - "created_at": drf_serializers.DateTimeField(), - "action": drf_serializers.CharField(), - }, - ), -) - - -class SensorDataUpdateView(APIView): - """ - آپدیت داده سنسور. هنگام آپدیت، نسخه فعلی در SensorDataHistory ذخیره می‌شود. - """ - - @extend_schema( - tags=["Sensor Data"], - summary="آپدیت کامل داده سنسور", - description="داده سنسور را بر اساس uuid_sensor آپدیت (یا ایجاد) می‌کند. نسخه قبلی در تاریخچه ذخیره می‌شود.", - request=SensorDataUpdateSerializer, - responses={ - 200: build_response( - SensorDataEnvelopeSerializer, - "داده سنسور با موفقیت ایجاد یا به‌روزرسانی شد.", - ), - 400: build_response( - SensorDataValidationErrorSerializer, - "داده ورودی نامعتبر است.", - ), - 404: build_response( - SensorDataNotFoundSerializer, - "location_id یافت نشد.", - ), - }, - examples=[ - OpenApiExample( - "نمونه درخواست", - value={ - "location_id": 1, - "soil_moisture": 45.2, - "soil_temperature": 22.5, - "soil_ph": 6.8, - "electrical_conductivity": 1.2, - "nitrogen": 30.0, - "phosphorus": 15.0, - "potassium": 20.0, - }, - request_only=True, - ), - ], - ) - def put(self, request, uuid_sensor): - return self._update(request, uuid_sensor) - - @extend_schema( - tags=["Sensor Data"], - summary="آپدیت جزئی داده سنسور", - description="فقط فیلدهای ارسال‌شده آپدیت می‌شوند.", - request=SensorDataUpdateSerializer, - responses={ - 200: build_response( - SensorDataEnvelopeSerializer, - "داده سنسور با موفقیت به‌روزرسانی شد.", - ), - 400: build_response( - SensorDataValidationErrorSerializer, - "داده ورودی نامعتبر است.", - ), - 404: build_response( - SensorDataNotFoundSerializer, - "location_id یافت نشد.", - ), - }, - ) - def patch(self, request, uuid_sensor): - return self._update(request, uuid_sensor, partial=True) - - def _update(self, request, uuid_sensor, partial=False): - serializer = SensorDataUpdateSerializer( - data=request.data, partial=partial - ) - if not serializer.is_valid(): - return Response( - {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, - status=status.HTTP_400_BAD_REQUEST, - ) - - location_id = serializer.validated_data.pop("location_id") - plant_ids = serializer.validated_data.pop("plant_ids", None) - location = SoilLocation.objects.filter(pk=location_id).first() - if not location: - return Response( - {"code": 404, "msg": "location_id یافت نشد.", "data": None}, - status=status.HTTP_404_NOT_FOUND, - ) - - with transaction.atomic(): - sensor_data, created = SensorData.objects.get_or_create( - uuid_sensor=uuid_sensor, - defaults={"location": location, **serializer.validated_data}, - ) - - if not created: - # آپدیت رکورد اصلی - for key, value in serializer.validated_data.items(): - setattr(sensor_data, key, value) - sensor_data.save() - - # ذخیره نسخه جدید (همان مقادیر جدول اصلی) در تاریخچه - SensorDataHistory.objects.create( - uuid_sensor=sensor_data.uuid_sensor, - location_id=sensor_data.location_id, - soil_moisture=sensor_data.soil_moisture, - soil_temperature=sensor_data.soil_temperature, - soil_ph=sensor_data.soil_ph, - electrical_conductivity=sensor_data.electrical_conductivity, - nitrogen=sensor_data.nitrogen, - phosphorus=sensor_data.phosphorus, - potassium=sensor_data.potassium, - ) - - if plant_ids is not None: - sensor_data.plants.set(plant_ids) - - return Response( - { - "code": 200, - "msg": "success", - "data": SensorDataResponseSerializer(sensor_data).data, - }, - status=status.HTTP_200_OK, - ) - - -class SensorParameterCreateView(APIView): - """ - اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog. - """ - - @extend_schema( - tags=["Sensor Parameters"], - summary="افزودن/ویرایش پارامتر سنسور", - description="پارامتر جدید اضافه یا پارامتر موجود را ویرایش می‌کند و در لاگ ثبت می‌شود.", - request=SensorParameterSerializer, - responses={ - 201: build_response( - SensorParameterResponseSerializer, - "پارامتر سنسور با موفقیت ایجاد یا ویرایش شد.", - ), - 400: build_response( - SensorDataValidationErrorSerializer, - "داده ورودی نامعتبر است.", - ), - }, - examples=[ - OpenApiExample( - "نمونه درخواست", - value={"code": "soil_moisture", "name_fa": "رطوبت خاک", "unit": "%"}, - request_only=True, - ), - ], - ) - def post(self, request): - serializer = SensorParameterSerializer(data=request.data) - if not serializer.is_valid(): - return Response( - {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, - status=status.HTTP_400_BAD_REQUEST, - ) - - code = serializer.validated_data["code"] - name_fa = serializer.validated_data["name_fa"] - unit = serializer.validated_data.get("unit", "") - - with transaction.atomic(): - parameter, created = SensorParameter.objects.update_or_create( - code=code, - defaults={"name_fa": name_fa, "unit": unit}, - ) - action = ( - ParameterUpdateLog.ACTION_ADDED - if created - else ParameterUpdateLog.ACTION_MODIFIED - ) - ParameterUpdateLog.objects.create(parameter=parameter, action=action) - - return Response( - { - "code": 201, - "msg": "success", - "data": { - "id": parameter.id, - "code": parameter.code, - "name_fa": parameter.name_fa, - "unit": parameter.unit, - "created_at": parameter.created_at, - "action": action, - }, - }, - status=status.HTTP_201_CREATED, - ) diff --git a/weather/management/commands/seed_weather_data.py b/weather/management/commands/seed_weather_data.py new file mode 100644 index 0000000..14eda1e --- /dev/null +++ b/weather/management/commands/seed_weather_data.py @@ -0,0 +1,89 @@ +""" +Management command to seed fixed weather forecasts for the demo farm location. +Run: python manage.py seed_weather_data +""" +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from location_data.models import SoilLocation +from weather.models import WeatherForecast + + +DEMO_LATITUDE = "50.000000" +DEMO_LONGITUDE = "50.000000" +DEMO_FORECASTS = [ + { + "day_offset": 0, + "temperature_min": 14.0, + "temperature_max": 24.5, + "temperature_mean": 19.3, + "precipitation": 0.0, + "precipitation_probability": 5.0, + "humidity_mean": 48.0, + "wind_speed_max": 12.0, + "et0": 4.2, + "weather_code": 1, + }, + { + "day_offset": 1, + "temperature_min": 13.5, + "temperature_max": 22.0, + "temperature_mean": 17.8, + "precipitation": 2.4, + "precipitation_probability": 60.0, + "humidity_mean": 61.0, + "wind_speed_max": 18.0, + "et0": 3.7, + "weather_code": 61, + }, + { + "day_offset": 2, + "temperature_min": 12.8, + "temperature_max": 20.5, + "temperature_mean": 16.4, + "precipitation": 4.8, + "precipitation_probability": 78.0, + "humidity_mean": 68.0, + "wind_speed_max": 20.0, + "et0": 3.1, + "weather_code": 63, + }, +] + + +class Command(BaseCommand): + help = "Seed weather forecast rows for the fixed 50.00, 50.00 demo location." + + def handle(self, *args, **options): + location, _ = SoilLocation.objects.get_or_create( + latitude=DEMO_LATITUDE, + longitude=DEMO_LONGITUDE, + ) + today = timezone.now().date() + + self.stdout.write( + self.style.SUCCESS( + f"Using SoilLocation id={location.id} at ({location.latitude}, {location.longitude})" + ) + ) + + for item in DEMO_FORECASTS: + forecast_date = today + timedelta(days=item["day_offset"]) + defaults = { + key: value + for key, value in item.items() + if key != "day_offset" + } + _, created = WeatherForecast.objects.update_or_create( + location=location, + forecast_date=forecast_date, + defaults=defaults, + ) + status_text = "Created" if created else "Updated" + self.stdout.write( + self.style.SUCCESS(f" {status_text} WeatherForecast for {forecast_date}") + ) + + self.stdout.write(self.style.SUCCESS("\nDone seeding weather_data demo records."))