diff --git a/config/settings.py b/config/settings.py index 38e8c60..426b45f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -56,8 +56,6 @@ INSTALLED_APPS = [ for optional_app in [ "rest_framework", "corsheaders", - "drf_spectacular", - "drf_spectacular_sidecar", ]: if importlib.util.find_spec(optional_app): INSTALLED_APPS.insert(6, optional_app) @@ -136,34 +134,6 @@ REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.AllowAny", ], - "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", -} - -SPECTACULAR_SETTINGS = { - "TITLE": "CropLogic AI API", - "DESCRIPTION": "API‌های هوش مصنوعی CropLogic — داده خاک، سنسور، هواشناسی و چت RAG", - "VERSION": "1.0.0", - "SERVE_INCLUDE_SCHEMA": False, - "SWAGGER_UI_DIST": "SIDECAR", - "SWAGGER_UI_FAVICON_HREF": "SIDECAR", - "REDOC_DIST": "SIDECAR", - "COMPONENT_SPLIT_REQUEST": True, - "TAGS": [ - {"name": "Dashboard Data", "description": "تجمیع داده‌های داشبورد مزرعه"}, - {"name": "Farm Alerts", "description": "tracker و timeline مستقل هشدارهای مزرعه"}, - {"name": "RAG Chat", "description": "چت هوشمند RAG"}, - {"name": "Soil Data", "description": "داده‌های خاک (SoilGrids)"}, - {"name": "Soile", "description": "heatmap مستقل رطوبت خاک و داده های مزرعه"}, - {"name": "Farm Data", "description": "داده‌های مزرعه و سنسورها"}, - {"name": "Economy", "description": "نمای اقتصادی مستقل مزرعه"}, - {"name": "Farm Parameters", "description": "پارامترهای سنسورهای مزرعه"}, - {"name": "Plant", "description": "مدیریت گیاهان و دریافت اطلاعات گیاه"}, - {"name": "Pest & Disease", "description": "تشخیص تصویری و پیش بینی ریسک آفات و بیماری"}, - {"name": "Crop Simulation", "description": "شبیه سازی رشد و مقایسه سناریوهای گیاه"}, - {"name": "Irrigation", "description": "مدیریت روش‌های آبیاری"}, - {"name": "Irrigation Recommendation", "description": "درخواست و پیگیری توصیه آبیاری"}, - {"name": "Fertilization Recommendation", "description": "درخواست و پیگیری توصیه کودهی"}, - ], } CORS_ALLOW_ALL_ORIGINS = DEBUG @@ -197,6 +167,9 @@ WEATHER_TIMEOUT_SECONDS = float(os.environ.get("WEATHER_TIMEOUT_SECONDS", "60")) SOIL_DATA_PROVIDER = os.environ.get("SOIL_DATA_PROVIDER", "mock").strip().lower() SOIL_MOCK_DELAY_SECONDS = float(os.environ.get("SOIL_MOCK_DELAY_SECONDS", "0.8")) SOILGRIDS_TIMEOUT_SECONDS = float(os.environ.get("SOILGRIDS_TIMEOUT_SECONDS", "60")) +BACKEND_PLANT_SYNC_BASE_URL = os.environ.get("BACKEND_PLANT_SYNC_BASE_URL", "") +BACKEND_PLANT_SYNC_API_KEY = os.environ.get("BACKEND_PLANT_SYNC_API_KEY", "") +BACKEND_PLANT_SYNC_TIMEOUT = int(os.environ.get("BACKEND_PLANT_SYNC_TIMEOUT", "20")) LOGGING = { "version": 1, diff --git a/config/urls.py b/config/urls.py index f31ba76..11e90cb 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,17 +1,8 @@ from django.contrib import admin from django.urls import include, path -from drf_spectacular.views import ( - SpectacularAPIView, - SpectacularRedocView, - SpectacularSwaggerView, -) urlpatterns = [ path("admin/", admin.site.urls), - # --- OpenAPI / Swagger --- - path("api/schema/", SpectacularAPIView.as_view(), name="schema"), - path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), - path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), # --- App APIs --- path("api/rag/", include("rag.urls")), path("api/farm-alerts/", include("farm_alerts.urls")), diff --git a/drf_spectacular/__init__.py b/drf_spectacular/__init__.py new file mode 100644 index 0000000..0adda54 --- /dev/null +++ b/drf_spectacular/__init__.py @@ -0,0 +1,19 @@ +from .types import OpenApiTypes +from .utils import ( + OpenApiExample, + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, + inline_serializer, +) + +__all__ = [ + "OpenApiExample", + "OpenApiParameter", + "OpenApiResponse", + "OpenApiTypes", + "extend_schema", + "extend_schema_view", + "inline_serializer", +] diff --git a/drf_spectacular/types.py b/drf_spectacular/types.py new file mode 100644 index 0000000..15e4ea2 --- /dev/null +++ b/drf_spectacular/types.py @@ -0,0 +1,8 @@ +class OpenApiTypes: + STR = str + INT = int + BOOL = bool + UUID = str + DATE = str + DATETIME = str + OBJECT = dict diff --git a/drf_spectacular/utils.py b/drf_spectacular/utils.py new file mode 100644 index 0000000..71a0048 --- /dev/null +++ b/drf_spectacular/utils.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass + +from rest_framework import serializers + + +@dataclass +class OpenApiExample: + name: str + value: object = None + request_only: bool = False + response_only: bool = False + summary: str | None = None + description: str | None = None + + +@dataclass +class OpenApiResponse: + response: object = None + description: str = "" + + +class OpenApiParameter: + QUERY = "query" + PATH = "path" + HEADER = "header" + + def __init__( + self, + name, + type=None, + location=None, + required=False, + description="", + default=None, + ): + self.name = name + self.type = type + self.location = location + self.required = required + self.description = description + self.default = default + + +def extend_schema(*args, **kwargs): + def decorator(target): + return target + + return decorator + + +def extend_schema_view(**kwargs): + def decorator(target): + return target + + return decorator + + +def inline_serializer(*, name, fields): + serializer_fields = {"__module__": __name__, **fields} + return type(name, (serializers.Serializer,), serializer_fields) diff --git a/drf_spectacular/views.py b/drf_spectacular/views.py new file mode 100644 index 0000000..70d1233 --- /dev/null +++ b/drf_spectacular/views.py @@ -0,0 +1,19 @@ +from django.http import HttpResponseNotFound +from django.views import View + + +class _DisabledSchemaView(View): + def dispatch(self, request, *args, **kwargs): + return HttpResponseNotFound("Schema endpoint is disabled.") + + +class SpectacularAPIView(_DisabledSchemaView): + pass + + +class SpectacularSwaggerView(_DisabledSchemaView): + pass + + +class SpectacularRedocView(_DisabledSchemaView): + pass diff --git a/farm_data/admin.py b/farm_data/admin.py index a3d95c0..bcaec56 100644 --- a/farm_data/admin.py +++ b/farm_data/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import ParameterUpdateLog, SensorData, SensorParameter +from .models import FarmPlantAssignment, ParameterUpdateLog, PlantCatalogSnapshot, SensorData, SensorParameter @admin.register(SensorData) @@ -14,7 +14,6 @@ class SensorDataAdmin(admin.ModelAdmin): ) list_filter = ("updated_at",) search_fields = ("farm_uuid", "center_location_id") - filter_horizontal = ("plants",) @admin.display(description="sensor keys") def sensor_keys(self, obj): @@ -22,6 +21,20 @@ class SensorDataAdmin(admin.ModelAdmin): return ", ".join(payload.keys()) +@admin.register(PlantCatalogSnapshot) +class PlantCatalogSnapshotAdmin(admin.ModelAdmin): + list_display = ("backend_plant_id", "name", "is_active", "source_updated_at", "updated_at") + search_fields = ("backend_plant_id", "name", "slug") + list_filter = ("is_active",) + + +@admin.register(FarmPlantAssignment) +class FarmPlantAssignmentAdmin(admin.ModelAdmin): + list_display = ("farm", "plant", "position", "stage", "updated_at") + search_fields = ("farm__farm_uuid", "plant__name") + list_filter = ("stage",) + + @admin.register(SensorParameter) class SensorParameterAdmin(admin.ModelAdmin): list_display = ("sensor_key", "code", "name_fa", "unit", "data_type", "created_at") diff --git a/farm_data/context.py b/farm_data/context.py index 080db18..3cff705 100644 --- a/farm_data/context.py +++ b/farm_data/context.py @@ -5,10 +5,11 @@ def load_farm_context(sensor_id: str) -> dict | None: from irrigation.models import IrrigationMethod from location_data.models import SoilDepthData from farm_data.models import SensorData + from farm_data.services import get_farm_plant_snapshots from weather.models import WeatherForecast try: - sensor = SensorData.objects.select_related("center_location").prefetch_related("plants").get( + sensor = SensorData.objects.select_related("center_location").prefetch_related("plant_assignments__plant").get( farm_uuid=sensor_id ) except SensorData.DoesNotExist: @@ -19,7 +20,7 @@ def load_farm_context(sensor_id: str) -> dict | None: forecasts = list( WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()).order_by("forecast_date")[:7] ) - plants = list(sensor.plants.all()) + plants = get_farm_plant_snapshots(sensor) irrigation_methods = list(IrrigationMethod.objects.all()[:5]) return { diff --git a/farm_data/management/commands/seed_farm_data.py b/farm_data/management/commands/seed_farm_data.py index 5d01937..8d02b66 100644 --- a/farm_data/management/commands/seed_farm_data.py +++ b/farm_data/management/commands/seed_farm_data.py @@ -6,9 +6,9 @@ from uuid import UUID from django.core.management.base import BaseCommand -from farm_data.models import SensorData +from farm_data.models import PlantCatalogSnapshot, SensorData +from farm_data.services import assign_farm_plants_from_backend_ids from location_data.models import SoilLocation -from plant.models import Plant from weather.models import WeatherForecast @@ -54,9 +54,14 @@ class Command(BaseCommand): "sensor_payload": DEMO_SENSOR_PAYLOAD, }, ) - plants = list(Plant.objects.filter(name__in=DEMO_PLANT_NAMES).order_by("name")) + plants = list( + PlantCatalogSnapshot.objects.filter(name__in=DEMO_PLANT_NAMES).order_by("name") + ) if plants: - farm_data.plants.set(plants) + assign_farm_plants_from_backend_ids( + farm_data, + [plant.backend_plant_id for plant in plants], + ) status_text = "Created" if created else "Updated" weather_text = weather_forecast.id if weather_forecast else "None" diff --git a/farm_data/migrations/0012_plant_catalog_snapshot_and_assignment.py b/farm_data/migrations/0012_plant_catalog_snapshot_and_assignment.py new file mode 100644 index 0000000..7f33a4d --- /dev/null +++ b/farm_data/migrations/0012_plant_catalog_snapshot_and_assignment.py @@ -0,0 +1,70 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0011_sensordata_irrigation_method"), + ] + + operations = [ + migrations.CreateModel( + name="PlantCatalogSnapshot", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("backend_plant_id", models.PositiveIntegerField(db_index=True, help_text="شناسه گیاه در Backend/plants", unique=True)), + ("name", models.CharField(db_index=True, max_length=255)), + ("slug", models.SlugField(blank=True, default="", max_length=255)), + ("icon", models.CharField(blank=True, default="leaf", max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("metadata", models.JSONField(blank=True, default=dict)), + ("light", models.CharField(blank=True, default="", max_length=255)), + ("watering", models.CharField(blank=True, default="", max_length=255)), + ("soil", models.CharField(blank=True, default="", max_length=255)), + ("temperature", models.CharField(blank=True, default="", max_length=255)), + ("growth_stage", models.CharField(blank=True, default="", max_length=255)), + ("growth_stages", models.JSONField(blank=True, default=list)), + ("planting_season", models.CharField(blank=True, default="", max_length=255)), + ("harvest_time", models.CharField(blank=True, default="", max_length=255)), + ("spacing", models.CharField(blank=True, default="", max_length=255)), + ("fertilizer", models.CharField(blank=True, default="", max_length=255)), + ("health_profile", models.JSONField(blank=True, default=dict)), + ("irrigation_profile", models.JSONField(blank=True, default=dict)), + ("growth_profile", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(default=True)), + ("source_updated_at", models.DateTimeField(blank=True, help_text="updated_at رکورد canonical در Backend", null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "plant catalog snapshot", + "verbose_name_plural": "plant catalog snapshots", + "db_table": "farm_data_plantcatalogsnapshot", + "ordering": ["name", "backend_plant_id"], + }, + ), + migrations.CreateModel( + name="FarmPlantAssignment", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("position", models.PositiveIntegerField(default=0)), + ("stage", models.CharField(blank=True, default="", max_length=64)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("assigned_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("farm", models.ForeignKey(db_column="farm_uuid", on_delete=django.db.models.deletion.CASCADE, related_name="plant_assignments", to="sensor_data.sensordata")), + ("plant", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="farm_assignments", to="sensor_data.plantcatalogsnapshot")), + ], + options={ + "verbose_name": "farm plant assignment", + "verbose_name_plural": "farm plant assignments", + "db_table": "farm_data_farmplantassignment", + "ordering": ["position", "id"], + }, + ), + migrations.AddConstraint( + model_name="farmplantassignment", + constraint=models.UniqueConstraint(fields=("farm", "plant"), name="farm_data_unique_farm_plant_assignment"), + ), + ] diff --git a/farm_data/models.py b/farm_data/models.py index 8680cd8..676560f 100644 --- a/farm_data/models.py +++ b/farm_data/models.py @@ -21,21 +21,34 @@ class SensorPayloadMixin: block = payload.get(sensor_key, {}) return block if isinstance(block, dict) else {} - for block in payload.values(): - if isinstance(block, dict): - return block + for _sensor_key, block in self.iter_sensor_blocks(): + return block return {} + def iter_sensor_blocks(self): + for sensor_key, block in self._payload().items(): + if isinstance(block, dict): + yield sensor_key, block + 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: + for _candidate_key, candidate in self.iter_sensor_blocks(): + if metric_name in candidate: return candidate.get(metric_name) return None + def get_sensor_keys(self) -> list[str]: + return [sensor_key for sensor_key, _block in self.iter_sensor_blocks()] + + def get_all_metrics(self) -> dict[str, dict]: + return { + sensor_key: dict(block) + for sensor_key, block in self.iter_sensor_blocks() + } + @property def soil_moisture(self): return self.get_metric("soil_moisture") @@ -151,6 +164,95 @@ class SensorData(SensorPayloadMixin, models.Model): def location_id(self): return self.center_location_id + @property + def plant_snapshots(self): + return [assignment.plant for assignment in self.plant_assignments.select_related("plant").order_by("position", "id")] + + +class PlantCatalogSnapshot(models.Model): + """ + کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژول‌های AI. + """ + + backend_plant_id = models.PositiveIntegerField( + unique=True, + db_index=True, + help_text="شناسه گیاه در Backend/plants", + ) + name = models.CharField(max_length=255, db_index=True) + slug = models.SlugField(max_length=255, blank=True, default="") + icon = models.CharField(max_length=255, blank=True, default="leaf") + description = models.TextField(blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + light = models.CharField(max_length=255, blank=True, default="") + watering = models.CharField(max_length=255, blank=True, default="") + soil = models.CharField(max_length=255, blank=True, default="") + temperature = models.CharField(max_length=255, blank=True, default="") + growth_stage = models.CharField(max_length=255, blank=True, default="") + growth_stages = models.JSONField(blank=True, default=list) + planting_season = models.CharField(max_length=255, blank=True, default="") + harvest_time = models.CharField(max_length=255, blank=True, default="") + spacing = models.CharField(max_length=255, blank=True, default="") + fertilizer = models.CharField(max_length=255, blank=True, default="") + health_profile = models.JSONField(default=dict, blank=True) + irrigation_profile = models.JSONField(default=dict, blank=True) + growth_profile = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True) + source_updated_at = models.DateTimeField( + null=True, + blank=True, + help_text="updated_at رکورد canonical در Backend", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_data_plantcatalogsnapshot" + ordering = ["name", "backend_plant_id"] + verbose_name = "plant catalog snapshot" + verbose_name_plural = "plant catalog snapshots" + + def __str__(self): + return f"{self.name} ({self.backend_plant_id})" + + +class FarmPlantAssignment(models.Model): + """ + رابطه مزرعه با snapshot گیاه برای read-model هوش مصنوعی. + """ + + farm = models.ForeignKey( + SensorData, + on_delete=models.CASCADE, + related_name="plant_assignments", + db_column="farm_uuid", + ) + plant = models.ForeignKey( + PlantCatalogSnapshot, + on_delete=models.CASCADE, + related_name="farm_assignments", + ) + position = models.PositiveIntegerField(default=0) + stage = models.CharField(max_length=64, blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + assigned_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_data_farmplantassignment" + ordering = ["position", "id"] + constraints = [ + models.UniqueConstraint( + fields=["farm", "plant"], + name="farm_data_unique_farm_plant_assignment", + ) + ] + verbose_name = "farm plant assignment" + verbose_name_plural = "farm plant assignments" + + def __str__(self): + return f"{self.farm_id} -> {self.plant_id}" + class SensorParameter(models.Model): """ diff --git a/farm_data/serializers.py b/farm_data/serializers.py index 1a58dc3..c16aa5d 100644 --- a/farm_data/serializers.py +++ b/farm_data/serializers.py @@ -3,10 +3,15 @@ from rest_framework import serializers from location_data.serializers import SoilDepthDataSerializer from irrigation.models import IrrigationMethod from irrigation.serializers import IrrigationMethodSerializer -from plant.serializers import PlantSerializer from weather.models import WeatherForecast -from .models import DEFAULT_SENSOR_DATA_TYPE, DEFAULT_SENSOR_KEY, SensorData +from .models import ( + DEFAULT_SENSOR_DATA_TYPE, + DEFAULT_SENSOR_KEY, + FarmPlantAssignment, + PlantCatalogSnapshot, + SensorData, +) class SensorDataUpdateSerializer(serializers.Serializer): @@ -19,7 +24,7 @@ class SensorDataUpdateSerializer(serializers.Serializer): plant_ids = serializers.ListField( child=serializers.IntegerField(), required=False, - help_text="لیست شناسه گیاهان مرتبط", + help_text="لیست شناسه گیاهان canonical در Backend/plants", ) irrigation_method_id = serializers.IntegerField( required=False, @@ -101,17 +106,16 @@ class SensorDataUpdateSerializer(serializers.Serializer): class SensorDataResponseSerializer(serializers.ModelSerializer): """سریالایزر خروجی برای SensorData.""" - plant_ids = serializers.PrimaryKeyRelatedField( - source="plants", - many=True, - read_only=True, - ) + plant_ids = serializers.SerializerMethodField() irrigation_method_id = serializers.IntegerField( source="irrigation_method.id", read_only=True, allow_null=True, ) + def get_plant_ids(self, obj): + return [plant.backend_plant_id for plant in obj.plant_snapshots] + class Meta: model = SensorData fields = [ @@ -172,13 +176,64 @@ class FarmSoilPayloadSerializer(serializers.Serializer): depths = SoilDepthDataSerializer(many=True) +class PlantCatalogSnapshotSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(source="backend_plant_id", read_only=True) + + class Meta: + model = PlantCatalogSnapshot + fields = [ + "id", + "backend_plant_id", + "name", + "slug", + "icon", + "description", + "metadata", + "light", + "watering", + "soil", + "temperature", + "growth_stage", + "growth_stages", + "planting_season", + "harvest_time", + "spacing", + "fertilizer", + "health_profile", + "irrigation_profile", + "growth_profile", + "is_active", + "source_updated_at", + "updated_at", + ] + + +class FarmPlantAssignmentSerializer(serializers.ModelSerializer): + plant_id = serializers.IntegerField(source="plant.backend_plant_id", read_only=True) + plant = PlantCatalogSnapshotSerializer(read_only=True) + + class Meta: + model = FarmPlantAssignment + fields = [ + "plant_id", + "position", + "stage", + "metadata", + "assigned_at", + "updated_at", + "plant", + ] + + class FarmDetailSerializer(serializers.Serializer): center_location = FarmCenterLocationSerializer() weather = WeatherForecastDetailSerializer(allow_null=True) sensor_payload = serializers.JSONField() + sensor_schema = serializers.JSONField() soil = FarmSoilPayloadSerializer() plant_ids = serializers.ListField(child=serializers.IntegerField()) - plants = PlantSerializer(many=True) + plants = PlantCatalogSnapshotSerializer(many=True) + plant_assignments = FarmPlantAssignmentSerializer(many=True) irrigation_method_id = serializers.IntegerField(allow_null=True) irrigation_method = IrrigationMethodSerializer(allow_null=True) created_at = serializers.DateTimeField() diff --git a/farm_data/services.py b/farm_data/services.py index 75b24e8..391c8d3 100644 --- a/farm_data/services.py +++ b/farm_data/services.py @@ -2,29 +2,400 @@ from __future__ import annotations from decimal import Decimal, ROUND_HALF_UP from numbers import Number +import logging +from django.conf import settings from django.db import transaction +from django.utils.dateparse import parse_datetime + +import requests from location_data.models import SoilLocation from location_data.serializers import SoilDepthDataSerializer from location_data.tasks import fetch_soil_data_for_coordinates from irrigation.serializers import IrrigationMethodSerializer -from plant.serializers import PlantSerializer from weather.services import update_weather_for_location from weather.models import WeatherForecast -from .models import SensorData -from .serializers import WeatherForecastDetailSerializer +from .models import ( + FarmPlantAssignment, + ParameterUpdateLog, + PlantCatalogSnapshot, + SensorData, + SensorParameter, +) +from .serializers import PlantCatalogSnapshotSerializer, WeatherForecastDetailSerializer DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"] DECIMAL_PRECISION = Decimal("0.000001") +logger = logging.getLogger(__name__) class ExternalDataSyncError(Exception): """خطا در همگام‌سازی داده از سرویس‌های بیرونی.""" +class BackendSyncError(Exception): + """خطا در همگام سازی کاتالوگ گیاه و assignmentها از Backend.""" + + +PARAMETER_LABEL_OVERRIDES = { + "soil_moisture": "رطوبت خاک", + "soil_temperature": "دمای خاک", + "soil_ph": "pH خاک", + "electrical_conductivity": "هدایت الکتریکی", + "nitrogen": "نیتروژن", + "phosphorus": "فسفر", + "potassium": "پتاسیم", +} +PARAMETER_UNIT_OVERRIDES = { + "soil_moisture": "%", + "soil_temperature": "°C", + "soil_ph": "", + "electrical_conductivity": "dS/m", + "nitrogen": "mg/kg", + "phosphorus": "mg/kg", + "potassium": "mg/kg", +} + + +def get_backend_plant_base_url() -> str: + return getattr(settings, "BACKEND_PLANT_SYNC_BASE_URL", "").rstrip("/") + + +def get_backend_plant_timeout() -> int: + return int(getattr(settings, "BACKEND_PLANT_SYNC_TIMEOUT", 20)) + + +def get_backend_plant_headers() -> dict[str, str]: + headers = {"Accept": "application/json"} + api_key = getattr(settings, "BACKEND_PLANT_SYNC_API_KEY", "").strip() + if api_key: + headers["X-API-Key"] = api_key + headers["Authorization"] = f"Api-Key {api_key}" + return headers + + +def _extract_envelope_list(payload): + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + data = payload.get("data") + if isinstance(data, list): + return data + result = payload.get("result") + if isinstance(result, list): + return result + if isinstance(data, dict) and isinstance(data.get("result"), list): + return data["result"] + return [] + + +def _normalize_growth_stages(item: dict) -> list[str]: + stages = item.get("growth_stages") + if isinstance(stages, list): + return [str(stage).strip() for stage in stages if str(stage).strip()] + + growth_stage = str(item.get("growth_stage") or "").strip() + if not growth_stage: + return [] + return [part.strip() for part in growth_stage.replace("،", ",").split(",") if part.strip()] + + +def _snapshot_defaults_from_payload(item: dict) -> dict: + source_updated_at = parse_datetime(str(item.get("updated_at") or "").strip()) if item.get("updated_at") else None + return { + "name": str(item.get("name") or "").strip(), + "slug": str(item.get("slug") or "").strip(), + "icon": str(item.get("icon") or "leaf").strip() or "leaf", + "description": str(item.get("description") or "").strip(), + "metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {}, + "light": str(item.get("light") or "").strip(), + "watering": str(item.get("watering") or "").strip(), + "soil": str(item.get("soil") or "").strip(), + "temperature": str(item.get("temperature") or "").strip(), + "growth_stage": str(item.get("growth_stage") or "").strip(), + "growth_stages": _normalize_growth_stages(item), + "planting_season": str(item.get("planting_season") or "").strip(), + "harvest_time": str(item.get("harvest_time") or "").strip(), + "spacing": str(item.get("spacing") or "").strip(), + "fertilizer": str(item.get("fertilizer") or "").strip(), + "health_profile": item.get("health_profile") if isinstance(item.get("health_profile"), dict) else {}, + "irrigation_profile": item.get("irrigation_profile") if isinstance(item.get("irrigation_profile"), dict) else {}, + "growth_profile": item.get("growth_profile") if isinstance(item.get("growth_profile"), dict) else {}, + "is_active": bool(item.get("is_active", True)), + "source_updated_at": source_updated_at, + } + + +def sync_plant_catalog_from_backend(plant_payloads: list[dict] | None = None) -> list[PlantCatalogSnapshot]: + if plant_payloads is None: + base_url = get_backend_plant_base_url() + if not base_url: + raise BackendSyncError("BACKEND_PLANT_SYNC_BASE_URL is not configured.") + try: + response = requests.get( + f"{base_url}/api/plants/", + headers=get_backend_plant_headers(), + timeout=get_backend_plant_timeout(), + ) + except requests.RequestException as exc: + raise BackendSyncError(f"Backend plant catalog request failed: {exc}") from exc + if response.status_code >= 400: + raise BackendSyncError(f"Backend plant catalog returned status {response.status_code}.") + plant_payloads = _extract_envelope_list(response.json()) + + snapshots: list[PlantCatalogSnapshot] = [] + with transaction.atomic(): + for item in plant_payloads or []: + if not isinstance(item, dict): + continue + plant_id = item.get("id") or item.get("backend_plant_id") + if plant_id in (None, ""): + continue + snapshot, _ = PlantCatalogSnapshot.objects.update_or_create( + backend_plant_id=int(plant_id), + defaults=_snapshot_defaults_from_payload(item), + ) + snapshots.append(snapshot) + return snapshots + + +def assign_farm_plants_from_backend_ids(farm: SensorData, backend_plant_ids: list[int] | None) -> list[PlantCatalogSnapshot]: + if backend_plant_ids is None: + return list(get_farm_plant_snapshots(farm)) + + normalized_ids = [int(plant_id) for plant_id in backend_plant_ids] + snapshots = list(PlantCatalogSnapshot.objects.filter(backend_plant_id__in=normalized_ids)) + snapshot_by_backend_id = {snapshot.backend_plant_id: snapshot for snapshot in snapshots} + missing_ids = [plant_id for plant_id in normalized_ids if plant_id not in snapshot_by_backend_id] + if missing_ids: + raise BackendSyncError( + "Plant catalog snapshot missing for backend ids: " + + ", ".join(str(plant_id) for plant_id in missing_ids) + ) + + with transaction.atomic(): + FarmPlantAssignment.objects.filter(farm=farm).exclude( + plant__backend_plant_id__in=normalized_ids + ).delete() + for position, backend_plant_id in enumerate(normalized_ids): + FarmPlantAssignment.objects.update_or_create( + farm=farm, + plant=snapshot_by_backend_id[backend_plant_id], + defaults={"position": position}, + ) + return [snapshot_by_backend_id[backend_plant_id] for backend_plant_id in normalized_ids] + + +def get_farm_plant_assignments(farm: SensorData) -> list[FarmPlantAssignment]: + return list( + farm.plant_assignments.select_related("plant").order_by("position", "id") + ) + + +def get_farm_plant_snapshots(farm: SensorData) -> list[PlantCatalogSnapshot]: + return [assignment.plant for assignment in get_farm_plant_assignments(farm)] + + +def get_primary_plant_snapshot(farm: SensorData) -> PlantCatalogSnapshot | None: + assignments = get_farm_plant_assignments(farm) + return assignments[0].plant if assignments else None + + +def get_farm_plant_snapshot_by_name( + farm: SensorData, + plant_name: str | None, +) -> PlantCatalogSnapshot | None: + normalized_name = str(plant_name or "").strip().lower() + if not normalized_name: + return get_primary_plant_snapshot(farm) + for assignment in get_farm_plant_assignments(farm): + if assignment.plant.name.strip().lower() == normalized_name: + return assignment.plant + return get_primary_plant_snapshot(farm) + + +def clone_snapshot_as_runtime_plant( + snapshot: PlantCatalogSnapshot | None, + *, + growth_stage: str | None = None, +): + if snapshot is None: + return None + + class RuntimePlant: + pass + + runtime = RuntimePlant() + for field_name in ( + "backend_plant_id", + "name", + "slug", + "icon", + "description", + "metadata", + "light", + "watering", + "soil", + "temperature", + "growth_stage", + "growth_stages", + "planting_season", + "harvest_time", + "spacing", + "fertilizer", + "health_profile", + "irrigation_profile", + "growth_profile", + "is_active", + ): + setattr(runtime, field_name, getattr(snapshot, field_name)) + if growth_stage: + runtime.growth_stage = growth_stage + runtime.id = snapshot.backend_plant_id + return runtime + + +def build_plant_text_from_snapshot( + plant: PlantCatalogSnapshot | None, + growth_stage: str, +) -> str | None: + if plant is None: + return None + + lines = [ + f"نام گیاه: {plant.name}", + f"مرحله رشد: {growth_stage}", + ] + if plant.light: + lines.append(f"نور مورد نیاز: {plant.light}") + if plant.watering: + lines.append(f"آبیاری: {plant.watering}") + if plant.soil: + lines.append(f"خاک مناسب: {plant.soil}") + if plant.temperature: + lines.append(f"دمای مناسب: {plant.temperature}") + if plant.planting_season: + lines.append(f"فصل کاشت: {plant.planting_season}") + if plant.harvest_time: + lines.append(f"زمان برداشت: {plant.harvest_time}") + if plant.spacing: + lines.append(f"فاصله کاشت: {plant.spacing}") + if plant.fertilizer: + lines.append(f"کود مناسب: {plant.fertilizer}") + return "\n".join(lines) + + +def build_farm_plant_context(farm_uuid: str) -> dict | None: + farm = ( + SensorData.objects.select_related( + "center_location", + "weather_forecast", + "irrigation_method", + ) + .prefetch_related("plant_assignments__plant", "center_location__depths") + .filter(farm_uuid=farm_uuid) + .first() + ) + if farm is None: + return None + assignments = get_farm_plant_assignments(farm) + snapshots = [assignment.plant for assignment in assignments] + return { + "farm": farm, + "plant_ids": [plant.backend_plant_id for plant in snapshots], + "plants": PlantCatalogSnapshotSerializer(snapshots, many=True).data, + "plant_snapshots": snapshots, + "plant_assignments": assignments, + "primary_plant": snapshots[0] if snapshots else None, + } + + +def infer_sensor_parameter_data_type(value: object) -> str: + if isinstance(value, bool): + return "bool" + if isinstance(value, int) and not isinstance(value, bool): + return "int" + if isinstance(value, float): + return "float" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "list" + if isinstance(value, dict): + return "object" + return "string" + + +def build_parameter_defaults(sensor_key: str, code: str, value: object) -> dict[str, object]: + return { + "name_fa": PARAMETER_LABEL_OVERRIDES.get(code) or code.replace("_", " ").strip(), + "unit": PARAMETER_UNIT_OVERRIDES.get(code, ""), + "data_type": infer_sensor_parameter_data_type(value), + "metadata": { + "source": "auto_discovered", + "sensor_key": sensor_key, + "code": code, + }, + } + + +def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[SensorParameter]: + if not isinstance(sensor_payload, dict): + return [] + + synced_parameters: list[SensorParameter] = [] + with transaction.atomic(): + for sensor_key, sensor_values in sensor_payload.items(): + if not isinstance(sensor_values, dict): + continue + for code, value in sensor_values.items(): + defaults = build_parameter_defaults(sensor_key, code, value) + parameter, created = SensorParameter.objects.get_or_create( + sensor_key=sensor_key, + code=code, + defaults=defaults, + ) + if created: + ParameterUpdateLog.objects.create( + parameter=parameter, + action=ParameterUpdateLog.ACTION_ADDED, + 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, + "source": "farm_data_auto_sync", + }, + ) + synced_parameters.append(parameter) + return synced_parameters + + +def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]: + parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code") + if sensor_payload and isinstance(sensor_payload, dict): + parameter_queryset = parameter_queryset.filter(sensor_key__in=list(sensor_payload.keys())) + + catalog: dict[str, list[dict[str, object]]] = {} + for parameter in parameter_queryset: + catalog.setdefault(parameter.sensor_key, []).append( + { + "code": parameter.code, + "name_fa": parameter.name_fa, + "unit": parameter.unit, + "data_type": parameter.data_type, + "metadata": parameter.metadata, + } + ) + return catalog + + def get_farm_details(farm_uuid: str): farm = ( SensorData.objects.select_related( @@ -32,13 +403,15 @@ def get_farm_details(farm_uuid: str): "weather_forecast", "irrigation_method", ) - .prefetch_related("plants", "center_location__depths") + .prefetch_related("plant_assignments__plant", "center_location__depths") .filter(farm_uuid=farm_uuid) .first() ) if farm is None: return None + sync_sensor_parameters_from_payload(farm.sensor_payload) + center_location = farm.center_location weather = farm.weather_forecast if weather is None: @@ -58,6 +431,9 @@ def get_farm_details(farm_uuid: str): resolved_metrics[key] = value metric_sources[key] = sensor_metric_sources[key] + plant_assignments = get_farm_plant_assignments(farm) + plant_snapshots = [assignment.plant for assignment in plant_assignments] + return { "center_location": { "id": center_location.id, @@ -67,13 +443,26 @@ def get_farm_details(farm_uuid: str): }, "weather": WeatherForecastDetailSerializer(weather).data if weather else None, "sensor_payload": farm.sensor_payload or {}, + "sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload), "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, + "plant_ids": [plant.backend_plant_id for plant in plant_snapshots], + "plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data, + "plant_assignments": [ + { + "plant_id": assignment.plant.backend_plant_id, + "position": assignment.position, + "stage": assignment.stage, + "metadata": assignment.metadata, + "assigned_at": assignment.assigned_at, + "updated_at": assignment.updated_at, + "plant": PlantCatalogSnapshotSerializer(assignment.plant).data, + } + for assignment in plant_assignments + ], "irrigation_method_id": farm.irrigation_method_id, "irrigation_method": ( IrrigationMethodSerializer(farm.irrigation_method).data diff --git a/farm_data/tests/test_farm_detail_api.py b/farm_data/tests/test_farm_detail_api.py index bf83cb6..9af6a65 100644 --- a/farm_data/tests/test_farm_detail_api.py +++ b/farm_data/tests/test_farm_detail_api.py @@ -6,9 +6,9 @@ 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 farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter +from farm_data.services import assign_farm_plants_from_backend_ids from irrigation.models import IrrigationMethod -from plant.models import Plant from weather.models import WeatherForecast from farm_data.services import resolve_center_location_from_boundary @@ -59,8 +59,8 @@ class FarmDetailApiTests(TestCase): precipitation=1.2, humidity_mean=52.0, ) - self.plant1 = Plant.objects.create(name="گوجه‌فرنگی") - self.plant2 = Plant.objects.create(name="خیار") + self.plant1 = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجه‌فرنگی") + self.plant2 = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="خیار") self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") self.farm_uuid = uuid.uuid4() self.farm = SensorData.objects.create( @@ -75,7 +75,7 @@ class FarmDetailApiTests(TestCase): } }, ) - self.farm.plants.set([self.plant2, self.plant1]) + assign_farm_plants_from_backend_ids(self.farm, [self.plant2.backend_plant_id, self.plant1.backend_plant_id]) def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self): response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") @@ -90,6 +90,8 @@ class FarmDetailApiTests(TestCase): payload["sensor_payload"]["sensor-7-1"]["soil_moisture"], 33.5, ) + self.assertIn("sensor_schema", payload) + self.assertEqual(payload["sensor_schema"]["sensor-7-1"][0]["code"], "nitrogen") resolved_metrics = payload["soil"]["resolved_metrics"] metric_sources = payload["soil"]["metric_sources"] @@ -100,12 +102,13 @@ class FarmDetailApiTests(TestCase): 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.assertCountEqual(payload["plant_ids"], [self.plant1.backend_plant_id, self.plant2.backend_plant_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]) + self.assertEqual(returned_plants[self.plant1.backend_plant_id]["name"], self.plant1.name) + self.assertEqual(returned_plants[self.plant2.backend_plant_id]["name"], self.plant2.name) + self.assertIn("light", returned_plants[self.plant1.backend_plant_id]) + self.assertEqual(len(payload["plant_assignments"]), 2) self.assertEqual(payload["irrigation_method_id"], self.irrigation_method.id) self.assertEqual(payload["irrigation_method"]["name"], self.irrigation_method.name) @@ -147,6 +150,28 @@ class FarmDetailApiTests(TestCase): self.assertEqual(resolved_metrics["status"], ["ok", "needs-check"]) self.assertEqual(metric_sources["status"]["strategy"], "distinct_values") + def test_detail_auto_registers_unknown_sensor_parameters(self): + self.farm.sensor_payload = { + "leaf-sensor": { + "leaf_wetness": 11.0, + "leaf_temperature": 19.8, + } + } + self.farm.save(update_fields=["sensor_payload"]) + + response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + leaf_schema = payload["sensor_schema"]["leaf-sensor"] + self.assertCountEqual( + [item["code"] for item in leaf_schema], + ["leaf_temperature", "leaf_wetness"], + ) + self.assertTrue( + SensorParameter.objects.filter(sensor_key="leaf-sensor", code="leaf_wetness").exists() + ) + class FarmDataUpsertApiTests(TestCase): def setUp(self): @@ -212,6 +237,43 @@ class FarmDataUpsertApiTests(TestCase): farm.sensor_payload["sensor-7-1"]["soil_moisture"], 31.2, ) + self.assertTrue( + SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists() + ) + + def test_post_auto_registers_new_sensor_without_manual_parameter_creation(self): + farm_uuid = uuid.uuid4() + + response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": self.boundary, + "sensor_payload": { + "canopy-sensor-v2": { + "leaf_wetness": 12.4, + "leaf_temperature": 21.6, + "disease_pressure_index": 0.41, + } + }, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + SensorParameter.objects.filter( + sensor_key="canopy-sensor-v2", + code="leaf_wetness", + ).exists() + ) + detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/") + self.assertEqual(detail_response.status_code, 200) + schema = detail_response.json()["data"]["sensor_schema"]["canopy-sensor-v2"] + self.assertCountEqual( + [item["code"] for item in schema], + ["disease_pressure_index", "leaf_temperature", "leaf_wetness"], + ) def test_post_requires_farm_uuid_in_request_body(self): response = self.client.post( diff --git a/farm_data/urls.py b/farm_data/urls.py index f6292d7..7391984 100644 --- a/farm_data/urls.py +++ b/farm_data/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .views import FarmDetailView, FarmDataUpsertView, SensorParameterCreateView +from .views import FarmDetailView, FarmDataUpsertView, PlantCatalogSyncView, SensorParameterCreateView urlpatterns = [ path( @@ -18,4 +18,9 @@ urlpatterns = [ SensorParameterCreateView.as_view(), name="farm-parameter-create", ), + path( + "plants/sync/", + PlantCatalogSyncView.as_view(), + name="farm-data-plant-sync", + ), ] diff --git a/farm_data/views.py b/farm_data/views.py index d53321d..2a36ccf 100644 --- a/farm_data/views.py +++ b/farm_data/views.py @@ -21,10 +21,14 @@ from .serializers import ( SensorParameterSerializer, ) from .services import ( + BackendSyncError, + assign_farm_plants_from_backend_ids, ExternalDataSyncError, ensure_location_and_weather_data, get_farm_details, resolve_center_location_from_boundary, + sync_sensor_parameters_from_payload, + sync_plant_catalog_from_backend, ) @@ -188,6 +192,7 @@ class FarmDataUpsertView(APIView): ) with transaction.atomic(): + sync_sensor_parameters_from_payload(sensor_payload) farm_data, created = SensorData.objects.get_or_create( farm_uuid=farm_uuid, defaults={ @@ -227,7 +232,13 @@ class FarmDataUpsertView(APIView): farm_data.save() if plant_ids is not None: - farm_data.plants.set(plant_ids) + try: + assign_farm_plants_from_backend_ids(farm_data, plant_ids) + except BackendSyncError as exc: + return Response( + {"code": 400, "msg": str(exc), "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) response_status = ( status.HTTP_201_CREATED if created else status.HTTP_200_OK @@ -276,6 +287,51 @@ class FarmDetailView(APIView): ) +class PlantCatalogSyncView(APIView): + @extend_schema( + tags=["Farm Data"], + summary="همگام‌سازی کاتالوگ گیاه از Backend", + description="payload گیاه‌های canonical را از Backend دریافت و در `farm_data` snapshot می‌کند.", + request=drf_serializers.ListSerializer( + child=inline_serializer( + name="PlantCatalogSyncItem", + fields={ + "id": drf_serializers.IntegerField(), + "name": drf_serializers.CharField(), + }, + ) + ), + responses={ + 200: OpenApiResponse(description="کاتالوگ گیاه با موفقیت sync شد."), + 400: OpenApiResponse(description="payload نامعتبر است."), + }, + ) + def post(self, request): + if not isinstance(request.data, list): + return Response( + {"code": 400, "msg": "payload باید آرایه‌ای از گیاه‌ها باشد.", "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + snapshots = sync_plant_catalog_from_backend(request.data) + except BackendSyncError as exc: + return Response( + {"code": 400, "msg": str(exc), "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response( + { + "code": 200, + "msg": "success", + "data": { + "count": len(snapshots), + "plant_ids": [snapshot.backend_plant_id for snapshot in snapshots], + }, + }, + status=status.HTTP_200_OK, + ) + + class SensorParameterCreateView(APIView): """ اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog. diff --git a/integration_tests/test_management_api_flow.py b/integration_tests/test_management_api_flow.py index fda673f..79c33fa 100644 --- a/integration_tests/test_management_api_flow.py +++ b/integration_tests/test_management_api_flow.py @@ -177,6 +177,9 @@ class FarmManagementJourneyTests(IntegrationAPITestCase): self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["soil_moisture"], 44.0) self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["nitrogen"], 19.5) self.assertEqual(farm_record.sensor_payload["leaf-sensor"]["leaf_wetness"], 11.0) + self.assertTrue( + SensorParameter.objects.filter(sensor_key="leaf-sensor", code="leaf_wetness").exists() + ) farm_detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/") self.assertEqual(farm_detail_response.status_code, 200) @@ -189,3 +192,7 @@ class FarmManagementJourneyTests(IntegrationAPITestCase): farm_detail["sensor_payload"]["leaf-sensor"]["leaf_temperature"], 21.3, ) + self.assertCountEqual( + [item["code"] for item in farm_detail["sensor_schema"]["leaf-sensor"]], + ["leaf_temperature", "leaf_wetness"], + ) diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py index 990f120..cc61870 100644 --- a/rag/services/fertilization.py +++ b/rag/services/fertilization.py @@ -13,6 +13,7 @@ from typing import Any from django.apps import apps from farm_data.models import SensorData +from farm_data.services import clone_snapshot_as_runtime_plant, get_farm_plant_snapshot_by_name from rag.api_provider import get_chat_client from rag.chat import ( _complete_audit_log, @@ -623,7 +624,7 @@ def get_fertilization_recommendation( sensor = ( SensorData.objects.select_related("center_location") - .prefetch_related("plants") + .prefetch_related("plant_assignments__plant") .filter(farm_uuid=resolved_farm_uuid) .first() ) @@ -635,20 +636,14 @@ def get_fertilization_recommendation( resolved_growth_stage = plant_config.resolve_growth_stage(growth_stage) plant = None - if not resolved_plant_name and sensor is not None: - plant = sensor.plants.first() - if plant is not None: - resolved_plant_name = plant.name - elif resolved_plant_name: - if sensor is not None: - plant = sensor.plants.filter(name=resolved_plant_name).first() - if plant is None: - Plant = apps.get_model("plant", "Plant") - plant = Plant.objects.filter(name=resolved_plant_name).first() - if plant is None and sensor is not None: - plant = sensor.plants.first() - if plant is not None: - resolved_plant_name = plant.name + if sensor is not None: + selected_snapshot = get_farm_plant_snapshot_by_name(sensor, resolved_plant_name) + plant = clone_snapshot_as_runtime_plant( + selected_snapshot, + growth_stage=resolved_growth_stage, + ) + if selected_snapshot is not None: + resolved_plant_name = selected_snapshot.name forecasts = [] optimized_result = None diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py index 94e8087..a0b6b7d 100644 --- a/rag/services/irrigation.py +++ b/rag/services/irrigation.py @@ -10,6 +10,10 @@ from django.apps import apps from django.db import transaction from farm_data.models import SensorData +from farm_data.services import ( + clone_snapshot_as_runtime_plant, + get_farm_plant_snapshot_by_name, +) from irrigation.evapotranspiration import ( calculate_forecast_water_needs, resolve_crop_profile, @@ -372,7 +376,7 @@ def get_irrigation_recommendation( sensor = ( SensorData.objects.select_related("center_location", "irrigation_method") - .prefetch_related("plants") + .prefetch_related("plant_assignments__plant") .filter(farm_uuid=resolved_farm_uuid) .first() ) @@ -381,12 +385,16 @@ def get_irrigation_recommendation( plant = None resolved_plant_name = plant_name - if sensor is not None and plant_name: - plant = sensor.plants.filter(name=plant_name).first() - elif sensor is not None: - plant = sensor.plants.first() - if plant is not None: - resolved_plant_name = plant.name + if sensor is not None: + selected_snapshot = get_farm_plant_snapshot_by_name(sensor, plant_name) + plant = clone_snapshot_as_runtime_plant( + selected_snapshot, + growth_stage=growth_stage, + ) + if selected_snapshot is not None: + resolved_plant_name = selected_snapshot.name + elif plant_name: + resolved_plant_name = plant_name crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage) active_kc = resolve_kc(crop_profile, growth_stage=growth_stage) diff --git a/rag/user_data.py b/rag/user_data.py index 4c2eca3..ef87583 100644 --- a/rag/user_data.py +++ b/rag/user_data.py @@ -166,36 +166,16 @@ def load_user_sources() -> list[tuple[str, str]]: def build_plant_text(plant_name: str, growth_stage: str) -> str | None: """ - ساخت متن اطلاعات گیاه از جدول Plant برای استفاده در context LLM. + ساخت متن اطلاعات گیاه از snapshotهای `farm_data` برای استفاده در context LLM. """ - from plant.models import Plant + from farm_data.models import PlantCatalogSnapshot + from farm_data.services import build_plant_text_from_snapshot - plant = Plant.objects.filter(name=plant_name).first() + plant = PlantCatalogSnapshot.objects.filter(name=plant_name).first() if not plant: return None - lines = [ - f"نام گیاه: {plant.name}", - f"مرحله رشد: {growth_stage}", - ] - if plant.light: - lines.append(f"نور مورد نیاز: {plant.light}") - if plant.watering: - lines.append(f"آبیاری: {plant.watering}") - if plant.soil: - lines.append(f"خاک مناسب: {plant.soil}") - if plant.temperature: - lines.append(f"دمای مناسب: {plant.temperature}") - if plant.planting_season: - lines.append(f"فصل کاشت: {plant.planting_season}") - if plant.harvest_time: - lines.append(f"زمان برداشت: {plant.harvest_time}") - if plant.spacing: - lines.append(f"فاصله کاشت: {plant.spacing}") - if plant.fertilizer: - lines.append(f"کود مناسب: {plant.fertilizer}") - - return "\n".join(lines) + return build_plant_text_from_snapshot(plant, growth_stage) def build_irrigation_method_text(method_name: str) -> str | None: diff --git a/requirements.txt b/requirements.txt index 1022d47..4740cb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,8 +11,6 @@ mysqlclient>=2.2,<2.3 gunicorn>=22,<23 # === API Docs === -drf-spectacular>=0.27,<0.28 -drf-spectacular-sidecar>=2024.7.1,<2025 # === Config === python-dotenv>=1.0,<1.1 diff --git a/soile/services.py b/soile/services.py index 4939107..2335090 100644 --- a/soile/services.py +++ b/soile/services.py @@ -193,10 +193,17 @@ 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("center_location").prefetch_related("plants", "center_location__depths") + plant_ids = list( + current_sensor.plant_assignments.values_list("plant__backend_plant_id", flat=True) + ) + queryset = SensorData.objects.select_related("center_location").prefetch_related( + "plant_assignments__plant", + "center_location__depths", + ) if plant_ids: - queryset = queryset.filter(plants__id__in=plant_ids).distinct() + queryset = queryset.filter( + plant_assignments__plant__backend_plant_id__in=plant_ids + ).distinct() return list(queryset) @@ -270,7 +277,7 @@ class SoilMoistureHeatmapService: def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]: current_sensor = ( SensorData.objects.select_related("center_location") - .prefetch_related("plants", "center_location__depths") + .prefetch_related("plant_assignments__plant", "center_location__depths") .filter(farm_uuid=farm_uuid) .first() ) @@ -438,7 +445,7 @@ class SoilHealthService: def get_health_summary(self, *, farm_uuid: str) -> dict[str, Any]: sensor = ( SensorData.objects.select_related("center_location") - .prefetch_related("plants") + .prefetch_related("plant_assignments__plant") .filter(farm_uuid=farm_uuid) .first() ) @@ -446,7 +453,10 @@ class SoilHealthService: raise ValueError("Farm not found.") return { "farm_uuid": str(sensor.farm_uuid), - **build_soil_health_summary(sensor, list(sensor.plants.all())), + **build_soil_health_summary( + sensor, + list(sensor.plant_snapshots), + ), } diff --git a/weather/water_need_prediction.py b/weather/water_need_prediction.py index dc7f285..285193d 100644 --- a/weather/water_need_prediction.py +++ b/weather/water_need_prediction.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Any +from farm_data.services import clone_snapshot_as_runtime_plant, get_primary_plant_snapshot from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile from farm_data.models import SensorData @@ -12,8 +13,7 @@ from .services import get_forecast_for_location def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) -> dict[str, Any]: location = getattr(sensor, "center_location", None) - plants = list(sensor.plants.all()) if hasattr(sensor, "plants") else [] - plant = plants[0] if plants else None + plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor)) irrigation_method = getattr(sensor, "irrigation_method", None) if not forecasts or location is None: @@ -53,7 +53,7 @@ class WaterNeedPredictionService: def get_water_need_prediction(self, *, farm_uuid: str) -> dict[str, Any]: sensor = ( SensorData.objects.select_related("center_location", "irrigation_method") - .prefetch_related("plants") + .prefetch_related("plant_assignments__plant") .filter(farm_uuid=farm_uuid) .first() )