This commit is contained in:
2026-05-05 01:46:10 +03:30
parent 2016aa2058
commit 5301071df5
23 changed files with 962 additions and 136 deletions
+3 -30
View File
@@ -56,8 +56,6 @@ INSTALLED_APPS = [
for optional_app in [ for optional_app in [
"rest_framework", "rest_framework",
"corsheaders", "corsheaders",
"drf_spectacular",
"drf_spectacular_sidecar",
]: ]:
if importlib.util.find_spec(optional_app): if importlib.util.find_spec(optional_app):
INSTALLED_APPS.insert(6, optional_app) INSTALLED_APPS.insert(6, optional_app)
@@ -136,34 +134,6 @@ REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.AllowAny", "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 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_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")) 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")) 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 = { LOGGING = {
"version": 1, "version": 1,
-9
View File
@@ -1,17 +1,8 @@
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), 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 --- # --- App APIs ---
path("api/rag/", include("rag.urls")), path("api/rag/", include("rag.urls")),
path("api/farm-alerts/", include("farm_alerts.urls")), path("api/farm-alerts/", include("farm_alerts.urls")),
+19
View File
@@ -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",
]
+8
View File
@@ -0,0 +1,8 @@
class OpenApiTypes:
STR = str
INT = int
BOOL = bool
UUID = str
DATE = str
DATETIME = str
OBJECT = dict
+60
View File
@@ -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)
+19
View File
@@ -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
+15 -2
View File
@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import ParameterUpdateLog, SensorData, SensorParameter from .models import FarmPlantAssignment, ParameterUpdateLog, PlantCatalogSnapshot, SensorData, SensorParameter
@admin.register(SensorData) @admin.register(SensorData)
@@ -14,7 +14,6 @@ class SensorDataAdmin(admin.ModelAdmin):
) )
list_filter = ("updated_at",) list_filter = ("updated_at",)
search_fields = ("farm_uuid", "center_location_id") search_fields = ("farm_uuid", "center_location_id")
filter_horizontal = ("plants",)
@admin.display(description="sensor keys") @admin.display(description="sensor keys")
def sensor_keys(self, obj): def sensor_keys(self, obj):
@@ -22,6 +21,20 @@ class SensorDataAdmin(admin.ModelAdmin):
return ", ".join(payload.keys()) 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) @admin.register(SensorParameter)
class SensorParameterAdmin(admin.ModelAdmin): class SensorParameterAdmin(admin.ModelAdmin):
list_display = ("sensor_key", "code", "name_fa", "unit", "data_type", "created_at") list_display = ("sensor_key", "code", "name_fa", "unit", "data_type", "created_at")
+3 -2
View File
@@ -5,10 +5,11 @@ def load_farm_context(sensor_id: str) -> dict | None:
from irrigation.models import IrrigationMethod from irrigation.models import IrrigationMethod
from location_data.models import SoilDepthData from location_data.models import SoilDepthData
from farm_data.models import SensorData from farm_data.models import SensorData
from farm_data.services import get_farm_plant_snapshots
from weather.models import WeatherForecast from weather.models import WeatherForecast
try: 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 farm_uuid=sensor_id
) )
except SensorData.DoesNotExist: except SensorData.DoesNotExist:
@@ -19,7 +20,7 @@ def load_farm_context(sensor_id: str) -> dict | None:
forecasts = list( forecasts = list(
WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()).order_by("forecast_date")[:7] 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]) irrigation_methods = list(IrrigationMethod.objects.all()[:5])
return { return {
@@ -6,9 +6,9 @@ from uuid import UUID
from django.core.management.base import BaseCommand 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 location_data.models import SoilLocation
from plant.models import Plant
from weather.models import WeatherForecast from weather.models import WeatherForecast
@@ -54,9 +54,14 @@ class Command(BaseCommand):
"sensor_payload": DEMO_SENSOR_PAYLOAD, "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: 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" status_text = "Created" if created else "Updated"
weather_text = weather_forecast.id if weather_forecast else "None" weather_text = weather_forecast.id if weather_forecast else "None"
@@ -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"),
),
]
+107 -5
View File
@@ -21,21 +21,34 @@ class SensorPayloadMixin:
block = payload.get(sensor_key, {}) block = payload.get(sensor_key, {})
return block if isinstance(block, dict) else {} return block if isinstance(block, dict) else {}
for block in payload.values(): for _sensor_key, block in self.iter_sensor_blocks():
if isinstance(block, dict): return block
return block
return {} 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): def get_metric(self, metric_name: str, sensor_key: str | None = None):
block = self.get_sensor_block(sensor_key) block = self.get_sensor_block(sensor_key)
if metric_name in block: if metric_name in block:
return block.get(metric_name) return block.get(metric_name)
for candidate in self._payload().values(): for _candidate_key, candidate in self.iter_sensor_blocks():
if isinstance(candidate, dict) and metric_name in candidate: if metric_name in candidate:
return candidate.get(metric_name) return candidate.get(metric_name)
return None 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 @property
def soil_moisture(self): def soil_moisture(self):
return self.get_metric("soil_moisture") return self.get_metric("soil_moisture")
@@ -151,6 +164,95 @@ class SensorData(SensorPayloadMixin, models.Model):
def location_id(self): def location_id(self):
return self.center_location_id 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): class SensorParameter(models.Model):
""" """
+64 -9
View File
@@ -3,10 +3,15 @@ from rest_framework import serializers
from location_data.serializers import SoilDepthDataSerializer from location_data.serializers import SoilDepthDataSerializer
from irrigation.models import IrrigationMethod from irrigation.models import IrrigationMethod
from irrigation.serializers import IrrigationMethodSerializer from irrigation.serializers import IrrigationMethodSerializer
from plant.serializers import PlantSerializer
from weather.models import WeatherForecast 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): class SensorDataUpdateSerializer(serializers.Serializer):
@@ -19,7 +24,7 @@ class SensorDataUpdateSerializer(serializers.Serializer):
plant_ids = serializers.ListField( plant_ids = serializers.ListField(
child=serializers.IntegerField(), child=serializers.IntegerField(),
required=False, required=False,
help_text="لیست شناسه گیاهان مرتبط", help_text="لیست شناسه گیاهان canonical در Backend/plants",
) )
irrigation_method_id = serializers.IntegerField( irrigation_method_id = serializers.IntegerField(
required=False, required=False,
@@ -101,17 +106,16 @@ class SensorDataUpdateSerializer(serializers.Serializer):
class SensorDataResponseSerializer(serializers.ModelSerializer): class SensorDataResponseSerializer(serializers.ModelSerializer):
"""سریالایزر خروجی برای SensorData.""" """سریالایزر خروجی برای SensorData."""
plant_ids = serializers.PrimaryKeyRelatedField( plant_ids = serializers.SerializerMethodField()
source="plants",
many=True,
read_only=True,
)
irrigation_method_id = serializers.IntegerField( irrigation_method_id = serializers.IntegerField(
source="irrigation_method.id", source="irrigation_method.id",
read_only=True, read_only=True,
allow_null=True, allow_null=True,
) )
def get_plant_ids(self, obj):
return [plant.backend_plant_id for plant in obj.plant_snapshots]
class Meta: class Meta:
model = SensorData model = SensorData
fields = [ fields = [
@@ -172,13 +176,64 @@ class FarmSoilPayloadSerializer(serializers.Serializer):
depths = SoilDepthDataSerializer(many=True) 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): class FarmDetailSerializer(serializers.Serializer):
center_location = FarmCenterLocationSerializer() center_location = FarmCenterLocationSerializer()
weather = WeatherForecastDetailSerializer(allow_null=True) weather = WeatherForecastDetailSerializer(allow_null=True)
sensor_payload = serializers.JSONField() sensor_payload = serializers.JSONField()
sensor_schema = serializers.JSONField()
soil = FarmSoilPayloadSerializer() soil = FarmSoilPayloadSerializer()
plant_ids = serializers.ListField(child=serializers.IntegerField()) 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_id = serializers.IntegerField(allow_null=True)
irrigation_method = IrrigationMethodSerializer(allow_null=True) irrigation_method = IrrigationMethodSerializer(allow_null=True)
created_at = serializers.DateTimeField() created_at = serializers.DateTimeField()
+395 -6
View File
@@ -2,29 +2,400 @@ from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from numbers import Number from numbers import Number
import logging
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.utils.dateparse import parse_datetime
import requests
from location_data.models import SoilLocation from location_data.models import SoilLocation
from location_data.serializers import SoilDepthDataSerializer from location_data.serializers import SoilDepthDataSerializer
from location_data.tasks import fetch_soil_data_for_coordinates from location_data.tasks import fetch_soil_data_for_coordinates
from irrigation.serializers import IrrigationMethodSerializer from irrigation.serializers import IrrigationMethodSerializer
from plant.serializers import PlantSerializer
from weather.services import update_weather_for_location from weather.services import update_weather_for_location
from weather.models import WeatherForecast from weather.models import WeatherForecast
from .models import SensorData from .models import (
from .serializers import WeatherForecastDetailSerializer FarmPlantAssignment,
ParameterUpdateLog,
PlantCatalogSnapshot,
SensorData,
SensorParameter,
)
from .serializers import PlantCatalogSnapshotSerializer, WeatherForecastDetailSerializer
DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"] DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"]
DECIMAL_PRECISION = Decimal("0.000001") DECIMAL_PRECISION = Decimal("0.000001")
logger = logging.getLogger(__name__)
class ExternalDataSyncError(Exception): 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): def get_farm_details(farm_uuid: str):
farm = ( farm = (
SensorData.objects.select_related( SensorData.objects.select_related(
@@ -32,13 +403,15 @@ def get_farm_details(farm_uuid: str):
"weather_forecast", "weather_forecast",
"irrigation_method", "irrigation_method",
) )
.prefetch_related("plants", "center_location__depths") .prefetch_related("plant_assignments__plant", "center_location__depths")
.filter(farm_uuid=farm_uuid) .filter(farm_uuid=farm_uuid)
.first() .first()
) )
if farm is None: if farm is None:
return None return None
sync_sensor_parameters_from_payload(farm.sensor_payload)
center_location = farm.center_location center_location = farm.center_location
weather = farm.weather_forecast weather = farm.weather_forecast
if weather is None: if weather is None:
@@ -58,6 +431,9 @@ def get_farm_details(farm_uuid: str):
resolved_metrics[key] = value resolved_metrics[key] = value
metric_sources[key] = sensor_metric_sources[key] metric_sources[key] = sensor_metric_sources[key]
plant_assignments = get_farm_plant_assignments(farm)
plant_snapshots = [assignment.plant for assignment in plant_assignments]
return { return {
"center_location": { "center_location": {
"id": center_location.id, "id": center_location.id,
@@ -67,13 +443,26 @@ def get_farm_details(farm_uuid: str):
}, },
"weather": WeatherForecastDetailSerializer(weather).data if weather else None, "weather": WeatherForecastDetailSerializer(weather).data if weather else None,
"sensor_payload": farm.sensor_payload or {}, "sensor_payload": farm.sensor_payload or {},
"sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload),
"soil": { "soil": {
"resolved_metrics": resolved_metrics, "resolved_metrics": resolved_metrics,
"metric_sources": metric_sources, "metric_sources": metric_sources,
"depths": SoilDepthDataSerializer(depths, many=True).data, "depths": SoilDepthDataSerializer(depths, many=True).data,
}, },
"plant_ids": list(farm.plants.values_list("id", flat=True)), "plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
"plants": PlantSerializer(farm.plants.all(), many=True).data, "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_id": farm.irrigation_method_id,
"irrigation_method": ( "irrigation_method": (
IrrigationMethodSerializer(farm.irrigation_method).data IrrigationMethodSerializer(farm.irrigation_method).data
+71 -9
View File
@@ -6,9 +6,9 @@ from django.test import TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
from location_data.models import SoilDepthData, SoilLocation 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 irrigation.models import IrrigationMethod
from plant.models import Plant
from weather.models import WeatherForecast from weather.models import WeatherForecast
from farm_data.services import resolve_center_location_from_boundary from farm_data.services import resolve_center_location_from_boundary
@@ -59,8 +59,8 @@ class FarmDetailApiTests(TestCase):
precipitation=1.2, precipitation=1.2,
humidity_mean=52.0, humidity_mean=52.0,
) )
self.plant1 = Plant.objects.create(name="گوجه‌فرنگی") self.plant1 = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجه‌فرنگی")
self.plant2 = Plant.objects.create(name="خیار") self.plant2 = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="خیار")
self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای")
self.farm_uuid = uuid.uuid4() self.farm_uuid = uuid.uuid4()
self.farm = SensorData.objects.create( 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): def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self):
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") 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"], payload["sensor_payload"]["sensor-7-1"]["soil_moisture"],
33.5, 33.5,
) )
self.assertIn("sensor_schema", payload)
self.assertEqual(payload["sensor_schema"]["sensor-7-1"][0]["code"], "nitrogen")
resolved_metrics = payload["soil"]["resolved_metrics"] resolved_metrics = payload["soil"]["resolved_metrics"]
metric_sources = payload["soil"]["metric_sources"] metric_sources = payload["soil"]["metric_sources"]
@@ -100,12 +102,13 @@ class FarmDetailApiTests(TestCase):
self.assertEqual(resolved_metrics["clay"], 22.0) self.assertEqual(resolved_metrics["clay"], 22.0)
self.assertEqual(metric_sources["clay"], "soil") self.assertEqual(metric_sources["clay"], "soil")
self.assertEqual(len(payload["soil"]["depths"]), 2) 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) self.assertEqual(len(payload["plants"]), 2)
returned_plants = {item["id"]: item for item in payload["plants"]} 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.plant1.backend_plant_id]["name"], self.plant1.name)
self.assertEqual(returned_plants[self.plant2.id]["name"], self.plant2.name) self.assertEqual(returned_plants[self.plant2.backend_plant_id]["name"], self.plant2.name)
self.assertIn("light", returned_plants[self.plant1.id]) 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_id"], self.irrigation_method.id)
self.assertEqual(payload["irrigation_method"]["name"], self.irrigation_method.name) 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(resolved_metrics["status"], ["ok", "needs-check"])
self.assertEqual(metric_sources["status"]["strategy"], "distinct_values") 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): class FarmDataUpsertApiTests(TestCase):
def setUp(self): def setUp(self):
@@ -212,6 +237,43 @@ class FarmDataUpsertApiTests(TestCase):
farm.sensor_payload["sensor-7-1"]["soil_moisture"], farm.sensor_payload["sensor-7-1"]["soil_moisture"],
31.2, 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): def test_post_requires_farm_uuid_in_request_body(self):
response = self.client.post( response = self.client.post(
+6 -1
View File
@@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from .views import FarmDetailView, FarmDataUpsertView, SensorParameterCreateView from .views import FarmDetailView, FarmDataUpsertView, PlantCatalogSyncView, SensorParameterCreateView
urlpatterns = [ urlpatterns = [
path( path(
@@ -18,4 +18,9 @@ urlpatterns = [
SensorParameterCreateView.as_view(), SensorParameterCreateView.as_view(),
name="farm-parameter-create", name="farm-parameter-create",
), ),
path(
"plants/sync/",
PlantCatalogSyncView.as_view(),
name="farm-data-plant-sync",
),
] ]
+57 -1
View File
@@ -21,10 +21,14 @@ from .serializers import (
SensorParameterSerializer, SensorParameterSerializer,
) )
from .services import ( from .services import (
BackendSyncError,
assign_farm_plants_from_backend_ids,
ExternalDataSyncError, ExternalDataSyncError,
ensure_location_and_weather_data, ensure_location_and_weather_data,
get_farm_details, get_farm_details,
resolve_center_location_from_boundary, 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(): with transaction.atomic():
sync_sensor_parameters_from_payload(sensor_payload)
farm_data, created = SensorData.objects.get_or_create( farm_data, created = SensorData.objects.get_or_create(
farm_uuid=farm_uuid, farm_uuid=farm_uuid,
defaults={ defaults={
@@ -227,7 +232,13 @@ class FarmDataUpsertView(APIView):
farm_data.save() farm_data.save()
if plant_ids is not None: 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 = ( response_status = (
status.HTTP_201_CREATED if created else status.HTTP_200_OK 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): class SensorParameterCreateView(APIView):
""" """
اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog. اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog.
@@ -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"]["soil_moisture"], 44.0)
self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["nitrogen"], 19.5) 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.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/") farm_detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/")
self.assertEqual(farm_detail_response.status_code, 200) self.assertEqual(farm_detail_response.status_code, 200)
@@ -189,3 +192,7 @@ class FarmManagementJourneyTests(IntegrationAPITestCase):
farm_detail["sensor_payload"]["leaf-sensor"]["leaf_temperature"], farm_detail["sensor_payload"]["leaf-sensor"]["leaf_temperature"],
21.3, 21.3,
) )
self.assertCountEqual(
[item["code"] for item in farm_detail["sensor_schema"]["leaf-sensor"]],
["leaf_temperature", "leaf_wetness"],
)
+10 -15
View File
@@ -13,6 +13,7 @@ from typing import Any
from django.apps import apps from django.apps import apps
from farm_data.models import SensorData 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.api_provider import get_chat_client
from rag.chat import ( from rag.chat import (
_complete_audit_log, _complete_audit_log,
@@ -623,7 +624,7 @@ def get_fertilization_recommendation(
sensor = ( sensor = (
SensorData.objects.select_related("center_location") SensorData.objects.select_related("center_location")
.prefetch_related("plants") .prefetch_related("plant_assignments__plant")
.filter(farm_uuid=resolved_farm_uuid) .filter(farm_uuid=resolved_farm_uuid)
.first() .first()
) )
@@ -635,20 +636,14 @@ def get_fertilization_recommendation(
resolved_growth_stage = plant_config.resolve_growth_stage(growth_stage) resolved_growth_stage = plant_config.resolve_growth_stage(growth_stage)
plant = None plant = None
if not resolved_plant_name and sensor is not None: if sensor is not None:
plant = sensor.plants.first() selected_snapshot = get_farm_plant_snapshot_by_name(sensor, resolved_plant_name)
if plant is not None: plant = clone_snapshot_as_runtime_plant(
resolved_plant_name = plant.name selected_snapshot,
elif resolved_plant_name: growth_stage=resolved_growth_stage,
if sensor is not None: )
plant = sensor.plants.filter(name=resolved_plant_name).first() if selected_snapshot is not None:
if plant is None: resolved_plant_name = selected_snapshot.name
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
forecasts = [] forecasts = []
optimized_result = None optimized_result = None
+15 -7
View File
@@ -10,6 +10,10 @@ from django.apps import apps
from django.db import transaction from django.db import transaction
from farm_data.models import SensorData 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 ( from irrigation.evapotranspiration import (
calculate_forecast_water_needs, calculate_forecast_water_needs,
resolve_crop_profile, resolve_crop_profile,
@@ -372,7 +376,7 @@ def get_irrigation_recommendation(
sensor = ( sensor = (
SensorData.objects.select_related("center_location", "irrigation_method") SensorData.objects.select_related("center_location", "irrigation_method")
.prefetch_related("plants") .prefetch_related("plant_assignments__plant")
.filter(farm_uuid=resolved_farm_uuid) .filter(farm_uuid=resolved_farm_uuid)
.first() .first()
) )
@@ -381,12 +385,16 @@ def get_irrigation_recommendation(
plant = None plant = None
resolved_plant_name = plant_name resolved_plant_name = plant_name
if sensor is not None and plant_name: if sensor is not None:
plant = sensor.plants.filter(name=plant_name).first() selected_snapshot = get_farm_plant_snapshot_by_name(sensor, plant_name)
elif sensor is not None: plant = clone_snapshot_as_runtime_plant(
plant = sensor.plants.first() selected_snapshot,
if plant is not None: growth_stage=growth_stage,
resolved_plant_name = plant.name )
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) crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage)
active_kc = resolve_kc(crop_profile, growth_stage=growth_stage) active_kc = resolve_kc(crop_profile, growth_stage=growth_stage)
+5 -25
View File
@@ -166,36 +166,16 @@ def load_user_sources() -> list[tuple[str, str]]:
def build_plant_text(plant_name: str, growth_stage: str) -> str | None: 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: if not plant:
return None return None
lines = [ return build_plant_text_from_snapshot(plant, growth_stage)
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_irrigation_method_text(method_name: str) -> str | None: def build_irrigation_method_text(method_name: str) -> str | None:
-2
View File
@@ -11,8 +11,6 @@ mysqlclient>=2.2,<2.3
gunicorn>=22,<23 gunicorn>=22,<23
# === API Docs === # === API Docs ===
drf-spectacular>=0.27,<0.28
drf-spectacular-sidecar>=2024.7.1,<2025
# === Config === # === Config ===
python-dotenv>=1.0,<1.1 python-dotenv>=1.0,<1.1
+16 -6
View File
@@ -193,10 +193,17 @@ def _grid_axis(min_value: float, max_value: float) -> list[float]:
def _load_sensor_network(current_sensor: Any) -> list[Any]: def _load_sensor_network(current_sensor: Any) -> list[Any]:
plant_ids = list(current_sensor.plants.values_list("id", flat=True)) plant_ids = list(
queryset = SensorData.objects.select_related("center_location").prefetch_related("plants", "center_location__depths") 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: 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) return list(queryset)
@@ -270,7 +277,7 @@ class SoilMoistureHeatmapService:
def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]: def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]:
current_sensor = ( current_sensor = (
SensorData.objects.select_related("center_location") 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) .filter(farm_uuid=farm_uuid)
.first() .first()
) )
@@ -438,7 +445,7 @@ class SoilHealthService:
def get_health_summary(self, *, farm_uuid: str) -> dict[str, Any]: def get_health_summary(self, *, farm_uuid: str) -> dict[str, Any]:
sensor = ( sensor = (
SensorData.objects.select_related("center_location") SensorData.objects.select_related("center_location")
.prefetch_related("plants") .prefetch_related("plant_assignments__plant")
.filter(farm_uuid=farm_uuid) .filter(farm_uuid=farm_uuid)
.first() .first()
) )
@@ -446,7 +453,10 @@ class SoilHealthService:
raise ValueError("Farm not found.") raise ValueError("Farm not found.")
return { return {
"farm_uuid": str(sensor.farm_uuid), "farm_uuid": str(sensor.farm_uuid),
**build_soil_health_summary(sensor, list(sensor.plants.all())), **build_soil_health_summary(
sensor,
list(sensor.plant_snapshots),
),
} }
+3 -3
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Any 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 irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile
from farm_data.models import SensorData 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]: def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) -> dict[str, Any]:
location = getattr(sensor, "center_location", None) location = getattr(sensor, "center_location", None)
plants = list(sensor.plants.all()) if hasattr(sensor, "plants") else [] plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor))
plant = plants[0] if plants else None
irrigation_method = getattr(sensor, "irrigation_method", None) irrigation_method = getattr(sensor, "irrigation_method", None)
if not forecasts or location is 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]: def get_water_need_prediction(self, *, farm_uuid: str) -> dict[str, Any]:
sensor = ( sensor = (
SensorData.objects.select_related("center_location", "irrigation_method") SensorData.objects.select_related("center_location", "irrigation_method")
.prefetch_related("plants") .prefetch_related("plant_assignments__plant")
.filter(farm_uuid=farm_uuid) .filter(farm_uuid=farm_uuid)
.first() .first()
) )