UPDATE
This commit is contained in:
+395
-6
@@ -2,29 +2,400 @@ from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from numbers import Number
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
import requests
|
||||
|
||||
from location_data.models import SoilLocation
|
||||
from location_data.serializers import SoilDepthDataSerializer
|
||||
from location_data.tasks import fetch_soil_data_for_coordinates
|
||||
from irrigation.serializers import IrrigationMethodSerializer
|
||||
from plant.serializers import PlantSerializer
|
||||
from weather.services import update_weather_for_location
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
from .models import SensorData
|
||||
from .serializers import WeatherForecastDetailSerializer
|
||||
from .models import (
|
||||
FarmPlantAssignment,
|
||||
ParameterUpdateLog,
|
||||
PlantCatalogSnapshot,
|
||||
SensorData,
|
||||
SensorParameter,
|
||||
)
|
||||
from .serializers import PlantCatalogSnapshotSerializer, WeatherForecastDetailSerializer
|
||||
|
||||
|
||||
DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"]
|
||||
DECIMAL_PRECISION = Decimal("0.000001")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExternalDataSyncError(Exception):
|
||||
"""خطا در همگامسازی داده از سرویسهای بیرونی."""
|
||||
|
||||
|
||||
class BackendSyncError(Exception):
|
||||
"""خطا در همگام سازی کاتالوگ گیاه و assignmentها از Backend."""
|
||||
|
||||
|
||||
PARAMETER_LABEL_OVERRIDES = {
|
||||
"soil_moisture": "رطوبت خاک",
|
||||
"soil_temperature": "دمای خاک",
|
||||
"soil_ph": "pH خاک",
|
||||
"electrical_conductivity": "هدایت الکتریکی",
|
||||
"nitrogen": "نیتروژن",
|
||||
"phosphorus": "فسفر",
|
||||
"potassium": "پتاسیم",
|
||||
}
|
||||
PARAMETER_UNIT_OVERRIDES = {
|
||||
"soil_moisture": "%",
|
||||
"soil_temperature": "°C",
|
||||
"soil_ph": "",
|
||||
"electrical_conductivity": "dS/m",
|
||||
"nitrogen": "mg/kg",
|
||||
"phosphorus": "mg/kg",
|
||||
"potassium": "mg/kg",
|
||||
}
|
||||
|
||||
|
||||
def get_backend_plant_base_url() -> str:
|
||||
return getattr(settings, "BACKEND_PLANT_SYNC_BASE_URL", "").rstrip("/")
|
||||
|
||||
|
||||
def get_backend_plant_timeout() -> int:
|
||||
return int(getattr(settings, "BACKEND_PLANT_SYNC_TIMEOUT", 20))
|
||||
|
||||
|
||||
def get_backend_plant_headers() -> dict[str, str]:
|
||||
headers = {"Accept": "application/json"}
|
||||
api_key = getattr(settings, "BACKEND_PLANT_SYNC_API_KEY", "").strip()
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
headers["Authorization"] = f"Api-Key {api_key}"
|
||||
return headers
|
||||
|
||||
|
||||
def _extract_envelope_list(payload):
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
data = payload.get("data")
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
result = payload.get("result")
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
if isinstance(data, dict) and isinstance(data.get("result"), list):
|
||||
return data["result"]
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_growth_stages(item: dict) -> list[str]:
|
||||
stages = item.get("growth_stages")
|
||||
if isinstance(stages, list):
|
||||
return [str(stage).strip() for stage in stages if str(stage).strip()]
|
||||
|
||||
growth_stage = str(item.get("growth_stage") or "").strip()
|
||||
if not growth_stage:
|
||||
return []
|
||||
return [part.strip() for part in growth_stage.replace("،", ",").split(",") if part.strip()]
|
||||
|
||||
|
||||
def _snapshot_defaults_from_payload(item: dict) -> dict:
|
||||
source_updated_at = parse_datetime(str(item.get("updated_at") or "").strip()) if item.get("updated_at") else None
|
||||
return {
|
||||
"name": str(item.get("name") or "").strip(),
|
||||
"slug": str(item.get("slug") or "").strip(),
|
||||
"icon": str(item.get("icon") or "leaf").strip() or "leaf",
|
||||
"description": str(item.get("description") or "").strip(),
|
||||
"metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {},
|
||||
"light": str(item.get("light") or "").strip(),
|
||||
"watering": str(item.get("watering") or "").strip(),
|
||||
"soil": str(item.get("soil") or "").strip(),
|
||||
"temperature": str(item.get("temperature") or "").strip(),
|
||||
"growth_stage": str(item.get("growth_stage") or "").strip(),
|
||||
"growth_stages": _normalize_growth_stages(item),
|
||||
"planting_season": str(item.get("planting_season") or "").strip(),
|
||||
"harvest_time": str(item.get("harvest_time") or "").strip(),
|
||||
"spacing": str(item.get("spacing") or "").strip(),
|
||||
"fertilizer": str(item.get("fertilizer") or "").strip(),
|
||||
"health_profile": item.get("health_profile") if isinstance(item.get("health_profile"), dict) else {},
|
||||
"irrigation_profile": item.get("irrigation_profile") if isinstance(item.get("irrigation_profile"), dict) else {},
|
||||
"growth_profile": item.get("growth_profile") if isinstance(item.get("growth_profile"), dict) else {},
|
||||
"is_active": bool(item.get("is_active", True)),
|
||||
"source_updated_at": source_updated_at,
|
||||
}
|
||||
|
||||
|
||||
def sync_plant_catalog_from_backend(plant_payloads: list[dict] | None = None) -> list[PlantCatalogSnapshot]:
|
||||
if plant_payloads is None:
|
||||
base_url = get_backend_plant_base_url()
|
||||
if not base_url:
|
||||
raise BackendSyncError("BACKEND_PLANT_SYNC_BASE_URL is not configured.")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/api/plants/",
|
||||
headers=get_backend_plant_headers(),
|
||||
timeout=get_backend_plant_timeout(),
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise BackendSyncError(f"Backend plant catalog request failed: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise BackendSyncError(f"Backend plant catalog returned status {response.status_code}.")
|
||||
plant_payloads = _extract_envelope_list(response.json())
|
||||
|
||||
snapshots: list[PlantCatalogSnapshot] = []
|
||||
with transaction.atomic():
|
||||
for item in plant_payloads or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
plant_id = item.get("id") or item.get("backend_plant_id")
|
||||
if plant_id in (None, ""):
|
||||
continue
|
||||
snapshot, _ = PlantCatalogSnapshot.objects.update_or_create(
|
||||
backend_plant_id=int(plant_id),
|
||||
defaults=_snapshot_defaults_from_payload(item),
|
||||
)
|
||||
snapshots.append(snapshot)
|
||||
return snapshots
|
||||
|
||||
|
||||
def assign_farm_plants_from_backend_ids(farm: SensorData, backend_plant_ids: list[int] | None) -> list[PlantCatalogSnapshot]:
|
||||
if backend_plant_ids is None:
|
||||
return list(get_farm_plant_snapshots(farm))
|
||||
|
||||
normalized_ids = [int(plant_id) for plant_id in backend_plant_ids]
|
||||
snapshots = list(PlantCatalogSnapshot.objects.filter(backend_plant_id__in=normalized_ids))
|
||||
snapshot_by_backend_id = {snapshot.backend_plant_id: snapshot for snapshot in snapshots}
|
||||
missing_ids = [plant_id for plant_id in normalized_ids if plant_id not in snapshot_by_backend_id]
|
||||
if missing_ids:
|
||||
raise BackendSyncError(
|
||||
"Plant catalog snapshot missing for backend ids: "
|
||||
+ ", ".join(str(plant_id) for plant_id in missing_ids)
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
FarmPlantAssignment.objects.filter(farm=farm).exclude(
|
||||
plant__backend_plant_id__in=normalized_ids
|
||||
).delete()
|
||||
for position, backend_plant_id in enumerate(normalized_ids):
|
||||
FarmPlantAssignment.objects.update_or_create(
|
||||
farm=farm,
|
||||
plant=snapshot_by_backend_id[backend_plant_id],
|
||||
defaults={"position": position},
|
||||
)
|
||||
return [snapshot_by_backend_id[backend_plant_id] for backend_plant_id in normalized_ids]
|
||||
|
||||
|
||||
def get_farm_plant_assignments(farm: SensorData) -> list[FarmPlantAssignment]:
|
||||
return list(
|
||||
farm.plant_assignments.select_related("plant").order_by("position", "id")
|
||||
)
|
||||
|
||||
|
||||
def get_farm_plant_snapshots(farm: SensorData) -> list[PlantCatalogSnapshot]:
|
||||
return [assignment.plant for assignment in get_farm_plant_assignments(farm)]
|
||||
|
||||
|
||||
def get_primary_plant_snapshot(farm: SensorData) -> PlantCatalogSnapshot | None:
|
||||
assignments = get_farm_plant_assignments(farm)
|
||||
return assignments[0].plant if assignments else None
|
||||
|
||||
|
||||
def get_farm_plant_snapshot_by_name(
|
||||
farm: SensorData,
|
||||
plant_name: str | None,
|
||||
) -> PlantCatalogSnapshot | None:
|
||||
normalized_name = str(plant_name or "").strip().lower()
|
||||
if not normalized_name:
|
||||
return get_primary_plant_snapshot(farm)
|
||||
for assignment in get_farm_plant_assignments(farm):
|
||||
if assignment.plant.name.strip().lower() == normalized_name:
|
||||
return assignment.plant
|
||||
return get_primary_plant_snapshot(farm)
|
||||
|
||||
|
||||
def clone_snapshot_as_runtime_plant(
|
||||
snapshot: PlantCatalogSnapshot | None,
|
||||
*,
|
||||
growth_stage: str | None = None,
|
||||
):
|
||||
if snapshot is None:
|
||||
return None
|
||||
|
||||
class RuntimePlant:
|
||||
pass
|
||||
|
||||
runtime = RuntimePlant()
|
||||
for field_name in (
|
||||
"backend_plant_id",
|
||||
"name",
|
||||
"slug",
|
||||
"icon",
|
||||
"description",
|
||||
"metadata",
|
||||
"light",
|
||||
"watering",
|
||||
"soil",
|
||||
"temperature",
|
||||
"growth_stage",
|
||||
"growth_stages",
|
||||
"planting_season",
|
||||
"harvest_time",
|
||||
"spacing",
|
||||
"fertilizer",
|
||||
"health_profile",
|
||||
"irrigation_profile",
|
||||
"growth_profile",
|
||||
"is_active",
|
||||
):
|
||||
setattr(runtime, field_name, getattr(snapshot, field_name))
|
||||
if growth_stage:
|
||||
runtime.growth_stage = growth_stage
|
||||
runtime.id = snapshot.backend_plant_id
|
||||
return runtime
|
||||
|
||||
|
||||
def build_plant_text_from_snapshot(
|
||||
plant: PlantCatalogSnapshot | None,
|
||||
growth_stage: str,
|
||||
) -> str | None:
|
||||
if plant is None:
|
||||
return None
|
||||
|
||||
lines = [
|
||||
f"نام گیاه: {plant.name}",
|
||||
f"مرحله رشد: {growth_stage}",
|
||||
]
|
||||
if plant.light:
|
||||
lines.append(f"نور مورد نیاز: {plant.light}")
|
||||
if plant.watering:
|
||||
lines.append(f"آبیاری: {plant.watering}")
|
||||
if plant.soil:
|
||||
lines.append(f"خاک مناسب: {plant.soil}")
|
||||
if plant.temperature:
|
||||
lines.append(f"دمای مناسب: {plant.temperature}")
|
||||
if plant.planting_season:
|
||||
lines.append(f"فصل کاشت: {plant.planting_season}")
|
||||
if plant.harvest_time:
|
||||
lines.append(f"زمان برداشت: {plant.harvest_time}")
|
||||
if plant.spacing:
|
||||
lines.append(f"فاصله کاشت: {plant.spacing}")
|
||||
if plant.fertilizer:
|
||||
lines.append(f"کود مناسب: {plant.fertilizer}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_farm_plant_context(farm_uuid: str) -> dict | None:
|
||||
farm = (
|
||||
SensorData.objects.select_related(
|
||||
"center_location",
|
||||
"weather_forecast",
|
||||
"irrigation_method",
|
||||
)
|
||||
.prefetch_related("plant_assignments__plant", "center_location__depths")
|
||||
.filter(farm_uuid=farm_uuid)
|
||||
.first()
|
||||
)
|
||||
if farm is None:
|
||||
return None
|
||||
assignments = get_farm_plant_assignments(farm)
|
||||
snapshots = [assignment.plant for assignment in assignments]
|
||||
return {
|
||||
"farm": farm,
|
||||
"plant_ids": [plant.backend_plant_id for plant in snapshots],
|
||||
"plants": PlantCatalogSnapshotSerializer(snapshots, many=True).data,
|
||||
"plant_snapshots": snapshots,
|
||||
"plant_assignments": assignments,
|
||||
"primary_plant": snapshots[0] if snapshots else None,
|
||||
}
|
||||
|
||||
|
||||
def infer_sensor_parameter_data_type(value: object) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "bool"
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return "int"
|
||||
if isinstance(value, float):
|
||||
return "float"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "list"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
return "string"
|
||||
|
||||
|
||||
def build_parameter_defaults(sensor_key: str, code: str, value: object) -> dict[str, object]:
|
||||
return {
|
||||
"name_fa": PARAMETER_LABEL_OVERRIDES.get(code) or code.replace("_", " ").strip(),
|
||||
"unit": PARAMETER_UNIT_OVERRIDES.get(code, ""),
|
||||
"data_type": infer_sensor_parameter_data_type(value),
|
||||
"metadata": {
|
||||
"source": "auto_discovered",
|
||||
"sensor_key": sensor_key,
|
||||
"code": code,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[SensorParameter]:
|
||||
if not isinstance(sensor_payload, dict):
|
||||
return []
|
||||
|
||||
synced_parameters: list[SensorParameter] = []
|
||||
with transaction.atomic():
|
||||
for sensor_key, sensor_values in sensor_payload.items():
|
||||
if not isinstance(sensor_values, dict):
|
||||
continue
|
||||
for code, value in sensor_values.items():
|
||||
defaults = build_parameter_defaults(sensor_key, code, value)
|
||||
parameter, created = SensorParameter.objects.get_or_create(
|
||||
sensor_key=sensor_key,
|
||||
code=code,
|
||||
defaults=defaults,
|
||||
)
|
||||
if created:
|
||||
ParameterUpdateLog.objects.create(
|
||||
parameter=parameter,
|
||||
action=ParameterUpdateLog.ACTION_ADDED,
|
||||
payload={
|
||||
"sensor_key": parameter.sensor_key,
|
||||
"code": parameter.code,
|
||||
"name_fa": parameter.name_fa,
|
||||
"unit": parameter.unit,
|
||||
"data_type": parameter.data_type,
|
||||
"metadata": parameter.metadata,
|
||||
"source": "farm_data_auto_sync",
|
||||
},
|
||||
)
|
||||
synced_parameters.append(parameter)
|
||||
return synced_parameters
|
||||
|
||||
|
||||
def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]:
|
||||
parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code")
|
||||
if sensor_payload and isinstance(sensor_payload, dict):
|
||||
parameter_queryset = parameter_queryset.filter(sensor_key__in=list(sensor_payload.keys()))
|
||||
|
||||
catalog: dict[str, list[dict[str, object]]] = {}
|
||||
for parameter in parameter_queryset:
|
||||
catalog.setdefault(parameter.sensor_key, []).append(
|
||||
{
|
||||
"code": parameter.code,
|
||||
"name_fa": parameter.name_fa,
|
||||
"unit": parameter.unit,
|
||||
"data_type": parameter.data_type,
|
||||
"metadata": parameter.metadata,
|
||||
}
|
||||
)
|
||||
return catalog
|
||||
|
||||
|
||||
def get_farm_details(farm_uuid: str):
|
||||
farm = (
|
||||
SensorData.objects.select_related(
|
||||
@@ -32,13 +403,15 @@ def get_farm_details(farm_uuid: str):
|
||||
"weather_forecast",
|
||||
"irrigation_method",
|
||||
)
|
||||
.prefetch_related("plants", "center_location__depths")
|
||||
.prefetch_related("plant_assignments__plant", "center_location__depths")
|
||||
.filter(farm_uuid=farm_uuid)
|
||||
.first()
|
||||
)
|
||||
if farm is None:
|
||||
return None
|
||||
|
||||
sync_sensor_parameters_from_payload(farm.sensor_payload)
|
||||
|
||||
center_location = farm.center_location
|
||||
weather = farm.weather_forecast
|
||||
if weather is None:
|
||||
@@ -58,6 +431,9 @@ def get_farm_details(farm_uuid: str):
|
||||
resolved_metrics[key] = value
|
||||
metric_sources[key] = sensor_metric_sources[key]
|
||||
|
||||
plant_assignments = get_farm_plant_assignments(farm)
|
||||
plant_snapshots = [assignment.plant for assignment in plant_assignments]
|
||||
|
||||
return {
|
||||
"center_location": {
|
||||
"id": center_location.id,
|
||||
@@ -67,13 +443,26 @@ def get_farm_details(farm_uuid: str):
|
||||
},
|
||||
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
|
||||
"sensor_payload": farm.sensor_payload or {},
|
||||
"sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload),
|
||||
"soil": {
|
||||
"resolved_metrics": resolved_metrics,
|
||||
"metric_sources": metric_sources,
|
||||
"depths": SoilDepthDataSerializer(depths, many=True).data,
|
||||
},
|
||||
"plant_ids": list(farm.plants.values_list("id", flat=True)),
|
||||
"plants": PlantSerializer(farm.plants.all(), many=True).data,
|
||||
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
|
||||
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
|
||||
"plant_assignments": [
|
||||
{
|
||||
"plant_id": assignment.plant.backend_plant_id,
|
||||
"position": assignment.position,
|
||||
"stage": assignment.stage,
|
||||
"metadata": assignment.metadata,
|
||||
"assigned_at": assignment.assigned_at,
|
||||
"updated_at": assignment.updated_at,
|
||||
"plant": PlantCatalogSnapshotSerializer(assignment.plant).data,
|
||||
}
|
||||
for assignment in plant_assignments
|
||||
],
|
||||
"irrigation_method_id": farm.irrigation_method_id,
|
||||
"irrigation_method": (
|
||||
IrrigationMethodSerializer(farm.irrigation_method).data
|
||||
|
||||
Reference in New Issue
Block a user