UPDATE
This commit is contained in:
+3
-30
@@ -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,
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
class OpenApiTypes:
|
||||||
|
STR = str
|
||||||
|
INT = int
|
||||||
|
BOOL = bool
|
||||||
|
UUID = str
|
||||||
|
DATE = str
|
||||||
|
DATETIME = str
|
||||||
|
OBJECT = dict
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
+106
-4
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"],
|
||||||
|
)
|
||||||
|
|||||||
@@ -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:
|
|
||||||
plant = sensor.plants.first()
|
|
||||||
if plant is not None:
|
|
||||||
resolved_plant_name = plant.name
|
|
||||||
elif resolved_plant_name:
|
|
||||||
if sensor is not None:
|
if sensor is not None:
|
||||||
plant = sensor.plants.filter(name=resolved_plant_name).first()
|
selected_snapshot = get_farm_plant_snapshot_by_name(sensor, resolved_plant_name)
|
||||||
if plant is None:
|
plant = clone_snapshot_as_runtime_plant(
|
||||||
Plant = apps.get_model("plant", "Plant")
|
selected_snapshot,
|
||||||
plant = Plant.objects.filter(name=resolved_plant_name).first()
|
growth_stage=resolved_growth_stage,
|
||||||
if plant is None and sensor is not None:
|
)
|
||||||
plant = sensor.plants.first()
|
if selected_snapshot is not None:
|
||||||
if plant is not None:
|
resolved_plant_name = selected_snapshot.name
|
||||||
resolved_plant_name = plant.name
|
|
||||||
|
|
||||||
forecasts = []
|
forecasts = []
|
||||||
optimized_result = None
|
optimized_result = None
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user