2026-04-06 23:50:24 +03:30
|
|
|
from copy import deepcopy
|
|
|
|
|
|
|
|
|
|
from django.db import transaction
|
|
|
|
|
from drf_spectacular.utils import (
|
|
|
|
|
OpenApiExample,
|
|
|
|
|
OpenApiResponse,
|
|
|
|
|
extend_schema,
|
|
|
|
|
inline_serializer,
|
|
|
|
|
)
|
|
|
|
|
from rest_framework import serializers as drf_serializers
|
|
|
|
|
from rest_framework import status
|
|
|
|
|
from rest_framework.response import Response
|
|
|
|
|
from rest_framework.views import APIView
|
|
|
|
|
|
|
|
|
|
from config.openapi import build_envelope_serializer, build_response
|
|
|
|
|
from .models import ParameterUpdateLog, SensorData, SensorParameter
|
|
|
|
|
from .serializers import (
|
|
|
|
|
FarmDetailSerializer,
|
|
|
|
|
SensorDataResponseSerializer,
|
|
|
|
|
SensorDataUpdateSerializer,
|
|
|
|
|
SensorParameterSerializer,
|
|
|
|
|
)
|
|
|
|
|
from .services import (
|
2026-05-05 01:46:10 +03:30
|
|
|
BackendSyncError,
|
|
|
|
|
assign_farm_plants_from_backend_ids,
|
2026-04-07 01:08:41 +03:30
|
|
|
ExternalDataSyncError,
|
|
|
|
|
ensure_location_and_weather_data,
|
2026-04-06 23:50:24 +03:30
|
|
|
get_farm_details,
|
|
|
|
|
resolve_center_location_from_boundary,
|
2026-05-05 01:46:10 +03:30
|
|
|
sync_sensor_parameters_from_payload,
|
|
|
|
|
sync_plant_catalog_from_backend,
|
2026-04-06 23:50:24 +03:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SensorDataEnvelopeSerializer = build_envelope_serializer(
|
|
|
|
|
"SensorDataEnvelopeSerializer",
|
|
|
|
|
SensorDataResponseSerializer,
|
|
|
|
|
)
|
|
|
|
|
SensorDataValidationErrorSerializer = build_envelope_serializer(
|
|
|
|
|
"SensorDataValidationErrorSerializer",
|
|
|
|
|
data_required=False,
|
|
|
|
|
allow_null=True,
|
|
|
|
|
)
|
|
|
|
|
SensorDataNotFoundSerializer = build_envelope_serializer(
|
|
|
|
|
"SensorDataNotFoundSerializer",
|
|
|
|
|
data_required=False,
|
|
|
|
|
allow_null=True,
|
|
|
|
|
)
|
|
|
|
|
FarmDetailEnvelopeSerializer = build_envelope_serializer(
|
|
|
|
|
"FarmDetailEnvelopeSerializer",
|
|
|
|
|
FarmDetailSerializer,
|
|
|
|
|
)
|
|
|
|
|
SensorParameterResponseSerializer = build_envelope_serializer(
|
|
|
|
|
"SensorParameterEnvelopeSerializer",
|
|
|
|
|
inline_serializer(
|
|
|
|
|
name="SensorParameterPayloadSerializer",
|
|
|
|
|
fields={
|
|
|
|
|
"id": drf_serializers.IntegerField(),
|
|
|
|
|
"sensor_key": drf_serializers.CharField(),
|
|
|
|
|
"code": drf_serializers.CharField(),
|
|
|
|
|
"name_fa": drf_serializers.CharField(),
|
|
|
|
|
"unit": drf_serializers.CharField(),
|
|
|
|
|
"data_type": drf_serializers.CharField(),
|
|
|
|
|
"metadata": drf_serializers.JSONField(),
|
|
|
|
|
"created_at": drf_serializers.DateTimeField(),
|
|
|
|
|
"action": drf_serializers.CharField(),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FarmDataUpsertView(APIView):
|
|
|
|
|
"""
|
|
|
|
|
ایجاد یا آپدیت داده farm.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@extend_schema(
|
|
|
|
|
tags=["Farm Data"],
|
|
|
|
|
summary="ایجاد یا آپدیت داده farm",
|
|
|
|
|
description=(
|
|
|
|
|
"داده farm را با `POST /api/farm-data/` ایجاد یا آپدیت میکند. "
|
|
|
|
|
"`farm_uuid` باید از API ارسال شود و هرگز خودکار ساخته نمیشود. "
|
|
|
|
|
"مرز مزرعه را میگیرد، مرکز زمین را خودش محاسبه و در location_data ذخیره میکند. "
|
|
|
|
|
"رکورد آبوهوا هم از همان مرکز زمین بهصورت خودکار پیدا میشود. "
|
|
|
|
|
'خوانشها داخل `sensor_payload` مثل `{"sensor-7-1": {...}}` نگهداری میشوند.'
|
|
|
|
|
),
|
|
|
|
|
request=SensorDataUpdateSerializer,
|
|
|
|
|
responses={
|
|
|
|
|
200: build_response(
|
|
|
|
|
SensorDataEnvelopeSerializer,
|
|
|
|
|
"داده farm با موفقیت بهروزرسانی شد.",
|
|
|
|
|
),
|
|
|
|
|
201: build_response(
|
|
|
|
|
SensorDataEnvelopeSerializer,
|
|
|
|
|
"داده farm با موفقیت ایجاد شد.",
|
|
|
|
|
),
|
|
|
|
|
400: build_response(
|
|
|
|
|
SensorDataValidationErrorSerializer,
|
|
|
|
|
"داده ورودی نامعتبر است.",
|
|
|
|
|
),
|
2026-04-07 01:08:41 +03:30
|
|
|
502: build_response(
|
|
|
|
|
SensorDataNotFoundSerializer,
|
|
|
|
|
"واکشی داده خاک یا آبوهوا از سرویس بیرونی ناموفق بود.",
|
|
|
|
|
),
|
2026-04-06 23:50:24 +03:30
|
|
|
},
|
|
|
|
|
examples=[
|
|
|
|
|
OpenApiExample(
|
|
|
|
|
"نمونه درخواست",
|
|
|
|
|
value={
|
|
|
|
|
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
|
|
|
"farm_boundary": {
|
|
|
|
|
"type": "Polygon",
|
|
|
|
|
"coordinates": [
|
|
|
|
|
[
|
|
|
|
|
[51.3900, 35.7000],
|
|
|
|
|
[51.4100, 35.7000],
|
|
|
|
|
[51.4100, 35.7200],
|
|
|
|
|
[51.3900, 35.7200],
|
|
|
|
|
[51.3900, 35.7000],
|
|
|
|
|
]
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
"sensor_payload": {
|
|
|
|
|
"sensor-7-1": {
|
|
|
|
|
"soil_moisture": 45.2,
|
|
|
|
|
"soil_temperature": 22.5,
|
|
|
|
|
"soil_ph": 6.8,
|
|
|
|
|
"electrical_conductivity": 1.2,
|
|
|
|
|
"nitrogen": 30.0,
|
|
|
|
|
"phosphorus": 15.0,
|
|
|
|
|
"potassium": 20.0,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
request_only=True,
|
|
|
|
|
),
|
|
|
|
|
OpenApiExample(
|
|
|
|
|
"نمونه چند سنسور",
|
|
|
|
|
value={
|
|
|
|
|
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
|
|
|
"farm_boundary": {
|
|
|
|
|
"corners": [
|
|
|
|
|
{"lat": 35.7000, "lon": 51.3900},
|
|
|
|
|
{"lat": 35.7000, "lon": 51.4100},
|
|
|
|
|
{"lat": 35.7200, "lon": 51.4100},
|
|
|
|
|
{"lat": 35.7200, "lon": 51.3900},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"sensor_payload": {
|
|
|
|
|
"sensor-7-1": {
|
|
|
|
|
"soil_moisture": 45.2,
|
|
|
|
|
"soil_temperature": 22.5,
|
|
|
|
|
},
|
|
|
|
|
"leaf-sensor": {
|
|
|
|
|
"leaf_wetness": 11.0,
|
|
|
|
|
"leaf_temperature": 19.3,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
request_only=True,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def post(self, request):
|
|
|
|
|
serializer = SensorDataUpdateSerializer(data=request.data)
|
|
|
|
|
if not serializer.is_valid():
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
|
|
|
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
farm_uuid = serializer.validated_data["farm_uuid"]
|
|
|
|
|
farm_boundary = serializer.validated_data["farm_boundary"]
|
|
|
|
|
plant_ids = serializer.validated_data.get("plant_ids")
|
2026-04-24 02:50:27 +03:30
|
|
|
irrigation_method_id = serializer.validated_data.get("irrigation_method_id")
|
2026-04-06 23:50:24 +03:30
|
|
|
sensor_payload = serializer.validated_data.get("sensor_payload", {})
|
|
|
|
|
try:
|
|
|
|
|
center_location = resolve_center_location_from_boundary(farm_boundary)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 400, "msg": "داده نامعتبر.", "data": {"farm_boundary": [str(exc)]}},
|
|
|
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
)
|
2026-04-07 01:08:41 +03:30
|
|
|
try:
|
|
|
|
|
center_location, weather_forecast = ensure_location_and_weather_data(
|
|
|
|
|
center_location
|
|
|
|
|
)
|
|
|
|
|
except ExternalDataSyncError as exc:
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 502, "msg": str(exc), "data": None},
|
|
|
|
|
status=status.HTTP_502_BAD_GATEWAY,
|
|
|
|
|
)
|
2026-04-06 23:50:24 +03:30
|
|
|
|
|
|
|
|
with transaction.atomic():
|
2026-05-05 01:46:10 +03:30
|
|
|
sync_sensor_parameters_from_payload(sensor_payload)
|
2026-04-06 23:50:24 +03:30
|
|
|
farm_data, created = SensorData.objects.get_or_create(
|
|
|
|
|
farm_uuid=farm_uuid,
|
|
|
|
|
defaults={
|
|
|
|
|
"center_location": center_location,
|
|
|
|
|
"weather_forecast": weather_forecast,
|
|
|
|
|
"sensor_payload": sensor_payload,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not created and sensor_payload:
|
|
|
|
|
merged_payload = deepcopy(farm_data.sensor_payload or {})
|
|
|
|
|
for sensor_key, sensor_values in sensor_payload.items():
|
|
|
|
|
current_values = merged_payload.get(sensor_key, {})
|
|
|
|
|
if not isinstance(current_values, dict):
|
|
|
|
|
current_values = {}
|
|
|
|
|
current_values.update(sensor_values)
|
|
|
|
|
merged_payload[sensor_key] = current_values
|
|
|
|
|
farm_data.sensor_payload = merged_payload
|
|
|
|
|
elif created:
|
|
|
|
|
farm_data.sensor_payload = sensor_payload
|
|
|
|
|
|
|
|
|
|
farm_data.center_location = center_location
|
|
|
|
|
farm_data.weather_forecast = weather_forecast
|
2026-04-24 02:50:27 +03:30
|
|
|
if "irrigation_method_id" in serializer.validated_data:
|
|
|
|
|
farm_data.irrigation_method_id = irrigation_method_id
|
2026-04-06 23:50:24 +03:30
|
|
|
if not created:
|
|
|
|
|
farm_data.save(
|
|
|
|
|
update_fields=[
|
|
|
|
|
"center_location",
|
|
|
|
|
"weather_forecast",
|
|
|
|
|
"sensor_payload",
|
2026-04-24 02:50:27 +03:30
|
|
|
"irrigation_method",
|
2026-04-06 23:50:24 +03:30
|
|
|
"updated_at",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
farm_data.save()
|
|
|
|
|
|
|
|
|
|
if plant_ids is not None:
|
2026-05-05 01:46:10 +03:30
|
|
|
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,
|
|
|
|
|
)
|
2026-04-06 23:50:24 +03:30
|
|
|
|
|
|
|
|
response_status = (
|
|
|
|
|
status.HTTP_201_CREATED if created else status.HTTP_200_OK
|
|
|
|
|
)
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
|
"code": 201 if created else 200,
|
|
|
|
|
"msg": "success",
|
|
|
|
|
"data": SensorDataResponseSerializer(farm_data).data,
|
|
|
|
|
},
|
|
|
|
|
status=response_status,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FarmDetailView(APIView):
|
|
|
|
|
@extend_schema(
|
|
|
|
|
tags=["Farm Data"],
|
|
|
|
|
summary="دریافت همه اطلاعات farm",
|
|
|
|
|
description=(
|
|
|
|
|
"اطلاعات تجمیعی farm را برمیگرداند. "
|
2026-04-25 17:22:41 +03:30
|
|
|
"برای resolved_metrics، دادههای sensor روی دادههای خاک اولویت دارند "
|
|
|
|
|
"و در حالت چند سنسوره، مقادیر متعارض بهصورت deterministic تجمیع میشوند."
|
2026-04-06 23:50:24 +03:30
|
|
|
),
|
|
|
|
|
responses={
|
|
|
|
|
200: build_response(
|
|
|
|
|
FarmDetailEnvelopeSerializer,
|
|
|
|
|
"اطلاعات farm با موفقیت بازگردانده شد.",
|
|
|
|
|
),
|
|
|
|
|
404: build_response(
|
|
|
|
|
SensorDataNotFoundSerializer,
|
|
|
|
|
"farm موردنظر یافت نشد.",
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
def get(self, request, farm_uuid):
|
|
|
|
|
data = get_farm_details(str(farm_uuid))
|
|
|
|
|
if data is None:
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 404, "msg": "farm یافت نشد.", "data": None},
|
|
|
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 200, "msg": "success", "data": data},
|
|
|
|
|
status=status.HTTP_200_OK,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-05 01:46:10 +03:30
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 23:50:24 +03:30
|
|
|
class SensorParameterCreateView(APIView):
|
|
|
|
|
"""
|
|
|
|
|
اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@extend_schema(
|
|
|
|
|
tags=["Farm Parameters"],
|
|
|
|
|
summary="افزودن/ویرایش پارامتر سنسور",
|
|
|
|
|
description="پارامتر جدید اضافه یا پارامتر موجود را ویرایش میکند و در لاگ ثبت میشود.",
|
|
|
|
|
request=SensorParameterSerializer,
|
|
|
|
|
responses={
|
|
|
|
|
201: build_response(
|
|
|
|
|
SensorParameterResponseSerializer,
|
|
|
|
|
"پارامتر سنسور با موفقیت ایجاد یا ویرایش شد.",
|
|
|
|
|
),
|
|
|
|
|
400: build_response(
|
|
|
|
|
SensorDataValidationErrorSerializer,
|
|
|
|
|
"داده ورودی نامعتبر است.",
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
examples=[
|
|
|
|
|
OpenApiExample(
|
|
|
|
|
"نمونه درخواست",
|
|
|
|
|
value={
|
|
|
|
|
"sensor_key": "sensor-7-1",
|
|
|
|
|
"code": "soil_moisture",
|
|
|
|
|
"name_fa": "رطوبت خاک",
|
|
|
|
|
"unit": "%",
|
|
|
|
|
"data_type": "float",
|
|
|
|
|
"metadata": {"min": 0, "max": 100},
|
|
|
|
|
},
|
|
|
|
|
request_only=True,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def post(self, request):
|
|
|
|
|
serializer = SensorParameterSerializer(data=request.data)
|
|
|
|
|
if not serializer.is_valid():
|
|
|
|
|
return Response(
|
|
|
|
|
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
|
|
|
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
sensor_key = serializer.validated_data.get("sensor_key")
|
|
|
|
|
code = serializer.validated_data["code"]
|
|
|
|
|
name_fa = serializer.validated_data["name_fa"]
|
|
|
|
|
unit = serializer.validated_data.get("unit", "")
|
|
|
|
|
data_type = serializer.validated_data.get("data_type", "")
|
|
|
|
|
metadata = serializer.validated_data.get("metadata", {})
|
|
|
|
|
|
|
|
|
|
with transaction.atomic():
|
|
|
|
|
parameter, created = SensorParameter.objects.update_or_create(
|
|
|
|
|
sensor_key=sensor_key,
|
|
|
|
|
code=code,
|
|
|
|
|
defaults={
|
|
|
|
|
"name_fa": name_fa,
|
|
|
|
|
"unit": unit,
|
|
|
|
|
"data_type": data_type,
|
|
|
|
|
"metadata": metadata,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
action = (
|
|
|
|
|
ParameterUpdateLog.ACTION_ADDED
|
|
|
|
|
if created
|
|
|
|
|
else ParameterUpdateLog.ACTION_MODIFIED
|
|
|
|
|
)
|
|
|
|
|
ParameterUpdateLog.objects.create(
|
|
|
|
|
parameter=parameter,
|
|
|
|
|
action=action,
|
|
|
|
|
payload={
|
|
|
|
|
"sensor_key": parameter.sensor_key,
|
|
|
|
|
"code": parameter.code,
|
|
|
|
|
"name_fa": parameter.name_fa,
|
|
|
|
|
"unit": parameter.unit,
|
|
|
|
|
"data_type": parameter.data_type,
|
|
|
|
|
"metadata": parameter.metadata,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
|
"code": 201,
|
|
|
|
|
"msg": "success",
|
|
|
|
|
"data": {
|
|
|
|
|
"id": parameter.id,
|
|
|
|
|
"sensor_key": parameter.sensor_key,
|
|
|
|
|
"code": parameter.code,
|
|
|
|
|
"name_fa": parameter.name_fa,
|
|
|
|
|
"unit": parameter.unit,
|
|
|
|
|
"data_type": parameter.data_type,
|
|
|
|
|
"metadata": parameter.metadata,
|
|
|
|
|
"created_at": parameter.created_at,
|
|
|
|
|
"action": action,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
status=status.HTTP_201_CREATED,
|
|
|
|
|
)
|