UPDATE
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_devices_from_sensor_payload(apps, schema_editor):
|
||||
SensorData = apps.get_model("sensor_data", "SensorData")
|
||||
Device = apps.get_model("sensor_data", "Device")
|
||||
|
||||
for farm in SensorData.objects.all().iterator():
|
||||
sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {}
|
||||
location_id = getattr(farm, "center_location_id", None)
|
||||
if location_id is None:
|
||||
continue
|
||||
|
||||
for sensor_name, payload in sensor_payload.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
|
||||
cluster_uuid = None
|
||||
raw_cluster_uuid = payload.get("cluster_uuid")
|
||||
if raw_cluster_uuid not in (None, ""):
|
||||
try:
|
||||
cluster_uuid = uuid.UUID(str(raw_cluster_uuid))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
cluster_uuid = None
|
||||
|
||||
Device.objects.update_or_create(
|
||||
farm_id=farm.pk,
|
||||
sensor_name=sensor_name,
|
||||
defaults={
|
||||
"location_id": location_id,
|
||||
"payload": payload,
|
||||
"cluster_uuid": cluster_uuid,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("location_data", "0019_cluster_block_centers"),
|
||||
("sensor_data", "0012_plant_catalog_snapshot_and_assignment"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Device",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("sensor_name", models.CharField(db_index=True, help_text='نام سنسور مثل "sensor-7-1"', max_length=64)),
|
||||
("payload", models.JSONField(blank=True, default=dict, help_text="payload همان سنسور")),
|
||||
("cluster_uuid", models.UUIDField(blank=True, db_index=True, help_text="uuid کلاستر داخل location برای این سنسور", null=True)),
|
||||
("created_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="devices", to="sensor_data.sensordata")),
|
||||
("location", models.ForeignKey(db_column="location_id", help_text="location مرتبط با این device", on_delete=django.db.models.deletion.CASCADE, related_name="devices", to="location_data.soillocation")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "device",
|
||||
"verbose_name_plural": "devices",
|
||||
"db_table": "farm_data_device",
|
||||
"ordering": ["sensor_name", "id"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="device",
|
||||
constraint=models.UniqueConstraint(fields=("farm", "sensor_name"), name="farm_data_unique_device_per_farm_sensor"),
|
||||
),
|
||||
migrations.RunPython(
|
||||
backfill_devices_from_sensor_payload,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -169,6 +169,57 @@ class SensorData(SensorPayloadMixin, models.Model):
|
||||
return [assignment.plant for assignment in self.plant_assignments.select_related("plant").order_by("position", "id")]
|
||||
|
||||
|
||||
class Device(models.Model):
|
||||
"""نسخه نرمالشده هر سنسور داخل farm_data_sensordata."""
|
||||
|
||||
farm = models.ForeignKey(
|
||||
SensorData,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="devices",
|
||||
db_column="farm_uuid",
|
||||
)
|
||||
location = models.ForeignKey(
|
||||
"location_data.SoilLocation",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="devices",
|
||||
db_column="location_id",
|
||||
help_text="location مرتبط با این device",
|
||||
)
|
||||
sensor_name = models.CharField(
|
||||
max_length=64,
|
||||
db_index=True,
|
||||
help_text='نام سنسور مثل "sensor-7-1"',
|
||||
)
|
||||
payload = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="payload همان سنسور",
|
||||
)
|
||||
cluster_uuid = models.UUIDField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="uuid کلاستر داخل location برای این سنسور",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_data_device"
|
||||
ordering = ["sensor_name", "id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["farm", "sensor_name"],
|
||||
name="farm_data_unique_device_per_farm_sensor",
|
||||
)
|
||||
]
|
||||
verbose_name = "device"
|
||||
verbose_name_plural = "devices"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.farm_id}::{self.sensor_name}"
|
||||
|
||||
|
||||
class PlantCatalogSnapshot(models.Model):
|
||||
"""
|
||||
کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژولهای AI.
|
||||
|
||||
+378
-21
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from numbers import Number
|
||||
import logging
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
@@ -17,13 +18,14 @@ from location_data.block_subdivision import create_or_get_block_subdivision
|
||||
from location_data.models import BlockSubdivision, SoilLocation
|
||||
from location_data.satellite_snapshot import (
|
||||
build_block_layout_metric_summary,
|
||||
build_farmer_block_aggregated_snapshot,
|
||||
build_location_block_satellite_snapshots,
|
||||
build_location_satellite_snapshot,
|
||||
)
|
||||
from irrigation.serializers import IrrigationMethodSerializer
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
from .models import (
|
||||
Device,
|
||||
FarmPlantAssignment,
|
||||
ParameterUpdateLog,
|
||||
PlantCatalogSnapshot,
|
||||
@@ -431,6 +433,45 @@ def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[Sen
|
||||
return synced_parameters
|
||||
|
||||
|
||||
def _parse_cluster_uuid(value: object) -> uuid.UUID | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def sync_devices_from_sensor_data(farm: SensorData) -> list[Device]:
|
||||
sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {}
|
||||
location = farm.center_location
|
||||
synced_devices: list[Device] = []
|
||||
|
||||
with transaction.atomic():
|
||||
active_sensor_names: list[str] = []
|
||||
for sensor_name, payload in sensor_payload.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
active_sensor_names.append(sensor_name)
|
||||
device, _created = Device.objects.update_or_create(
|
||||
farm=farm,
|
||||
sensor_name=sensor_name,
|
||||
defaults={
|
||||
"location": location,
|
||||
"payload": payload,
|
||||
"cluster_uuid": _parse_cluster_uuid(payload.get("cluster_uuid")),
|
||||
},
|
||||
)
|
||||
synced_devices.append(device)
|
||||
|
||||
stale_devices = Device.objects.filter(farm=farm)
|
||||
if active_sensor_names:
|
||||
stale_devices = stale_devices.exclude(sensor_name__in=active_sensor_names)
|
||||
stale_devices.delete()
|
||||
|
||||
return synced_devices
|
||||
|
||||
|
||||
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):
|
||||
@@ -464,24 +505,10 @@ def get_farm_details(farm_uuid: str):
|
||||
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
|
||||
)
|
||||
|
||||
latest_satellite = build_location_satellite_snapshot(center_location)
|
||||
block_metric_snapshots = build_location_block_satellite_snapshots(
|
||||
soil_snapshot = _build_farm_soil_snapshot(
|
||||
center_location,
|
||||
sensor_payload=farm.sensor_payload,
|
||||
)
|
||||
if all(
|
||||
snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics")
|
||||
for snapshot in block_metric_snapshots
|
||||
):
|
||||
block_metric_snapshots = []
|
||||
soil_metrics = dict(latest_satellite.get("resolved_metrics") or {})
|
||||
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload)
|
||||
|
||||
resolved_metrics = dict(soil_metrics)
|
||||
metric_sources = {key: "remote_sensing" for key in soil_metrics}
|
||||
for key, value in sensor_metrics.items():
|
||||
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]
|
||||
@@ -501,11 +528,7 @@ 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,
|
||||
"satellite_snapshots": block_metric_snapshots,
|
||||
},
|
||||
"soil": soil_snapshot,
|
||||
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
|
||||
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
|
||||
"plant_assignments": [
|
||||
@@ -528,9 +551,343 @@ def get_farm_details(farm_uuid: str):
|
||||
),
|
||||
"created_at": farm.created_at,
|
||||
"updated_at": farm.updated_at,
|
||||
"source_metadata": {
|
||||
"soil": soil_snapshot.get("source_metadata", {}),
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"scope": "location_center_based",
|
||||
"location_id": center_location.id,
|
||||
"note": "Weather remains tied to the farm center location.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_farm_soil_snapshot(
|
||||
center_location: SoilLocation,
|
||||
*,
|
||||
sensor_payload: dict | None,
|
||||
) -> dict[str, object]:
|
||||
# Canonical farm soil metrics now come from farmer-level block aggregation.
|
||||
aggregated_snapshot = build_farmer_block_aggregated_snapshot(
|
||||
center_location,
|
||||
sensor_payload=sensor_payload,
|
||||
)
|
||||
block_snapshots = build_location_block_satellite_snapshots(
|
||||
center_location,
|
||||
sensor_payload=sensor_payload,
|
||||
)
|
||||
if all(
|
||||
snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics")
|
||||
for snapshot in block_snapshots
|
||||
):
|
||||
block_snapshots = []
|
||||
|
||||
has_explicit_blocks = bool((center_location.block_layout or {}).get("blocks"))
|
||||
resolved_metrics = dict(aggregated_snapshot.get("resolved_metrics") or {})
|
||||
metric_sources = dict(aggregated_snapshot.get("metric_sources") or {})
|
||||
compatibility_sensor_overlay_applied = False
|
||||
|
||||
if not has_explicit_blocks:
|
||||
compatibility_sensor_overlay_applied = _merge_legacy_sensor_metrics_if_missing(
|
||||
resolved_metrics,
|
||||
metric_sources,
|
||||
sensor_payload,
|
||||
)
|
||||
|
||||
cluster_breakdown = _build_cluster_breakdown(block_snapshots)
|
||||
return {
|
||||
"resolved_metrics": resolved_metrics,
|
||||
"metric_sources": metric_sources,
|
||||
"block_snapshots": block_snapshots,
|
||||
"satellite_snapshots": block_snapshots,
|
||||
"cluster_breakdown": cluster_breakdown,
|
||||
"source_metadata": {
|
||||
"canonical_source": "farmer_block_aggregated_snapshot",
|
||||
"aggregation_strategy": aggregated_snapshot.get("aggregation_strategy") or "missing",
|
||||
"status": aggregated_snapshot.get("status") or "missing",
|
||||
"block_count": int(aggregated_snapshot.get("block_count") or len(block_snapshots)),
|
||||
"has_explicit_blocks": has_explicit_blocks,
|
||||
"compatibility_sensor_overlay_applied": compatibility_sensor_overlay_applied,
|
||||
"policy": {
|
||||
"sensor": "cluster_mean -> block_mean -> farm_mean",
|
||||
"satellite": "cluster_mean -> block_mean -> farm_mean",
|
||||
"weather": "location_center_based",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _merge_legacy_sensor_metrics_if_missing(
|
||||
resolved_metrics: dict,
|
||||
metric_sources: dict,
|
||||
sensor_payload: dict | None,
|
||||
) -> bool:
|
||||
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(sensor_payload)
|
||||
applied = False
|
||||
for metric_name, metric_value in sensor_metrics.items():
|
||||
if metric_name in resolved_metrics:
|
||||
continue
|
||||
resolved_metrics[metric_name] = metric_value
|
||||
metric_sources[metric_name] = sensor_metric_sources[metric_name]
|
||||
applied = True
|
||||
return applied
|
||||
|
||||
|
||||
def _build_cluster_breakdown(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
cluster_breakdown: list[dict[str, object]] = []
|
||||
for snapshot in block_snapshots:
|
||||
block_code = str(snapshot.get("block_code") or "").strip()
|
||||
for sub_block in snapshot.get("satellite_sub_blocks") or []:
|
||||
cluster_breakdown.append(
|
||||
{
|
||||
"block_code": block_code,
|
||||
"source": "satellite",
|
||||
**dict(sub_block),
|
||||
}
|
||||
)
|
||||
for sub_block in snapshot.get("sensor_sub_blocks") or []:
|
||||
cluster_breakdown.append(
|
||||
{
|
||||
"block_code": block_code,
|
||||
"source": "sensor",
|
||||
**dict(sub_block),
|
||||
}
|
||||
)
|
||||
return cluster_breakdown
|
||||
|
||||
|
||||
AI_FARM_AGGREGATION_POLICY = {
|
||||
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
|
||||
"weather": "center_location_latest_forecast",
|
||||
"default_block_policy": "1_main_block + 1_default_sub_block_when_missing",
|
||||
}
|
||||
|
||||
|
||||
def build_ai_farm_snapshot(farm_uuid: str) -> dict[str, object] | None:
|
||||
farm = get_canonical_farm_record(farm_uuid)
|
||||
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:
|
||||
weather = center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
|
||||
|
||||
soil_snapshot = _build_farm_soil_snapshot(
|
||||
center_location,
|
||||
sensor_payload=farm.sensor_payload,
|
||||
)
|
||||
block_metrics = _build_ai_block_metrics(soil_snapshot.get("block_snapshots") or [])
|
||||
sub_block_metrics = _build_ai_sub_block_metrics(soil_snapshot.get("block_snapshots") or [])
|
||||
plant_assignments = get_farm_plant_assignments(farm)
|
||||
|
||||
return {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"aggregation_policy": dict(AI_FARM_AGGREGATION_POLICY),
|
||||
"farm_metrics": {
|
||||
"resolved_metrics": dict(soil_snapshot.get("resolved_metrics") or {}),
|
||||
"metric_sources": dict(soil_snapshot.get("metric_sources") or {}),
|
||||
"status": (soil_snapshot.get("source_metadata") or {}).get("status", "missing"),
|
||||
"aggregation_strategy": (soil_snapshot.get("source_metadata") or {}).get(
|
||||
"aggregation_strategy", "missing"
|
||||
),
|
||||
},
|
||||
"block_metrics": block_metrics,
|
||||
"sub_block_metrics": sub_block_metrics,
|
||||
"weather": {
|
||||
"forecast": WeatherForecastDetailSerializer(weather).data if weather else None,
|
||||
"source_metadata": {
|
||||
"source": "center_location_forecast",
|
||||
"scope": "location_center_based",
|
||||
"location_id": center_location.id,
|
||||
"status": "completed" if weather else "missing",
|
||||
},
|
||||
},
|
||||
"plants": [
|
||||
{
|
||||
"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,
|
||||
"details": (
|
||||
IrrigationMethodSerializer(farm.irrigation_method).data
|
||||
if farm.irrigation_method
|
||||
else None
|
||||
),
|
||||
"source_metadata": {
|
||||
"source": "farm_record",
|
||||
"status": "completed" if farm.irrigation_method_id else "missing",
|
||||
},
|
||||
},
|
||||
"source_metadata": {
|
||||
"farm": {
|
||||
"farm_uuid": str(farm.farm_uuid),
|
||||
"center_location_id": center_location.id,
|
||||
"has_explicit_blocks": bool((center_location.block_layout or {}).get("blocks")),
|
||||
},
|
||||
"farm_metrics": dict(soil_snapshot.get("source_metadata") or {}),
|
||||
"block_metrics": {
|
||||
"source": "build_location_block_satellite_snapshots",
|
||||
"block_count": len(block_metrics),
|
||||
"status": "completed" if block_metrics else "missing",
|
||||
},
|
||||
"sub_block_metrics": {
|
||||
"source": "block_snapshot_sub_blocks",
|
||||
"sub_block_count": len(sub_block_metrics),
|
||||
"status": "completed" if sub_block_metrics else "missing",
|
||||
},
|
||||
"weather": {
|
||||
"source": "center_location_forecast",
|
||||
"scope": "location_center_based",
|
||||
"location_id": center_location.id,
|
||||
"note": "Weather remains tied to the farm center location.",
|
||||
},
|
||||
"plants": {
|
||||
"source": "farm_plant_assignments",
|
||||
"count": len(plant_assignments),
|
||||
},
|
||||
"irrigation_method": {
|
||||
"source": "farm_record",
|
||||
"status": "completed" if farm.irrigation_method_id else "missing",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_ai_farm_snapshot_or_details(farm_uuid: str) -> dict[str, object] | None:
|
||||
"""Return the canonical AI snapshot, or fall back to farm details for older consumers."""
|
||||
snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return snapshot
|
||||
|
||||
|
||||
def get_ai_snapshot_metric(snapshot: dict[str, object] | None, metric_name: str) -> object | None:
|
||||
if not isinstance(snapshot, dict):
|
||||
return None
|
||||
farm_metrics = snapshot.get("farm_metrics") or {}
|
||||
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
|
||||
if isinstance(resolved_metrics, dict):
|
||||
return resolved_metrics.get(metric_name)
|
||||
return None
|
||||
|
||||
|
||||
def get_ai_snapshot_weather(snapshot: dict[str, object] | None) -> dict[str, object]:
|
||||
if not isinstance(snapshot, dict):
|
||||
return {}
|
||||
weather_section = snapshot.get("weather") or {}
|
||||
forecast = weather_section.get("forecast") if isinstance(weather_section, dict) else None
|
||||
return forecast if isinstance(forecast, dict) else {}
|
||||
|
||||
|
||||
def _build_ai_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
block_metrics: list[dict[str, object]] = []
|
||||
for snapshot in block_snapshots:
|
||||
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
|
||||
block_metrics.append(
|
||||
{
|
||||
"block_code": block_code,
|
||||
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
|
||||
"metric_sources": dict(snapshot.get("metric_sources") or {}),
|
||||
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
|
||||
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
|
||||
"status": snapshot.get("status") or "missing",
|
||||
"aggregation_strategy": snapshot.get("aggregation_strategy") or "missing",
|
||||
"sub_block_count": int(snapshot.get("sub_block_count") or 0),
|
||||
"temporal_extent": snapshot.get("temporal_extent"),
|
||||
"source_metadata": {
|
||||
"source": "build_location_block_satellite_snapshots",
|
||||
"block_code": block_code,
|
||||
"run_id": snapshot.get("run_id"),
|
||||
"cell_count": snapshot.get("cell_count"),
|
||||
},
|
||||
}
|
||||
)
|
||||
return block_metrics
|
||||
|
||||
|
||||
def _build_ai_sub_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
sub_block_metrics: list[dict[str, object]] = []
|
||||
for snapshot in block_snapshots:
|
||||
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
|
||||
satellite_sub_blocks = snapshot.get("satellite_sub_blocks") or []
|
||||
sensor_sub_blocks = snapshot.get("sensor_sub_blocks") or []
|
||||
if not satellite_sub_blocks and not sensor_sub_blocks:
|
||||
sub_block_metrics.append(
|
||||
{
|
||||
"block_code": block_code,
|
||||
"sub_block_code": "default-sub-block",
|
||||
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
|
||||
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
|
||||
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
|
||||
"status": snapshot.get("status") or "missing",
|
||||
"source_metadata": {
|
||||
"source": "default_sub_block_compatibility",
|
||||
"scope": "future_default_policy",
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
sub_blocks_by_code: dict[str, dict[str, object]] = {}
|
||||
for sub_block in satellite_sub_blocks:
|
||||
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
|
||||
entry = sub_blocks_by_code.setdefault(
|
||||
sub_block_code,
|
||||
{
|
||||
"block_code": block_code,
|
||||
"sub_block_code": sub_block_code,
|
||||
"resolved_metrics": {},
|
||||
"satellite_metrics": {},
|
||||
"sensor_metrics": {},
|
||||
"status": snapshot.get("status") or "missing",
|
||||
"source_metadata": {
|
||||
"source": "block_snapshot_sub_blocks",
|
||||
"satellite_present": False,
|
||||
"sensor_present": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
entry["satellite_metrics"] = dict(sub_block.get("resolved_metrics") or {})
|
||||
entry["resolved_metrics"].update(entry["satellite_metrics"])
|
||||
entry["source_metadata"]["satellite_present"] = True
|
||||
for sub_block in sensor_sub_blocks:
|
||||
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
|
||||
entry = sub_blocks_by_code.setdefault(
|
||||
sub_block_code,
|
||||
{
|
||||
"block_code": block_code,
|
||||
"sub_block_code": sub_block_code,
|
||||
"resolved_metrics": {},
|
||||
"satellite_metrics": {},
|
||||
"sensor_metrics": {},
|
||||
"status": snapshot.get("status") or "missing",
|
||||
"source_metadata": {
|
||||
"source": "block_snapshot_sub_blocks",
|
||||
"satellite_present": False,
|
||||
"sensor_present": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
entry["sensor_metrics"] = dict(sub_block.get("resolved_metrics") or {})
|
||||
entry["resolved_metrics"].update(entry["sensor_metrics"])
|
||||
entry["source_metadata"]["sensor_present"] = True
|
||||
sub_block_metrics.extend(sub_blocks_by_code.values())
|
||||
return sub_block_metrics
|
||||
|
||||
|
||||
def resolve_center_location_from_boundary(
|
||||
farm_boundary: dict | list,
|
||||
block_count: int = 1,
|
||||
|
||||
@@ -15,9 +15,10 @@ from location_data.models import (
|
||||
RemoteSensingSubdivisionResult,
|
||||
SoilLocation,
|
||||
)
|
||||
from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter
|
||||
from farm_data.models import Device, PlantCatalogSnapshot, SensorData, SensorParameter
|
||||
from farm_data.services import (
|
||||
assign_farm_plants_from_backend_ids,
|
||||
build_ai_farm_snapshot,
|
||||
get_canonical_farm_record,
|
||||
get_runtime_plant_for_farm,
|
||||
list_runtime_plants_for_farm,
|
||||
@@ -356,6 +357,309 @@ class FarmDetailApiTests(TestCase):
|
||||
0.6,
|
||||
)
|
||||
|
||||
def test_detail_uses_farmer_aggregated_snapshot_as_canonical_soil_source(self):
|
||||
self.location.block_layout = {
|
||||
"blocks": [
|
||||
{"block_code": "block-a", "order": 1},
|
||||
{"block_code": "block-b", "order": 2},
|
||||
]
|
||||
}
|
||||
self.location.save(update_fields=["block_layout", "updated_at"])
|
||||
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 2,
|
||||
"resolved_metrics": {"nitrogen": 42.0, "ndvi": 0.61},
|
||||
"metric_sources": {
|
||||
"nitrogen": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2},
|
||||
"ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2},
|
||||
},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-a",
|
||||
"resolved_metrics": {"nitrogen": 20.0, "ndvi": 0.5},
|
||||
"metric_sources": {},
|
||||
"satellite_sub_blocks": [{"sub_block_code": "cluster-a"}],
|
||||
"sensor_sub_blocks": [],
|
||||
},
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-b",
|
||||
"resolved_metrics": {"nitrogen": 64.0, "ndvi": 0.72},
|
||||
"metric_sources": {},
|
||||
"satellite_sub_blocks": [{"sub_block_code": "cluster-b"}],
|
||||
"sensor_sub_blocks": [],
|
||||
},
|
||||
]
|
||||
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 42.0)
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.61)
|
||||
self.assertEqual(payload["soil"]["source_metadata"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
self.assertEqual(payload["soil"]["source_metadata"]["policy"]["sensor"], "cluster_mean -> block_mean -> farm_mean")
|
||||
self.assertEqual(payload["source_metadata"]["weather"]["scope"], "location_center_based")
|
||||
self.assertEqual(len(payload["soil"]["block_snapshots"]), 2)
|
||||
self.assertEqual(len(payload["soil"]["cluster_breakdown"]), 2)
|
||||
aggregated_mock.assert_called_once()
|
||||
block_mock.assert_called_once()
|
||||
|
||||
def test_detail_without_explicit_blocks_keeps_aggregated_snapshot_and_marks_compatibility_policy(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"ndvi": 0.55},
|
||||
"metric_sources": {
|
||||
"ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1},
|
||||
},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "",
|
||||
"resolved_metrics": {"ndvi": 0.55},
|
||||
"metric_sources": {},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [],
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.55)
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 99.0)
|
||||
self.assertFalse(payload["soil"]["source_metadata"]["has_explicit_blocks"])
|
||||
self.assertTrue(payload["soil"]["source_metadata"]["compatibility_sensor_overlay_applied"])
|
||||
self.assertEqual(payload["soil"]["metric_sources"]["nitrogen"]["type"], "sensor")
|
||||
|
||||
def test_detail_canonical_soil_metrics_do_not_come_from_single_raw_location_snapshot(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"soil_moisture": 12.0},
|
||||
"metric_sources": {
|
||||
"soil_moisture": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1},
|
||||
},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-1",
|
||||
"resolved_metrics": {"soil_moisture": 77.0},
|
||||
"metric_sources": {},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [],
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["soil"]["resolved_metrics"]["soil_moisture"], 12.0)
|
||||
self.assertNotEqual(
|
||||
payload["soil"]["resolved_metrics"]["soil_moisture"],
|
||||
payload["soil"]["block_snapshots"][0]["resolved_metrics"]["soil_moisture"],
|
||||
)
|
||||
|
||||
|
||||
class BuildAiFarmSnapshotTests(TestCase):
|
||||
def setUp(self):
|
||||
self.location = SoilLocation.objects.create(
|
||||
latitude="35.700000",
|
||||
longitude="51.400000",
|
||||
farm_boundary={"type": "Polygon", "coordinates": []},
|
||||
)
|
||||
self.weather = WeatherForecast.objects.create(
|
||||
location=self.location,
|
||||
forecast_date=date(2026, 4, 10),
|
||||
temperature_min=12.0,
|
||||
temperature_max=23.0,
|
||||
temperature_mean=18.0,
|
||||
precipitation=1.2,
|
||||
humidity_mean=52.0,
|
||||
)
|
||||
self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=201, name="ذرت")
|
||||
self.irrigation_method = IrrigationMethod.objects.create(name="تیپ")
|
||||
self.farm_uuid = uuid.uuid4()
|
||||
self.farm = SensorData.objects.create(
|
||||
farm_uuid=self.farm_uuid,
|
||||
center_location=self.location,
|
||||
weather_forecast=self.weather,
|
||||
irrigation_method=self.irrigation_method,
|
||||
sensor_payload={"sensor-1": {"soil_moisture": 30.0}},
|
||||
)
|
||||
assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id])
|
||||
|
||||
def test_build_ai_farm_snapshot_returns_normalized_block_and_sub_block_metrics(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0},
|
||||
"metric_sources": {"ndvi": {"type": "farmer_block"}, "soil_moisture": {"type": "farmer_block"}},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-1",
|
||||
"aggregation_strategy": "sub_block_mean",
|
||||
"resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0},
|
||||
"metric_sources": {"ndvi": {"type": "satellite"}, "soil_moisture": {"type": "sensor"}},
|
||||
"satellite_metrics": {"ndvi": 0.6},
|
||||
"sensor_metrics": {"soil_moisture": 24.0},
|
||||
"sub_block_count": 2,
|
||||
"run_id": 91,
|
||||
"cell_count": 8,
|
||||
"temporal_extent": {"start_date": "2026-04-01", "end_date": "2026-04-30"},
|
||||
"satellite_sub_blocks": [
|
||||
{"sub_block_code": "cluster-a", "resolved_metrics": {"ndvi": 0.5}},
|
||||
{"sub_block_code": "cluster-b", "resolved_metrics": {"ndvi": 0.7}},
|
||||
],
|
||||
"sensor_sub_blocks": [
|
||||
{"sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 20.0}},
|
||||
{"sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 28.0}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||
|
||||
self.assertIsNotNone(snapshot)
|
||||
self.assertEqual(snapshot["farm_uuid"], str(self.farm_uuid))
|
||||
self.assertEqual(snapshot["aggregation_policy"]["sensor"], "cluster_mean_then_block_mean_then_farm_mean")
|
||||
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 24.0)
|
||||
self.assertEqual(snapshot["block_metrics"][0]["block_code"], "block-1")
|
||||
self.assertEqual(snapshot["block_metrics"][0]["source_metadata"]["run_id"], 91)
|
||||
self.assertEqual(len(snapshot["sub_block_metrics"]), 2)
|
||||
self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["satellite_present"], True)
|
||||
self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["sensor_present"], True)
|
||||
self.assertEqual(snapshot["weather"]["source_metadata"]["scope"], "location_center_based")
|
||||
self.assertEqual(len(snapshot["plants"]), 1)
|
||||
self.assertEqual(snapshot["irrigation_method"]["details"]["name"], self.irrigation_method.name)
|
||||
|
||||
def test_build_ai_farm_snapshot_without_explicit_subdivisions_uses_default_compatibility_shape(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"ndvi": 0.55},
|
||||
"metric_sources": {"ndvi": {"type": "farmer_block"}},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "",
|
||||
"resolved_metrics": {"ndvi": 0.55},
|
||||
"metric_sources": {"ndvi": {"type": "satellite"}},
|
||||
"satellite_metrics": {"ndvi": 0.55},
|
||||
"sensor_metrics": {},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [],
|
||||
}
|
||||
]
|
||||
|
||||
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||
|
||||
self.assertEqual(snapshot["block_metrics"][0]["block_code"], "default-block")
|
||||
self.assertEqual(snapshot["sub_block_metrics"][0]["sub_block_code"], "default-sub-block")
|
||||
self.assertEqual(
|
||||
snapshot["sub_block_metrics"][0]["source_metadata"]["source"],
|
||||
"default_sub_block_compatibility",
|
||||
)
|
||||
self.assertEqual(
|
||||
snapshot["aggregation_policy"]["default_block_policy"],
|
||||
"1_main_block + 1_default_sub_block_when_missing",
|
||||
)
|
||||
|
||||
def test_build_ai_farm_snapshot_handles_missing_sensor_data(self):
|
||||
self.farm.sensor_payload = None
|
||||
self.farm.save(update_fields=["sensor_payload"])
|
||||
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"ndvi": 0.49},
|
||||
"metric_sources": {"ndvi": {"type": "farmer_block"}},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-1",
|
||||
"resolved_metrics": {"ndvi": 0.49},
|
||||
"metric_sources": {"ndvi": {"type": "satellite"}},
|
||||
"satellite_metrics": {"ndvi": 0.49},
|
||||
"sensor_metrics": {},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [],
|
||||
}
|
||||
]
|
||||
|
||||
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||
|
||||
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"], {"ndvi": 0.49})
|
||||
self.assertEqual(snapshot["block_metrics"][0]["sensor_metrics"], {})
|
||||
|
||||
def test_build_ai_farm_snapshot_handles_missing_remote_sensing_data(self):
|
||||
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||
"farm_data.services.build_location_block_satellite_snapshots"
|
||||
) as block_mock:
|
||||
aggregated_mock.return_value = {
|
||||
"status": "completed",
|
||||
"aggregation_strategy": "farmer_block_mean",
|
||||
"block_count": 1,
|
||||
"resolved_metrics": {"soil_moisture": 30.0},
|
||||
"metric_sources": {"soil_moisture": {"type": "farmer_block"}},
|
||||
}
|
||||
block_mock.return_value = [
|
||||
{
|
||||
"status": "completed",
|
||||
"block_code": "block-1",
|
||||
"resolved_metrics": {"soil_moisture": 30.0},
|
||||
"metric_sources": {"soil_moisture": {"type": "sensor"}},
|
||||
"satellite_metrics": {},
|
||||
"sensor_metrics": {"soil_moisture": 30.0},
|
||||
"satellite_sub_blocks": [],
|
||||
"sensor_sub_blocks": [
|
||||
{"sub_block_code": "cluster-1", "resolved_metrics": {"soil_moisture": 30.0}}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||
|
||||
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 30.0)
|
||||
self.assertEqual(snapshot["block_metrics"][0]["satellite_metrics"], {})
|
||||
self.assertEqual(snapshot["sub_block_metrics"][0]["sensor_metrics"]["soil_moisture"], 30.0)
|
||||
|
||||
|
||||
class FarmDataUpsertApiTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -406,6 +710,10 @@ class FarmDataUpsertApiTests(TestCase):
|
||||
farm.sensor_payload["sensor-7-1"]["soil_moisture"],
|
||||
31.2,
|
||||
)
|
||||
device = Device.objects.get(farm=farm, sensor_name="sensor-7-1")
|
||||
self.assertEqual(device.location_id, self.location.id)
|
||||
self.assertEqual(device.payload["soil_moisture"], 31.2)
|
||||
self.assertIsNone(device.cluster_uuid)
|
||||
self.assertTrue(
|
||||
SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists()
|
||||
)
|
||||
@@ -444,6 +752,57 @@ class FarmDataUpsertApiTests(TestCase):
|
||||
["disease_pressure_index", "leaf_temperature", "leaf_wetness"],
|
||||
)
|
||||
|
||||
def test_post_syncs_device_rows_from_sensor_payload(self):
|
||||
farm_uuid = uuid.uuid4()
|
||||
cluster_uuid = uuid.uuid4()
|
||||
|
||||
create_response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": self.boundary,
|
||||
"sensor_payload": {
|
||||
"sensor-7-1": {
|
||||
"cluster_uuid": str(cluster_uuid),
|
||||
"soil_moisture": 31.2,
|
||||
},
|
||||
"leaf-sensor": {
|
||||
"leaf_wetness": 10.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(create_response.status_code, 201)
|
||||
farm = SensorData.objects.get(farm_uuid=farm_uuid)
|
||||
self.assertEqual(Device.objects.filter(farm=farm).count(), 2)
|
||||
soil_device = Device.objects.get(farm=farm, sensor_name="sensor-7-1")
|
||||
self.assertEqual(str(soil_device.cluster_uuid), str(cluster_uuid))
|
||||
self.assertEqual(soil_device.payload["soil_moisture"], 31.2)
|
||||
|
||||
update_response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": self.boundary,
|
||||
"sensor_payload": {
|
||||
"sensor-7-1": {
|
||||
"cluster_uuid": str(cluster_uuid),
|
||||
"soil_moisture": 33.8,
|
||||
"nitrogen": 20.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(update_response.status_code, 200)
|
||||
soil_device.refresh_from_db()
|
||||
self.assertEqual(soil_device.payload["soil_moisture"], 33.8)
|
||||
self.assertEqual(soil_device.payload["nitrogen"], 20.5)
|
||||
self.assertEqual(Device.objects.filter(farm=farm, sensor_name="leaf-sensor").count(), 1)
|
||||
|
||||
def test_post_requires_farm_uuid_in_request_body(self):
|
||||
response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
|
||||
@@ -28,6 +28,7 @@ from .services import (
|
||||
ensure_location_and_weather_data,
|
||||
get_farm_details,
|
||||
resolve_center_location_from_boundary,
|
||||
sync_devices_from_sensor_data,
|
||||
sync_sensor_parameters_from_payload,
|
||||
sync_plant_catalog_from_backend,
|
||||
)
|
||||
@@ -239,6 +240,8 @@ class FarmDataUpsertView(APIView):
|
||||
else:
|
||||
farm_data.save()
|
||||
|
||||
sync_devices_from_sensor_data(farm_data)
|
||||
|
||||
if plant_ids is not None:
|
||||
try:
|
||||
assign_farm_plants_from_backend_ids(farm_data, plant_ids)
|
||||
@@ -280,6 +283,13 @@ class FarmDetailView(APIView):
|
||||
"برای resolved_metrics، دادههای sensor روی دادههای خاک اولویت دارند "
|
||||
"و در حالت چند سنسوره، مقادیر متعارض بهصورت deterministic تجمیع میشوند."
|
||||
),
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه مسیر farm detail",
|
||||
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
|
||||
parameter_only=("farm_uuid", "path"),
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: build_response(
|
||||
FarmDetailEnvelopeSerializer,
|
||||
|
||||
Reference in New Issue
Block a user