2 Commits

Author SHA1 Message Date
sajad-dev 86cea06f3a UPDATE 2026-05-14 22:58:32 +03:30
sajad-dev a4763265bf UPDATE 2026-05-13 22:29:18 +03:30
22 changed files with 3491 additions and 695 deletions
Binary file not shown.
+1 -3
View File
@@ -18,14 +18,12 @@ urlpatterns = [
path("api/crop-health/", include("crop_health.urls")),
path("api/soil/", include("soil.urls")),
path("api/crop-zoning/", include("crop_zoning.urls")),
path("api/location-data/", include("crop_zoning.urls")),
# path("api/yield-harvest/", include("yield_harvest.urls")),
path("api/yield-harvest/", include("yield_harvest.crop_simulation_urls")),
path("api/pest-detection/", include("pest_detection.urls")),
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
path("api/sensor-7-in-1/", include("device_hub.sensor_7_in_1_urls")),
path("api/sensors/", include("device_hub.comparison_urls")),
path("api/irrigation/", include("irrigation.urls")),
path("api/weather/", include("water.weather_urls")),
+37
View File
@@ -0,0 +1,37 @@
from rest_framework import serializers
class LocationDataQuerySerializer(serializers.Serializer):
lat = serializers.DecimalField(max_digits=18, decimal_places=12, required=False)
lon = serializers.DecimalField(max_digits=18, decimal_places=12, required=False)
farm_uuid = serializers.UUIDField(required=False)
class LocationDataUpsertSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=False)
lat = serializers.DecimalField(max_digits=18, decimal_places=12, required=False)
lon = serializers.DecimalField(max_digits=18, decimal_places=12, required=False)
farm_boundary = serializers.JSONField(required=False)
block_layout = serializers.JSONField(required=False)
class FarmUUIDRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True)
class RemoteSensingQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True)
page = serializers.IntegerField(required=False, min_value=1)
page_size = serializers.IntegerField(required=False, min_value=1)
start_date = serializers.DateField(required=False)
end_date = serializers.DateField(required=False)
class ClusterBlockLiveQuerySerializer(serializers.Serializer):
start_date = serializers.DateField(required=False)
end_date = serializers.DateField(required=False)
farm_uuid = serializers.UUIDField(required=False)
class KOptionActivateSerializer(serializers.Serializer):
requested_k = serializers.IntegerField(min_value=1)
+619 -78
View File
@@ -1,9 +1,11 @@
import math
import hashlib
from copy import deepcopy
from decimal import Decimal
from datetime import timedelta
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from celery.result import AsyncResult
from kombu.exceptions import OperationalError
from django.db import transaction
@@ -58,6 +60,9 @@ TASK_STATE_RETRY = "RETRY"
TASK_STATE_SUCCESS = "SUCCESS"
TASK_STATE_FAILURE = "FAILURE"
TASK_STATE_REVOKED = "REVOKED"
AI_LOCATION_DATA_PATH = "/api/location-data/"
AI_REMOTE_SENSING_PATH = "/api/location-data/remote-sensing/"
AI_CLUSTER_RECOMMENDATIONS_PATH = "/api/location-data/remote-sensing/cluster-recommendations/"
def get_default_cell_side_km():
@@ -544,8 +549,101 @@ def build_area_zone_payload(zone):
return base_payload
def _build_area_layer_zone_base_payload(zone):
def _serialize_cluster_candidate(candidate_payload):
if not isinstance(candidate_payload, dict):
return None
return {
"plantId": candidate_payload.get("plant_id"),
"plantName": str(candidate_payload.get("plant_name") or ""),
"position": candidate_payload.get("position"),
"stage": str(candidate_payload.get("stage") or ""),
"score": candidate_payload.get("score"),
"predictedYield": candidate_payload.get("predicted_yield"),
"predictedYieldTons": candidate_payload.get("predicted_yield_tons"),
"biomass": candidate_payload.get("biomass"),
"maxLai": candidate_payload.get("max_lai"),
"simulationEngine": candidate_payload.get("simulation_engine"),
"simulationModelName": candidate_payload.get("simulation_model_name"),
"simulationWarning": str(candidate_payload.get("simulation_warning") or ""),
"supportingMetrics": deepcopy(candidate_payload.get("supporting_metrics") or {}),
}
def _get_zone_ai_cluster_payload(zone):
analysis = getattr(zone, "analysis", None)
raw_response = getattr(analysis, "raw_response", None)
if not isinstance(raw_response, dict):
return {}
cluster_payload = raw_response.get("cluster_recommendation") or {}
if isinstance(cluster_payload, dict):
return cluster_payload
return {}
def _build_zone_cluster_info(zone, cluster_payload):
cluster_block = cluster_payload.get("cluster_block") or {}
return {
"blockCode": str(cluster_payload.get("block_code") or ""),
"clusterUuid": str(cluster_payload.get("cluster_uuid") or cluster_block.get("uuid") or zone.zone_id),
"subBlockCode": str(cluster_payload.get("sub_block_code") or cluster_block.get("sub_block_code") or zone.zone_id),
"clusterLabel": cluster_payload.get("cluster_label"),
"cellCount": cluster_block.get("cell_count"),
"cellCodes": deepcopy(cluster_block.get("cell_codes") or []),
"centerCellCode": cluster_block.get("center_cell_code"),
"centerCellLat": cluster_block.get("center_cell_lat"),
"centerCellLon": cluster_block.get("center_cell_lon"),
"sourceMetadata": deepcopy(cluster_payload.get("source_metadata") or {}),
}
def _build_zone_cluster_metrics(cluster_payload):
if not cluster_payload:
return {
"satelliteMetrics": {},
"sensorMetrics": {},
"resolvedMetrics": {},
"criteria": [],
}
suggested_plant = cluster_payload.get("suggested_plant")
return {
"satelliteMetrics": deepcopy(cluster_payload.get("satellite_metrics") or {}),
"sensorMetrics": deepcopy(cluster_payload.get("sensor_metrics") or {}),
"resolvedMetrics": deepcopy(cluster_payload.get("resolved_metrics") or {}),
"criteria": _build_metric_criteria(cluster_payload, suggested_plant),
}
def _build_zone_crop_prediction(cluster_payload):
if not cluster_payload:
return {"suggestedPlant": None, "candidatePlants": []}
return {
"suggestedPlant": _serialize_cluster_candidate(cluster_payload.get("suggested_plant")),
"candidatePlants": [
item
for item in (
_serialize_cluster_candidate(candidate_payload)
for candidate_payload in (cluster_payload.get("candidate_plants") or [])
)
if item is not None
],
}
def _attach_ai_zone_payload(base_payload, zone):
cluster_payload = _get_zone_ai_cluster_payload(zone)
base_payload["clusterInfo"] = _build_zone_cluster_info(zone, cluster_payload)
base_payload["clusterMetrics"] = _build_zone_cluster_metrics(cluster_payload)
base_payload["cropPrediction"] = _build_zone_crop_prediction(cluster_payload)
return base_payload
def _build_area_layer_zone_base_payload(zone):
return _attach_ai_zone_payload(
{
"zoneId": zone.zone_id,
"zoneUuid": str(zone.uuid),
"geometry": zone.geometry,
@@ -555,7 +653,9 @@ def _build_area_layer_zone_base_payload(zone):
"sequence": zone.sequence,
"processing_status": zone.processing_status,
"processing_error": zone.processing_error,
}
},
zone,
)
def build_water_need_area_zone_payload(zone):
@@ -907,73 +1007,478 @@ def get_farm_for_uuid(farm_uuid, owner=None):
raise ValueError("Farm not found.") from exc
def _raise_ai_response_error(response, default_message):
payload = response.data if isinstance(response.data, dict) else {}
message = payload.get("msg") or payload.get("message") or default_message
if response.status_code >= 500:
raise ImproperlyConfigured(message)
raise ValueError(message)
def _unwrap_ai_response(response, *, expected_statuses):
if response.status_code not in expected_statuses:
_raise_ai_response_error(response, f"AI location_data API returned status {response.status_code}.")
payload = response.data if isinstance(response.data, dict) else {}
if "data" in payload:
return payload["data"]
return payload
def _request_ai_location_data(path, *, method="GET", payload=None, query=None):
return external_request(
"ai",
path,
method=method,
payload=payload,
query=query,
)
def _feature_from_geometry(geometry):
if not isinstance(geometry, dict):
return get_default_area_feature()
if geometry.get("type") == "Feature":
return normalize_area_feature(geometry)
return normalize_area_feature(
{
"type": "Feature",
"properties": {},
"geometry": geometry,
}
)
def _upsert_crop_area_snapshot(farm, area_feature):
normalized_feature = normalize_area_feature(area_feature)
ring = get_polygon_ring(normalized_feature)
points = normalize_points(ring)
area_sqm = round(polygon_area_sqm(ring), 2)
area_hectares = round(area_sqm / 10000.0, 4)
defaults = {
"geometry": normalized_feature,
"points": points,
"center": calculate_center(points),
"area_sqm": area_sqm,
"area_hectares": area_hectares,
"chunk_area_sqm": round(get_chunk_area_sqm(), 2),
}
crop_area = farm.current_crop_area
if crop_area is None:
crop_area = CropArea.objects.create(
farm=farm,
zone_count=0,
**defaults,
)
farm.current_crop_area = crop_area
farm.save(update_fields=["current_crop_area", "updated_at"])
return crop_area
for field_name, value in defaults.items():
setattr(crop_area, field_name, value)
crop_area.save(update_fields=[*defaults.keys(), "updated_at"])
return crop_area
def _get_farm_area_feature(farm, fallback=None):
if fallback is not None:
return normalize_area_feature(fallback)
crop_area = farm.current_crop_area or farm.crop_areas.order_by("-created_at", "-id").first()
if crop_area is not None and crop_area.geometry:
return normalize_area_feature(crop_area.geometry)
return get_default_area_feature()
def _build_processing_layer_payload(farm, remote_payload, *, page, page_size):
area_feature = _get_farm_area_feature(
farm,
fallback=((remote_payload.get("location") or {}).get("farm_boundary")),
)
location = remote_payload.get("location") or {}
run = remote_payload.get("run") or {}
status_value = str(remote_payload.get("status") or "").lower()
task_status = "PROCESSING" if status_value == "processing" else "PENDING"
return {
"task": {
"status": task_status,
"stage": status_value or "queued",
"stage_label": "در حال دریافت تقسیم بندی و متریک ها از AI",
"area_uuid": str(getattr(farm.current_crop_area, "uuid", "")) if farm.current_crop_area_id else "",
"total_zones": 0,
"completed_zones": 0,
"processing_zones": 0,
"pending_zones": 0,
"failed_zones": 0,
"remaining_zones": 0,
"progress_percent": 0,
"summary": {
"done": 0,
"in_progress": 0,
"remaining": 0,
"failed": 0,
},
"message": "تقسیم بندی و متریک های کشت در AI در حال آماده سازی است.",
"failed_zone_errors": [],
"cell_side_km": round(get_default_cell_side_km(), 4),
"task_id": remote_payload.get("task_id") or (run.get("metadata") or {}).get("task_id"),
},
"area": area_feature,
"zones": [],
"location": location,
"farmerBlocks": deepcopy(((location.get("block_layout") or {}).get("blocks") or [])),
"clusterBlocks": [],
"pagination": {
"page": page,
"page_size": page_size,
"total_pages": 0,
"total_zones": 0,
"returned_zones": 0,
"has_next": False,
"has_previous": False,
},
}
def _hash_color(value):
digest = hashlib.md5(str(value).encode("utf-8")).hexdigest()
return f"#{digest[:6]}"
def _clamp_percent(value, *, default=0):
try:
numeric = float(value)
except (TypeError, ValueError):
return default
return max(0, min(100, round(numeric)))
def _extract_zone_points(geometry):
coordinates = (geometry or {}).get("coordinates") or []
if not coordinates or not coordinates[0]:
return []
ring = coordinates[0]
return ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
def _build_metric_criteria(cluster_payload, suggested_plant):
resolved_metrics = cluster_payload.get("resolved_metrics") or {}
criteria = []
ndvi_score = _clamp_percent((resolved_metrics.get("ndvi") or 0) * 100)
criteria.append({"name": "NDVI", "value": ndvi_score})
ndwi_raw = resolved_metrics.get("ndwi")
ndwi_score = _clamp_percent(((float(ndwi_raw) + 1.0) / 2.0) * 100) if ndwi_raw is not None else 0
criteria.append({"name": "NDWI", "value": ndwi_score})
soil_moisture = resolved_metrics.get("soil_moisture")
if soil_moisture is not None:
criteria.append({"name": "رطوبت خاک", "value": _clamp_percent(soil_moisture)})
nitrogen = resolved_metrics.get("nitrogen")
if nitrogen is not None:
criteria.append({"name": "نیتروژن", "value": _clamp_percent(float(nitrogen) * 4)})
if suggested_plant is not None:
criteria.append({"name": "امتیاز AI", "value": _clamp_percent(suggested_plant.get("score"))})
return criteria[:4]
def _derive_layer_bundle(cluster_payload, suggested_plant):
resolved_metrics = cluster_payload.get("resolved_metrics") or {}
criteria = _build_metric_criteria(cluster_payload, suggested_plant)
soil_score = next((item["value"] for item in criteria if item["name"] in {"NDVI", "نیتروژن"}), 0)
if soil_score >= 75:
soil_level = "high"
elif soil_score >= 45:
soil_level = "medium"
else:
soil_level = "low"
moisture_value = resolved_metrics.get("soil_moisture")
ndwi_raw = resolved_metrics.get("ndwi")
if moisture_value is not None:
water_score = _clamp_percent(100 - float(moisture_value))
water_value_text = f"{round(float(moisture_value), 2)}% soil moisture"
elif ndwi_raw is not None:
water_score = _clamp_percent(100 - (((float(ndwi_raw) + 1.0) / 2.0) * 100))
water_value_text = f"NDWI {round(float(ndwi_raw), 3)}"
else:
water_score = 0
water_value_text = ""
if water_score >= 65:
water_level = "high"
elif water_score >= 35:
water_level = "medium"
else:
water_level = "low"
ai_score = _clamp_percent((suggested_plant or {}).get("score"))
risk_score = max(0, min(100, round((100 - soil_score) * 0.6 + (100 - ai_score) * 0.4)))
if risk_score >= 65:
risk_level = "high"
elif risk_score >= 35:
risk_level = "medium"
else:
risk_level = "low"
return {
"criteria": criteria,
"soil": {
"score": soil_score,
"level": soil_level,
"color": _get_level_color_map("soil", soil_level),
},
"water": {
"level": water_level,
"value": water_value_text,
"color": _get_level_color_map("water", water_level),
},
"risk": {
"level": risk_level,
"color": _get_level_color_map("risk", risk_level),
},
}
def _sync_crop_area_from_ai(farm, remote_payload, recommendation_payload=None):
location_payload = remote_payload.get("location") or {}
area_feature = _get_farm_area_feature(
farm,
fallback=location_payload.get("farm_boundary"),
)
crop_area = _upsert_crop_area_snapshot(farm, area_feature)
subdivision_result = remote_payload.get("subdivision_result") or {}
cluster_blocks = subdivision_result.get("cluster_blocks") or []
recommendation_map = {}
for cluster in (recommendation_payload or {}).get("clusters", []):
cluster_uuid = str(cluster.get("cluster_uuid") or ((cluster.get("cluster_block") or {}).get("uuid") or ""))
if cluster_uuid:
recommendation_map[cluster_uuid] = cluster
existing_zones = {zone.zone_id: zone for zone in crop_area.zones.all()}
retained_zone_ids = []
with transaction.atomic():
for sequence, cluster_block in enumerate(
sorted(cluster_blocks, key=lambda item: (item.get("cluster_label") is None, item.get("cluster_label"), item.get("sub_block_code") or ""))
):
zone_id = str(cluster_block.get("uuid") or cluster_block.get("sub_block_code") or f"cluster-{sequence}")
geometry = cluster_block.get("geometry") or {}
points = _extract_zone_points(geometry)
area_sqm = round(polygon_area_sqm((geometry.get("coordinates") or [[points]])[0]), 2) if geometry.get("coordinates") else 0.0
area_hectares = round(area_sqm / 10000.0, 4)
zone_defaults = {
"geometry": geometry,
"points": points,
"center": {
"longitude": float(cluster_block.get("centroid_lon") or 0),
"latitude": float(cluster_block.get("centroid_lat") or 0),
},
"area_sqm": area_sqm,
"area_hectares": area_hectares,
"sequence": sequence,
"processing_status": CropZone.STATUS_COMPLETED,
"processing_error": "",
"task_id": str(((remote_payload.get("run") or {}).get("metadata") or {}).get("task_id") or ""),
}
zone = existing_zones.get(zone_id)
if zone is None:
zone = CropZone.objects.create(crop_area=crop_area, zone_id=zone_id, **zone_defaults)
else:
for field_name, value in zone_defaults.items():
setattr(zone, field_name, value)
zone.save(update_fields=[*zone_defaults.keys(), "updated_at"])
retained_zone_ids.append(zone.zone_id)
cluster_payload = recommendation_map.get(zone_id, {})
suggested_plant = cluster_payload.get("suggested_plant")
layer_bundle = _derive_layer_bundle(cluster_payload, suggested_plant)
product_id = str((suggested_plant or {}).get("plant_name") or (suggested_plant or {}).get("plant_id") or "")
if product_id:
product, _ = CropProduct.objects.update_or_create(
product_id=product_id,
defaults={
"label": str((suggested_plant or {}).get("plant_name") or product_id),
"color": _hash_color(product_id),
},
)
recommendation, _ = CropZoneRecommendation.objects.update_or_create(
crop_zone=zone,
defaults={
"product": product,
"match_percent": _clamp_percent((suggested_plant or {}).get("score")),
"water_need": layer_bundle["water"]["value"],
"estimated_profit": (
f"{round(float((suggested_plant or {}).get('predicted_yield_tons')), 2)} ton/ha"
if (suggested_plant or {}).get("predicted_yield_tons") is not None
else ""
),
"reason": "پیشنهاد محصول بر اساس متریک های سنجش از دور و تحلیل کلاستر AI تولید شده است.",
},
)
CropZoneCriteria.objects.filter(recommendation=recommendation).delete()
CropZoneCriteria.objects.bulk_create(
[
CropZoneCriteria(
recommendation=recommendation,
name=item["name"],
value=item["value"],
sequence=index,
)
for index, item in enumerate(layer_bundle["criteria"])
]
)
else:
CropZoneRecommendation.objects.filter(crop_zone=zone).delete()
CropZoneWaterNeedLayer.objects.update_or_create(
crop_zone=zone,
defaults=layer_bundle["water"],
)
CropZoneSoilQualityLayer.objects.update_or_create(
crop_zone=zone,
defaults=layer_bundle["soil"],
)
CropZoneCultivationRiskLayer.objects.update_or_create(
crop_zone=zone,
defaults=layer_bundle["risk"],
)
CropZoneAnalysis.objects.update_or_create(
crop_zone=zone,
defaults={
"source": "ai_location_data",
"external_record_id": zone_id,
"latitude": zone.center.get("latitude"),
"longitude": zone.center.get("longitude"),
"raw_response": {
"remote_sensing": remote_payload,
"cluster_recommendation": cluster_payload,
},
"depths": [],
},
)
CropZone.objects.filter(crop_area=crop_area).exclude(zone_id__in=retained_zone_ids).delete()
crop_area.zone_count = len(retained_zone_ids)
crop_area.chunk_area_sqm = subdivision_result.get("chunk_size_sqm") or crop_area.chunk_area_sqm
crop_area.save(update_fields=["zone_count", "chunk_area_sqm", "updated_at"])
return crop_area
def _get_ai_remote_sensing_payload(*, farm_uuid, page, page_size):
response = _request_ai_location_data(
AI_REMOTE_SENSING_PATH,
method="GET",
query={
"farm_uuid": str(farm_uuid),
"page": page,
"page_size": page_size,
},
)
return _unwrap_ai_response(response, expected_statuses={200})
def _start_ai_remote_sensing(*, farm_uuid):
response = _request_ai_location_data(
AI_REMOTE_SENSING_PATH,
method="POST",
payload={"farm_uuid": str(farm_uuid)},
)
return _unwrap_ai_response(response, expected_statuses={202})
def _get_ai_cluster_recommendations(*, farm_uuid):
response = _request_ai_location_data(
AI_CLUSTER_RECOMMENDATIONS_PATH,
method="GET",
query={"farm_uuid": str(farm_uuid)},
)
return _unwrap_ai_response(response, expected_statuses={200})
def _build_ai_layer_context(remote_payload, recommendation_payload=None):
location = deepcopy(remote_payload.get("location") or {})
subdivision_result = deepcopy(remote_payload.get("subdivision_result") or {})
run = deepcopy(remote_payload.get("run") or {})
return {
"source": {
"type": "ai_location_data",
"service": "ai",
"status": str(remote_payload.get("status") or ""),
},
"location": location,
"farmerBlocks": deepcopy(((location.get("block_layout") or {}).get("blocks") or [])),
"clusterBlocks": deepcopy(subdivision_result.get("cluster_blocks") or []),
"subdivisionSummary": {
"clusterCount": subdivision_result.get("cluster_count")
or len(subdivision_result.get("cluster_blocks") or []),
"chunkSizeSqm": subdivision_result.get("chunk_size_sqm") or remote_payload.get("chunk_size_sqm"),
"selectedFeatures": deepcopy(
subdivision_result.get("selected_features")
or run.get("selected_features")
or []
),
"temporalExtent": deepcopy(remote_payload.get("temporal_extent") or {}),
"summary": deepcopy(remote_payload.get("summary") or {}),
},
"registeredPlants": deepcopy((recommendation_payload or {}).get("registered_plants") or []),
"evaluatedPlantCount": (recommendation_payload or {}).get("evaluated_plant_count"),
}
def _get_latest_layer_payload_from_ai(zone_builder, *, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
farm = get_farm_for_uuid(farm_uuid, owner=owner)
remote_payload = _get_ai_remote_sensing_payload(
farm_uuid=farm_uuid,
page=page,
page_size=page_size,
)
remote_status = str(remote_payload.get("status") or "").lower()
if remote_status == "not_found":
remote_payload = _start_ai_remote_sensing(farm_uuid=farm_uuid)
return _build_processing_layer_payload(farm, remote_payload, page=page, page_size=page_size)
if remote_status != "success":
return _build_processing_layer_payload(farm, remote_payload, page=page, page_size=page_size)
recommendation_payload = None
try:
recommendation_payload = _get_ai_cluster_recommendations(farm_uuid=farm_uuid)
except ValueError:
recommendation_payload = None
crop_area = _sync_crop_area_from_ai(farm, remote_payload, recommendation_payload)
return _build_latest_area_layer_payload(
zone_builder,
area=crop_area,
page=page,
page_size=page_size,
extra_payload=_build_ai_layer_context(remote_payload, recommendation_payload),
)
def ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None, owner=None):
farm = get_farm_for_uuid(farm_uuid, owner=owner)
latest_area = CropArea.objects.filter(farm=farm).order_by("-created_at", "-id").first()
if latest_area is None:
latest_area, _ = create_zones_and_dispatch(area_feature or get_default_area_feature(), farm=farm)
return latest_area
zones = create_missing_zones_for_area(latest_area)
for zone in zones:
ensure_rule_based_zone_data(zone)
stale_zone_ids = _get_stale_zone_ids(zones)
zones_to_dispatch = [
zone.id
for zone in zones
if zone.processing_status != CropZone.STATUS_COMPLETED
and zone.id not in stale_zone_ids
and not (zone.processing_status in {CropZone.STATUS_PENDING, CropZone.STATUS_PROCESSING} and zone.task_id)
]
if stale_zone_ids:
dispatch_zone_processing_tasks(zone_ids=stale_zone_ids, force=True)
if zones_to_dispatch:
dispatch_zone_processing_tasks(zone_ids=zones_to_dispatch)
return CropArea.objects.get(id=latest_area.id)
return _upsert_crop_area_snapshot(farm, _get_farm_area_feature(farm, fallback=area_feature))
def create_zones_and_dispatch(area_feature, cell_side_km=None, farm=None):
ensure_products_exist()
area_feature = normalize_area_feature(area_feature)
zoning_result = split_area_into_zones(area_feature, cell_side_km=cell_side_km)
area_data = zoning_result["area"]
if farm is None:
raise ValueError("farm is required.")
with transaction.atomic():
crop_area = CropArea.objects.create(
farm=farm,
geometry=area_data["geometry"],
points=area_data["points"],
center=area_data["center"],
area_sqm=round(area_data["area_sqm"], 2),
area_hectares=round(area_data["area_hectares"], 4),
chunk_area_sqm=round(area_data["chunk_area_sqm"], 2),
zone_count=area_data["zone_count"],
)
zones = CropZone.objects.bulk_create(
[
CropZone(
crop_area=crop_area,
zone_id=zone["zone_id"],
geometry=zone["geometry"],
points=zone["points"],
center=zone["center"],
area_sqm=round(zone["area_sqm"], 2),
area_hectares=round(zone["area_hectares"], 4),
sequence=zone["sequence"],
)
for zone in zoning_result["zones"]
]
)
crop_area.refresh_from_db()
zones = list(crop_area.zones.order_by("sequence", "id"))
for zone in zones:
ensure_rule_based_zone_data(zone)
dispatch_zone_processing_tasks(crop_area.id)
return crop_area, zones
crop_area = _upsert_crop_area_snapshot(farm, area_feature)
CropZone.objects.filter(crop_area=crop_area).delete()
crop_area.zone_count = 0
crop_area.chunk_area_sqm = round(get_chunk_area_sqm(cell_side_km), 2)
crop_area.save(update_fields=["zone_count", "chunk_area_sqm", "updated_at"])
return crop_area, []
def _zones_queryset(zone_ids=None):
@@ -982,6 +1487,7 @@ def _zones_queryset(zone_ids=None):
"water_need_layer",
"soil_quality_layer",
"cultivation_risk_layer",
"analysis",
).prefetch_related(
Prefetch("recommendation__criteria", queryset=CropZoneCriteria.objects.order_by("sequence", "id"))
).order_by("sequence", "id")
@@ -1017,7 +1523,13 @@ def _get_idle_area_payload(page, page_size):
}
def _build_latest_area_layer_payload(zone_builder, area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
def _build_latest_area_layer_payload(
zone_builder,
area=None,
page=1,
page_size=DEFAULT_ZONE_PAGE_SIZE,
extra_payload=None,
):
area = area or CropArea.objects.order_by("-created_at", "-id").first()
if not area:
return _get_idle_area_payload(page, page_size)
@@ -1058,7 +1570,7 @@ def _build_latest_area_layer_payload(zone_builder, area=None, page=1, page_size=
if total_zones:
progress_percent = round((completed_zones / total_zones) * 100, 2)
return {
payload = {
"task": {
"status": task_status,
"stage": current_stage,
@@ -1107,34 +1619,46 @@ def _build_latest_area_layer_payload(zone_builder, area=None, page=1, page_size=
"has_previous": page > 1 and total_pages > 0,
},
}
if extra_payload:
payload.update(extra_payload)
return payload
def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _build_latest_area_layer_payload(build_area_zone_payload, area=area, page=page, page_size=page_size)
def get_latest_area_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai(
build_area_zone_payload,
farm_uuid=farm_uuid,
owner=owner,
page=page,
page_size=page_size,
)
def get_latest_water_need_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _build_latest_area_layer_payload(
def get_latest_water_need_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai(
build_water_need_area_zone_payload,
area=area,
farm_uuid=farm_uuid,
owner=owner,
page=page,
page_size=page_size,
)
def get_latest_soil_quality_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _build_latest_area_layer_payload(
def get_latest_soil_quality_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai(
build_soil_quality_area_zone_payload,
area=area,
farm_uuid=farm_uuid,
owner=owner,
page=page,
page_size=page_size,
)
def get_latest_cultivation_risk_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _build_latest_area_layer_payload(
def get_latest_cultivation_risk_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai(
build_cultivation_risk_area_zone_payload,
area=area,
farm_uuid=farm_uuid,
owner=owner,
page=page,
page_size=page_size,
)
@@ -1197,10 +1721,24 @@ def get_cultivation_risk_payload(zone_ids=None):
}
def get_zone_details_payload(zone_id):
zone = _zones_queryset().get(zone_id=zone_id)
def get_zone_details_payload(zone_id, *, farm_uuid=None, owner=None):
zone_filters = {"zone_id": zone_id}
if farm_uuid:
_get_latest_layer_payload_from_ai(
build_area_zone_payload,
farm_uuid=farm_uuid,
owner=owner,
page=1,
page_size=DEFAULT_ZONE_PAGE_SIZE,
)
zone_filters["crop_area__farm__farm_uuid"] = farm_uuid
if owner is not None:
zone_filters["crop_area__farm__owner"] = owner
zone = _zones_queryset().get(**zone_filters)
recommendation = getattr(zone, "recommendation", None)
criteria = recommendation.criteria.all() if recommendation else []
cluster_payload = _get_zone_ai_cluster_payload(zone)
return {
"zoneId": zone.zone_id,
"crop": recommendation.product.product_id if recommendation else "",
@@ -1210,4 +1748,7 @@ def get_zone_details_payload(zone_id):
"reason": recommendation.reason if recommendation else "",
"criteria": [{"name": item.name, "value": item.value} for item in criteria],
"area_hectares": zone.area_hectares,
"clusterInfo": _build_zone_cluster_info(zone, cluster_payload),
"clusterMetrics": _build_zone_cluster_metrics(cluster_payload),
"cropPrediction": _build_zone_crop_prediction(cluster_payload),
}
+155 -368
View File
@@ -1,419 +1,206 @@
from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from kombu.exceptions import OperationalError
from django.test import TestCase
from django.urls import Resolver404, resolve
from rest_framework.test import APIRequestFactory, force_authenticate
from crop_zoning.models import CropArea, CropZone
from crop_zoning.views import (
AreaView,
CultivationRiskView,
SoilQualityView,
WaterNeedView,
ZonesInitialView,
)
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType
AREA_GEOJSON = {
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.418934, 35.706815],
[51.423054, 35.691062],
[51.384258, 35.689389],
[51.418934, 35.706815],
]
],
},
}
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class ZonesInitialViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
def test_post_accepts_area_geojson_alias(self):
request = self.factory.post(
"/api/crop-zoning/zones/initial/",
{"area_geojson": AREA_GEOJSON},
format="json",
)
response = ZonesInitialView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
self.assertGreater(response.data["data"]["zone_count"], 1)
self.assertEqual(
response.data["data"]["zone_count"],
len(response.data["data"]["zones"]),
from .views import (
ClusterBlockLiveView,
ClusterRecommendationsView,
KOptionsActivateView,
KOptionsView,
LocationDataNdviHealthView,
LocationDataRemoteSensingView,
LocationDataView,
RunStatusView,
)
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class AreaViewTests(TestCase):
CLUSTER_UUID = "11111111-2222-3333-4444-555555555555"
class LocationDataProxyViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="farmer",
username="location-user",
password="secret123",
email="farmer@example.com",
phone_number="09120000000",
email="location@example.com",
phone_number="09120000030",
)
self.other_user = get_user_model().objects.create_user(
username="other-farmer",
username="location-other-user",
password="secret123",
email="other@example.com",
phone_number="09120000001",
email="location-other@example.com",
phone_number="09120000031",
)
self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, name="farm-1", farm_type=self.farm_type)
self.other_farm = FarmHub.objects.create(owner=self.other_user, name="farm-2", farm_type=self.farm_type)
self.farm_type = FarmType.objects.create(name="Location Farm Type")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1")
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2")
def _create_area(self, **kwargs):
defaults = {
"farm": self.farm,
"geometry": AREA_GEOJSON,
"points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
"center": {"longitude": 51.40874867, "latitude": 35.69575533},
"area_sqm": 300000,
"area_hectares": 30,
"chunk_area_sqm": 200000,
"zone_count": 2,
}
defaults.update(kwargs)
return CropArea.objects.create(**defaults)
def _request(self):
request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}")
def _get(self, path):
request = self.factory.get(path)
force_authenticate(request, user=self.user)
return request
def _request_with_pagination(self, page=1, page_size=10):
request = self.factory.get(
f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}&page={page}&page_size={page_size}"
)
def _post(self, path, data):
request = self.factory.post(path, data, format="json")
force_authenticate(request, user=self.user)
return request
def test_get_requires_farm_uuid(self):
request = self.factory.get("/api/crop-zoning/area/")
force_authenticate(request, user=self.user)
response = AreaView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "farm_uuid is required.")
def test_get_rejects_foreign_farm_uuid(self):
request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.other_farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = AreaView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "Farm not found.")
def test_get_returns_pending_task_status_until_all_zones_complete(self):
crop_area = self._create_area()
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="celery-task-1",
)
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-1",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4088, "latitude": 35.6958},
area_sqm=100000,
area_hectares=10,
sequence=1,
processing_status=CropZone.STATUS_PROCESSING,
task_id="celery-task-1",
@patch("crop_zoning.views.external_api_request")
def test_get_location_data_proxies_query_params_to_ai(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"code": 200,
"msg": "success",
"data": {"source": "database", "id": 12, "lon": "51.389000", "lat": "35.689200"},
},
)
response = AreaView.as_view()(self._request())
response = LocationDataView.as_view()(self._get("/api/location-data/?lat=35.6892&lon=51.389"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
self.assertEqual(response.data["data"]["task"]["status"], "PROCESSING")
self.assertEqual(response.data["data"]["task"]["total_zones"], 2)
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
self.assertEqual(len(response.data["data"]["zones"]), 2)
self.assertEqual(response.data["data"]["zones"][0]["zoneId"], "zone-0")
self.assertIn("processing_status", response.data["data"]["zones"][0])
def test_get_returns_area_when_all_tasks_complete(self):
crop_area = self._create_area()
for sequence in range(2):
CropZone.objects.create(
crop_area=crop_area,
zone_id=f"zone-{sequence}",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087 + (sequence * 0.0001), "latitude": 35.6957},
area_sqm=150000,
area_hectares=15,
sequence=sequence,
processing_status=CropZone.STATUS_COMPLETED,
task_id="celery-task-1",
self.assertEqual(response.data["data"]["id"], 12)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/location-data/",
method="GET",
payload=None,
query={"lat": "35.6892", "lon": "51.389"},
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
self.assertEqual(len(response.data["data"]["zones"]), 2)
self.assertEqual(response.data["data"]["zones"][1]["zoneId"], "zone-1")
self.assertIn("crop", response.data["data"]["zones"][0])
self.assertIn("waterNeedLayer", response.data["data"]["zones"][0])
def test_get_returns_paginated_zones(self):
crop_area = self._create_area(zone_count=3, area_sqm=300000, area_hectares=30)
for sequence in range(3):
CropZone.objects.create(
crop_area=crop_area,
zone_id=f"zone-{sequence}",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087 + (sequence * 0.0001), "latitude": 35.6957},
area_sqm=100000,
area_hectares=10,
sequence=sequence,
processing_status=CropZone.STATUS_COMPLETED,
task_id=f"celery-task-{sequence}",
def test_post_location_data_rejects_foreign_farm_uuid(self):
response = LocationDataView.as_view()(
self._post("/api/location-data/", {"farm_uuid": str(self.other_farm.farm_uuid), "lat": "35.6892", "lon": "51.389"})
)
response = AreaView.as_view()(self._request_with_pagination(page=2, page_size=1))
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data, {"code": 404, "msg": "location پیدا نشد.", "data": None})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertEqual(response.data["data"]["zones"][0]["zoneId"], "zone-1")
self.assertEqual(response.data["data"]["pagination"]["page"], 2)
self.assertEqual(response.data["data"]["pagination"]["page_size"], 1)
self.assertEqual(response.data["data"]["pagination"]["total_pages"], 3)
self.assertTrue(response.data["data"]["pagination"]["has_next"])
self.assertTrue(response.data["data"]["pagination"]["has_previous"])
def test_get_rejects_invalid_pagination_params(self):
response = AreaView.as_view()(self._request_with_pagination(page=0, page_size=10))
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "page must be a positive integer.")
@patch("crop_zoning.services.dispatch_zone_processing_tasks")
def test_get_dispatches_zone_task_when_task_id_is_missing(self, mock_dispatch):
crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20)
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
@patch("crop_zoning.views.external_api_request")
def test_post_ndvi_health_proxies_owned_farm_to_ai(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"code": 200,
"msg": "success",
"data": {"ndviIndex": 0.63, "vegetation_health_class": "healthy"},
},
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
mock_dispatch.assert_called_once()
@patch("crop_zoning.services.create_zones_and_dispatch")
def test_get_creates_area_when_farm_has_no_data(self, mock_create):
created_area = self._create_area(zone_count=0)
mock_create.return_value = (created_area, [])
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
mock_create.assert_called_once()
self.assertEqual(mock_create.call_args.kwargs["farm"], self.farm)
@patch("crop_zoning.tasks.process_zone_soil_data.delay")
def test_each_zone_gets_its_own_task(self, mock_delay):
crop_area = self._create_area()
zone0 = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
zone1 = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-1",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4088, "latitude": 35.6958},
area_sqm=100000,
area_hectares=10,
sequence=1,
processing_status=CropZone.STATUS_PENDING,
task_id="",
response = LocationDataNdviHealthView.as_view()(
self._post("/api/location-data/ndvi-health/", {"farm_uuid": str(self.farm.farm_uuid)})
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_delay.call_count, 2)
zone0.refresh_from_db()
zone1.refresh_from_db()
self.assertTrue(zone0.task_id)
self.assertTrue(zone1.task_id)
self.assertNotEqual(zone0.task_id, zone1.task_id)
@patch("crop_zoning.services.AsyncResult")
def test_stale_tasks_are_redispatched(self, mock_async_result):
crop_area = self._create_area()
stale_time = timezone.now() - timedelta(minutes=10)
stale_zone = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PROCESSING,
task_id="stale-task",
self.assertEqual(response.data["data"]["ndviIndex"], 0.63)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/location-data/ndvi-health/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid)},
query=None,
)
CropZone.objects.filter(id=stale_zone.id).update(updated_at=stale_time)
mock_async_result.side_effect = OperationalError("broker down")
with patch("crop_zoning.services.dispatch_zone_processing_tasks") as mock_dispatch:
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
mock_dispatch.assert_called_once_with(zone_ids=[stale_zone.id], force=True)
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
def test_get_remote_sensing_rejects_foreign_farm_uuid(self):
response = LocationDataRemoteSensingView.as_view()(
self._get(f"/api/location-data/remote-sensing/?farm_uuid={self.other_farm.farm_uuid}")
)
class LayerAreaViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="layer-farmer",
password="secret123",
email="layer@example.com",
phone_number="09120000002",
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data, {"code": 404, "msg": "مزرعه پیدا نشد.", "data": None})
@patch("crop_zoning.views.external_api_request")
def test_post_remote_sensing_passes_through_202_response(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=202,
data={
"code": 202,
"msg": "queued",
"data": {"status": "processing", "task_id": "task-123"},
},
)
self.farm_type = FarmType.objects.create(name="باغی")
self.farm = FarmHub.objects.create(owner=self.user, name="layer-farm", farm_type=self.farm_type)
def _create_area(self, **kwargs):
defaults = {
"farm": self.farm,
"geometry": AREA_GEOJSON,
"points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
"center": {"longitude": 51.40874867, "latitude": 35.69575533},
"area_sqm": 300000,
"area_hectares": 30,
"chunk_area_sqm": 200000,
"zone_count": 1,
}
defaults.update(kwargs)
return CropArea.objects.create(**defaults)
def _create_completed_zone(self):
crop_area = self._create_area()
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=300000,
area_hectares=30,
sequence=0,
processing_status=CropZone.STATUS_COMPLETED,
task_id="celery-task-1",
response = LocationDataRemoteSensingView.as_view()(
self._post("/api/location-data/remote-sensing/", {"farm_uuid": str(self.farm.farm_uuid)})
)
return crop_area
def _request(self, path):
request = self.factory.get(f"{path}?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
return request
self.assertEqual(response.status_code, 202)
self.assertEqual(response.data["data"]["task_id"], "task-123")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/location-data/remote-sensing/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid)},
query=None,
)
def test_water_need_view_requires_farm_uuid(self):
request = self.factory.get("/api/crop-zoning/water-need/")
force_authenticate(request, user=self.user)
@patch("crop_zoning.views.external_api_request")
def test_auxiliary_location_data_endpoints_proxy_to_ai(self, mock_external_api_request):
mock_external_api_request.side_effect = [
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"status": "success"}}),
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"cluster_count": 2}}),
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"result_id": 5}}),
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"activated_requested_k": 4}}),
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"status": "running"}}),
]
response = WaterNeedView.as_view()(request)
cluster_response = ClusterBlockLiveView.as_view()(
self._get(f"/api/location-data/remote-sensing/cluster-blocks/{CLUSTER_UUID}/live/"),
cluster_uuid=CLUSTER_UUID,
)
recommendation_response = ClusterRecommendationsView.as_view()(
self._get(f"/api/location-data/remote-sensing/cluster-recommendations/?farm_uuid={self.farm.farm_uuid}")
)
k_options_response = KOptionsView.as_view()(
self._get("/api/location-data/remote-sensing/results/5/k-options/"),
result_id=5,
)
activate_response = KOptionsActivateView.as_view()(
self._post("/api/location-data/remote-sensing/results/5/k-options/activate/", {"requested_k": 4}),
result_id=5,
)
run_status_response = RunStatusView.as_view()(
self._get("/api/location-data/remote-sensing/runs/9/status/"),
run_id=9,
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "farm_uuid is required.")
self.assertEqual(cluster_response.status_code, 200)
self.assertEqual(recommendation_response.data["data"]["cluster_count"], 2)
self.assertEqual(k_options_response.data["data"]["result_id"], 5)
self.assertEqual(activate_response.data["data"]["activated_requested_k"], 4)
self.assertEqual(run_status_response.data["data"]["status"], "running")
def test_water_need_view_returns_area_style_payload(self):
self._create_completed_zone()
def test_new_routes_exist_and_old_crop_zoning_routes_are_removed(self):
self.assertIs(resolve("/api/location-data/").func.view_class, LocationDataView)
self.assertIs(resolve("/api/location-data/ndvi-health/").func.view_class, LocationDataNdviHealthView)
self.assertIs(resolve("/api/location-data/remote-sensing/").func.view_class, LocationDataRemoteSensingView)
self.assertIs(
resolve(f"/api/location-data/remote-sensing/cluster-blocks/{CLUSTER_UUID}/live/").func.view_class,
ClusterBlockLiveView,
)
self.assertIs(
resolve("/api/location-data/remote-sensing/cluster-recommendations/").func.view_class,
ClusterRecommendationsView,
)
self.assertIs(
resolve("/api/location-data/remote-sensing/results/5/k-options/").func.view_class,
KOptionsView,
)
self.assertIs(
resolve("/api/location-data/remote-sensing/results/5/k-options/activate/").func.view_class,
KOptionsActivateView,
)
self.assertIs(
resolve("/api/location-data/remote-sensing/runs/9/status/").func.view_class,
RunStatusView,
)
response = WaterNeedView.as_view()(self._request("/api/crop-zoning/water-need/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0])
self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0])
def test_soil_quality_view_returns_area_style_payload(self):
self._create_completed_zone()
response = SoilQualityView.as_view()(self._request("/api/crop-zoning/soil-quality/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("soilQualityLayer", response.data["data"]["zones"][0])
self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0])
def test_cultivation_risk_view_returns_area_style_payload(self):
self._create_completed_zone()
response = CultivationRiskView.as_view()(self._request("/api/crop-zoning/cultivation-risk/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("cultivationRiskLayer", response.data["data"]["zones"][0])
self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0])
with self.assertRaises(Resolver404):
resolve("/api/crop-zoning/area/")
+34 -34
View File
@@ -1,43 +1,43 @@
from django.urls import path
from .views import (
AreaView,
CultivationRiskView,
ProductsView,
SoilQualityView,
WaterNeedView,
ZoneDetailsView,
ZonesCultivationRiskView,
ZonesInitialView,
ZonesSoilQualityView,
ZonesWaterNeedView,
ClusterBlockLiveView,
ClusterRecommendationsView,
KOptionsActivateView,
KOptionsView,
LocationDataNdviHealthView,
LocationDataRemoteSensingView,
LocationDataView,
RunStatusView,
)
urlpatterns = [
path("area/", AreaView.as_view(), name="crop-zoning-area"),
path("water-need/", WaterNeedView.as_view(), name="crop-zoning-water-need"),
path("soil-quality/", SoilQualityView.as_view(), name="crop-zoning-soil-quality"),
path("cultivation-risk/", CultivationRiskView.as_view(), name="crop-zoning-cultivation-risk"),
path("products/", ProductsView.as_view(), name="crop-zoning-products"),
# path("zones/initial/", ZonesInitialView.as_view(), name="crop-zoning-zones-initial"),
# path(
# "zones/water-need/",
# ZonesWaterNeedView.as_view(),
# name="crop-zoning-zones-water-need",
# ),
# path(
# "zones/soil-quality/",
# ZonesSoilQualityView.as_view(),
# name="crop-zoning-zones-soil-quality",
# ),
# path(
# "zones/cultivation-risk/",
# ZonesCultivationRiskView.as_view(),
# name="crop-zoning-zones-cultivation-risk",
# ),
path("", LocationDataView.as_view(), name="location-data"),
path("ndvi-health/", LocationDataNdviHealthView.as_view(), name="location-data-ndvi-health"),
path("remote-sensing/", LocationDataRemoteSensingView.as_view(), name="location-data-remote-sensing"),
path(
"zones/<str:zone_id>/details/",
ZoneDetailsView.as_view(),
name="crop-zoning-zone-details",
"remote-sensing/cluster-blocks/<uuid:cluster_uuid>/live/",
ClusterBlockLiveView.as_view(),
name="location-data-cluster-block-live",
),
path(
"remote-sensing/cluster-recommendations/",
ClusterRecommendationsView.as_view(),
name="location-data-cluster-recommendations",
),
path(
"remote-sensing/results/<int:result_id>/k-options/",
KOptionsView.as_view(),
name="location-data-k-options",
),
path(
"remote-sensing/results/<int:result_id>/k-options/activate/",
KOptionsActivateView.as_view(),
name="location-data-k-options-activate",
),
path(
"remote-sensing/runs/<int:run_id>/status/",
RunStatusView.as_view(),
name="location-data-run-status",
),
]
+220 -173
View File
@@ -1,215 +1,262 @@
from copy import deepcopy
from uuid import UUID
from django.core.exceptions import ImproperlyConfigured
from django.http import Http404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from config.swagger import code_response
from external_api_adapter.adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
from farm_hub.models import FarmHub
from .serializers import (
FarmUUIDRequestSerializer,
KOptionActivateSerializer,
LocationDataUpsertSerializer,
)
from .services import (
create_zones_and_dispatch,
ensure_latest_area_ready_for_processing,
get_latest_cultivation_risk_payload,
get_cultivation_risk_payload,
get_default_area_feature,
get_initial_zones_payload,
get_latest_area_payload,
get_latest_soil_quality_payload,
get_latest_water_need_payload,
get_products_payload,
get_soil_quality_payload,
get_water_need_payload,
get_zone_details_payload,
get_zone_page_request_params,
AI_CLUSTER_RECOMMENDATIONS_PATH,
AI_LOCATION_DATA_PATH,
AI_REMOTE_SENSING_PATH,
)
AREA_QUERY_PARAMETERS = [
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=True,
description="UUID مزرعه برای گرفتن يا ساخت آخرين پردازش محدوده همان مزرعه.",
default="11111111-1111-1111-1111-111111111111"),
OpenApiParameter(
name="page",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="شماره صفحه زون ها. مقدار پيش فرض 1 است.",
),
OpenApiParameter(
name="page_size",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="تعداد زون در هر صفحه. مقدار پيش فرض 10 است.",
),
AI_PROXY_ERROR_MESSAGE = "ارتباط با سرویس AI ناموفق بود."
FARM_NOT_FOUND_MESSAGE = "مزرعه پیدا نشد."
QUERY_FARM_NOT_FOUND_MESSAGE = "location پیدا نشد."
SUCCESS_RESPONSE = code_response("LocationDataGenericSuccess", data=serializers.JSONField())
ERROR_RESPONSE = code_response("LocationDataGenericError", data=serializers.JSONField())
LOCATION_DATA_QUERY_PARAMETERS = [
OpenApiParameter("lat", OpenApiTypes.NUMBER, OpenApiParameter.QUERY, required=False),
OpenApiParameter("lon", OpenApiTypes.NUMBER, OpenApiParameter.QUERY, required=False),
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False),
]
REMOTE_SENSING_QUERY_PARAMETERS = [
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=True),
OpenApiParameter("page", OpenApiTypes.INT, OpenApiParameter.QUERY, required=False),
OpenApiParameter("page_size", OpenApiTypes.INT, OpenApiParameter.QUERY, required=False),
OpenApiParameter("start_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False),
OpenApiParameter("end_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False),
]
CLUSTER_BLOCK_LIVE_QUERY_PARAMETERS = [
OpenApiParameter("start_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False),
OpenApiParameter("end_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False),
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False),
]
OPTIONAL_FARM_UUID_QUERY_PARAMETER = [
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False),
]
class BaseAreaDataView(APIView):
payload_getter = None
class AILocationDataProxyView(APIView):
ai_path = AI_LOCATION_DATA_PATH
farm_uuid_locations = ()
farm_not_found_message = FARM_NOT_FOUND_MESSAGE
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
def _build_path(self, **kwargs):
return self.ai_path.format(**kwargs)
def _get_payload(self, request):
if not request.data:
return None
if isinstance(request.data, dict):
return deepcopy(request.data)
return request.data
def _get_query(self, request):
if not request.query_params:
return None
query = {}
for key, values in request.query_params.lists():
query[key] = values if len(values) > 1 else values[0]
return query
def _parse_uuid(self, value):
if not value:
return None
try:
page, page_size = get_zone_page_request_params(request.query_params)
crop_area = ensure_latest_area_ready_for_processing(farm_uuid=farm_uuid, owner=request.user)
except ValueError as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except ImproperlyConfigured as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return UUID(str(value))
except (TypeError, ValueError, AttributeError):
return None
def _extract_farm_uuid(self, request, payload, query):
for location in self.farm_uuid_locations:
if location == "body" and isinstance(payload, dict) and payload.get("farm_uuid"):
parsed = self._parse_uuid(payload.get("farm_uuid"))
if parsed is not None:
return parsed
if location == "query" and isinstance(query, dict) and query.get("farm_uuid"):
parsed = self._parse_uuid(query.get("farm_uuid"))
if parsed is not None:
return parsed
return None
def _ensure_farm_access(self, request, farm_uuid):
if farm_uuid is None:
return None
if FarmHub.objects.filter(farm_uuid=farm_uuid, owner=request.user).exists():
return None
return Response(
{"status": "success", "data": self.payload_getter(crop_area, page=page, page_size=page_size)},
status=status.HTTP_200_OK,
{"code": 404, "msg": self.farm_not_found_message, "data": None},
status=status.HTTP_404_NOT_FOUND,
)
class AreaView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_area_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningAreaResponse", data=serializers.JSONField()),
400: status_response("CropZoningAreaValidationError", data=serializers.JSONField()),
500: status_response("CropZoningAreaServerError", data=serializers.JSONField()),
},
def _build_proxy_error(self, exc):
return Response(
{"code": 502, "msg": AI_PROXY_ERROR_MESSAGE, "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
def get(self, request):
return super().get(request)
class WaterNeedView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_water_need_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningWaterNeedResponse", data=serializers.JSONField()),
400: status_response("CropZoningWaterNeedValidationError", data=serializers.JSONField()),
500: status_response("CropZoningWaterNeedServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class SoilQualityView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_soil_quality_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningSoilQualityResponse", data=serializers.JSONField()),
400: status_response("CropZoningSoilQualityValidationError", data=serializers.JSONField()),
500: status_response("CropZoningSoilQualityServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class CultivationRiskView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_cultivation_risk_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningCultivationRiskResponse", data=serializers.JSONField()),
400: status_response("CropZoningCultivationRiskValidationError", data=serializers.JSONField()),
500: status_response("CropZoningCultivationRiskServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class ProductsView(APIView):
@extend_schema(
tags=["Crop Zoning"],
responses={200: status_response("CropZoningProductsResponse", data=serializers.JSONField())},
)
def get(self, request):
return Response({"status": "success", "data": get_products_payload()}, status=status.HTTP_200_OK)
class ZonesInitialView(APIView):
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
)
def post(self, request):
area_feature = (
request.data.get("area")
or request.data.get("area_geojson")
or request.data.get("boundary")
or get_default_area_feature()
)
cell_side_km = request.data.get("cell_side_km")
def _proxy(self, request, *, method, **path_kwargs):
payload = self._get_payload(request)
query = self._get_query(request)
farm_uuid = self._extract_farm_uuid(request, payload, query)
farm_error = self._ensure_farm_access(request, farm_uuid)
if farm_error is not None:
return farm_error
try:
crop_area, _zones = create_zones_and_dispatch(area_feature, cell_side_km=cell_side_km)
except ValueError as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except ImproperlyConfigured as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
adapter_response = external_api_request(
"ai",
self._build_path(**path_kwargs),
method=method,
payload=payload,
query=query,
)
except (ExternalAPIRequestError, ImproperlyConfigured) as exc:
return self._build_proxy_error(exc)
return Response({"status": "success", "data": get_initial_zones_payload(crop_area)}, status=status.HTTP_200_OK)
response_payload = adapter_response.data
if not isinstance(response_payload, dict):
response_payload = {
"code": adapter_response.status_code,
"msg": "success" if adapter_response.status_code < 400 else "error",
"data": response_payload,
}
return Response(response_payload, status=adapter_response.status_code)
class ZonesWaterNeedView(APIView):
class LocationDataView(AILocationDataProxyView):
farm_uuid_locations = ("query", "body")
farm_not_found_message = QUERY_FARM_NOT_FOUND_MESSAGE
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesWaterNeedResponse", data=serializers.JSONField())},
tags=["Location Data"],
parameters=LOCATION_DATA_QUERY_PARAMETERS,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request):
return self._proxy(request, method="GET")
@extend_schema(
tags=["Location Data"],
request=LocationDataUpsertSerializer,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def post(self, request):
zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_water_need_payload(zone_ids)}, status=status.HTTP_200_OK)
return self._proxy(request, method="POST")
class ZonesSoilQualityView(APIView):
class LocationDataNdviHealthView(AILocationDataProxyView):
ai_path = f"{AI_LOCATION_DATA_PATH}ndvi-health/"
farm_uuid_locations = ("body",)
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesSoilQualityResponse", data=serializers.JSONField())},
tags=["Location Data"],
request=FarmUUIDRequestSerializer,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def post(self, request):
zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_soil_quality_payload(zone_ids)}, status=status.HTTP_200_OK)
return self._proxy(request, method="POST")
class ZonesCultivationRiskView(APIView):
class LocationDataRemoteSensingView(AILocationDataProxyView):
ai_path = AI_REMOTE_SENSING_PATH
farm_uuid_locations = ("query", "body")
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesCultivationRiskResponse", data=serializers.JSONField())},
tags=["Location Data"],
parameters=REMOTE_SENSING_QUERY_PARAMETERS,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request):
return self._proxy(request, method="GET")
@extend_schema(
tags=["Location Data"],
request=FarmUUIDRequestSerializer,
responses={202: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def post(self, request):
zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_cultivation_risk_payload(zone_ids)}, status=status.HTTP_200_OK)
return self._proxy(request, method="POST")
class ZoneDetailsView(APIView):
class ClusterBlockLiveView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}cluster-blocks/{{cluster_uuid}}/live/"
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Crop Zoning"],
responses={200: status_response("CropZoningZoneDetailsResponse", data=serializers.JSONField())},
tags=["Location Data"],
parameters=CLUSTER_BLOCK_LIVE_QUERY_PARAMETERS,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE, 502: ERROR_RESPONSE},
)
def get(self, request, zone_id):
try:
data = get_zone_details_payload(zone_id)
except Exception as exc:
if exc.__class__.__name__ == "DoesNotExist":
raise Http404("Zone not found")
raise
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
def get(self, request, cluster_uuid):
return self._proxy(request, method="GET", cluster_uuid=cluster_uuid)
class ClusterRecommendationsView(AILocationDataProxyView):
ai_path = AI_CLUSTER_RECOMMENDATIONS_PATH
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Location Data"],
parameters=REMOTE_SENSING_QUERY_PARAMETERS[:1],
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request):
return self._proxy(request, method="GET")
class KOptionsView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}results/{{result_id}}/k-options/"
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Location Data"],
parameters=OPTIONAL_FARM_UUID_QUERY_PARAMETER,
responses={200: SUCCESS_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request, result_id):
return self._proxy(request, method="GET", result_id=result_id)
class KOptionsActivateView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}results/{{result_id}}/k-options/activate/"
farm_uuid_locations = ("query", "body")
@extend_schema(
tags=["Location Data"],
parameters=OPTIONAL_FARM_UUID_QUERY_PARAMETER,
request=KOptionActivateSerializer,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def post(self, request, result_id):
return self._proxy(request, method="POST", result_id=result_id)
class RunStatusView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}runs/{{run_id}}/status/"
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Location Data"],
parameters=OPTIONAL_FARM_UUID_QUERY_PARAMETER,
responses={200: SUCCESS_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request, run_id):
return self._proxy(request, method="GET", run_id=run_id)
-9
View File
@@ -1,9 +0,0 @@
from django.urls import path
from .views import SensorComparisonChartView, SensorRadarChartView, SensorValuesListView
urlpatterns = [
path("comparison-chart/", SensorComparisonChartView.as_view(), name="sensor-comparison-chart"),
path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"),
path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"),
]
@@ -0,0 +1,31 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0009_sync_devicecatalog_schema"),
]
operations = [
migrations.AddField(
model_name="farmdevice",
name="cluster_uuid",
field=models.UUIDField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name="farmdevice",
name="location_metadata",
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name="sensorexternalrequestlog",
name="cluster_uuid",
field=models.UUIDField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name="sensorexternalrequestlog",
name="location_metadata",
field=models.JSONField(blank=True, default=dict),
),
]
+12
View File
@@ -60,6 +60,8 @@ class FarmDevice(models.Model):
physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True)
name = models.CharField(max_length=255)
sensor_type = models.CharField(max_length=255, blank=True, default="")
cluster_uuid = models.UUIDField(null=True, blank=True, db_index=True)
location_metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
specifications = models.JSONField(default=dict, blank=True)
power_source = models.JSONField(default=dict, blank=True)
@@ -90,11 +92,21 @@ class FarmDevice(models.Model):
return catalog
return None
def get_sensor_key(self):
if self.sensor_catalog and self.sensor_catalog.code:
return self.sensor_catalog.code
return "sensor-7-1"
def get_ai_device_key(self):
return f"device:{self.physical_device_uuid}"
class SensorExternalRequestLog(models.Model):
farm_uuid = models.UUIDField(db_index=True)
sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True)
physical_device_uuid = models.UUIDField(db_index=True)
cluster_uuid = models.UUIDField(null=True, blank=True, db_index=True)
location_metadata = models.JSONField(default=dict, blank=True)
payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
-9
View File
@@ -1,9 +0,0 @@
from django.urls import path
from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
urlpatterns = [
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
path("radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"),
path("comparison-chart/", Sensor7In1ComparisonChartView.as_view(), name="sensor-7-in-1-comparison-chart"),
]
+6
View File
@@ -61,6 +61,8 @@ class FarmDeviceLogSerializer(serializers.ModelSerializer):
"physical_device_uuid",
"name",
"sensor_type",
"cluster_uuid",
"location_metadata",
"is_active",
"specifications",
"power_source",
@@ -105,6 +107,8 @@ class DeviceDetailSerializer(serializers.ModelSerializer):
"physical_device_uuid",
"name",
"sensor_type",
"cluster_uuid",
"location_metadata",
"is_active",
"specifications",
"power_source",
@@ -171,6 +175,8 @@ class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
"farm_uuid",
"sensor_catalog_uuid",
"physical_device_uuid",
"cluster_uuid",
"location_metadata",
"farm_device",
"sensor_catalog",
"payload",
+166 -6
View File
@@ -1,6 +1,7 @@
from copy import deepcopy
from datetime import timedelta
import logging
import uuid
from django.conf import settings
from django.db import OperationalError, ProgrammingError, transaction
@@ -99,14 +100,23 @@ def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, ph
def create_sensor_external_notification(*, physical_device_uuid, payload=None):
payload = payload or {}
sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
runtime_context = build_sensor_runtime_context(sensor=sensor, payload=payload)
return create_sensor_external_notification_for_sensor(sensor=sensor, payload=payload, runtime_context=runtime_context)
def create_sensor_external_notification_for_sensor(*, sensor, payload=None, runtime_context=None):
payload = payload or {}
if sensor is None:
raise ValueError("Physical device not found.")
runtime_context = runtime_context or build_sensor_runtime_context(sensor=sensor, payload=payload)
try:
with transaction.atomic():
SensorExternalRequestLog.objects.create(
farm_uuid=sensor.farm.farm_uuid,
sensor_catalog_uuid=sensor.sensor_catalog.uuid if sensor.sensor_catalog else None,
physical_device_uuid=sensor.physical_device_uuid,
cluster_uuid=runtime_context["cluster_uuid"],
location_metadata=runtime_context["location_metadata"],
payload=payload,
)
return create_notification_for_farm_uuid(
@@ -114,7 +124,14 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None):
title="Sensor external API request",
message=f"Payload received from device {sensor.physical_device_uuid}.",
level="info",
metadata={"farm_uuid": str(sensor.farm.farm_uuid), "sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None, "physical_device_uuid": str(sensor.physical_device_uuid), "payload": payload},
metadata={
"farm_uuid": str(sensor.farm.farm_uuid),
"sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None,
"physical_device_uuid": str(sensor.physical_device_uuid),
"cluster_uuid": str(runtime_context["cluster_uuid"]) if runtime_context["cluster_uuid"] else None,
"location_metadata": runtime_context["location_metadata"],
"payload": payload,
},
)
except (ProgrammingError, OperationalError) as exc:
raise ValueError("Sensor external API tables are not migrated.") from exc
@@ -123,15 +140,31 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None):
def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None):
payload = payload or {}
sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
runtime_context = build_sensor_runtime_context(sensor=sensor, payload=payload)
return forward_sensor_payload_to_farm_data_for_sensor(sensor=sensor, payload=payload, runtime_context=runtime_context)
def forward_sensor_payload_to_farm_data_for_sensor(*, sensor, payload=None, runtime_context=None):
payload = payload or {}
if sensor is None:
raise ValueError("Physical device not found.")
farm_boundary = _get_farm_boundary(sensor=sensor)
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
if not api_key:
raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.")
runtime_context = runtime_context or build_sensor_runtime_context(sensor=sensor, payload=payload)
sensor_key = _get_sensor_key(sensor=sensor)
normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=payload)
request_payload = {"farm_uuid": str(sensor.farm.farm_uuid), "farm_boundary": farm_boundary, "sensor_key": sensor_key, "sensor_payload": normalized_sensor_payload}
request_payload = {
"farm_uuid": str(sensor.farm.farm_uuid),
"farm_boundary": farm_boundary,
"sensor_key": sensor_key,
"sensor_payload": _build_ai_sensor_payload(
sensor=sensor,
sensor_key=sensor_key,
sensor_payload=payload,
runtime_context=runtime_context,
),
}
try:
response = external_api_request(
"ai",
@@ -169,10 +202,137 @@ def _normalize_sensor_payload(*, sensor_key, sensor_payload):
return {sensor_key: sensor_payload}
def _build_ai_sensor_payload(*, sensor, sensor_key, sensor_payload, runtime_context=None):
if sensor_payload and not isinstance(sensor_payload, dict):
raise FarmDataForwardError("`payload` must be a JSON object.")
raw_payload = _extract_payload(sensor_payload)
runtime_context = runtime_context or build_sensor_runtime_context(sensor=sensor, payload=sensor_payload)
device_payload = {
"sensor_key": sensor_key,
"physical_device_uuid": str(sensor.physical_device_uuid),
"recorded_at": timezone.now().isoformat(),
"metrics": raw_payload or {},
"metadata": {
"source_service": "backend_device_hub",
"farm_device_uuid": str(sensor.uuid),
"sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None,
"sensor_type": sensor.sensor_type or "",
"device_name": sensor.name or "",
"cluster_uuid": str(runtime_context["cluster_uuid"]) if runtime_context["cluster_uuid"] else None,
"location": runtime_context["location_metadata"],
},
}
if runtime_context["cluster_uuid"] is not None:
device_payload["cluster_uuid"] = str(runtime_context["cluster_uuid"])
if runtime_context["location_metadata"].get("zone") is not None:
device_payload["zone"] = runtime_context["location_metadata"]["zone"]
if runtime_context["location_metadata"].get("depth_cm") is not None:
device_payload["depth_cm"] = runtime_context["location_metadata"]["depth_cm"]
return {
sensor.get_ai_device_key(): device_payload
}
def build_sensor_runtime_context(*, sensor, payload=None):
payload = payload or {}
payload_location = _extract_payload_location_metadata(payload)
payload_cluster_uuid = _extract_cluster_uuid(payload)
location_metadata = dict(sensor.location_metadata or {})
location_metadata.update(payload_location)
return {
"cluster_uuid": payload_cluster_uuid or sensor.cluster_uuid,
"location_metadata": location_metadata,
}
def sync_sensor_runtime_context(*, sensor, payload=None):
if sensor is None:
raise ValueError("Physical device not found.")
runtime_context = build_sensor_runtime_context(sensor=sensor, payload=payload)
update_fields = []
if runtime_context["cluster_uuid"] != sensor.cluster_uuid:
sensor.cluster_uuid = runtime_context["cluster_uuid"]
update_fields.append("cluster_uuid")
if runtime_context["location_metadata"] != (sensor.location_metadata or {}):
sensor.location_metadata = runtime_context["location_metadata"]
update_fields.append("location_metadata")
if update_fields:
update_fields.append("updated_at")
sensor.save(update_fields=update_fields)
return runtime_context
def _extract_cluster_uuid(payload):
if not isinstance(payload, dict):
return None
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
candidates = [
payload.get("cluster_uuid"),
payload.get("clusterId"),
metadata.get("cluster_uuid"),
metadata.get("clusterId"),
]
for candidate in candidates:
parsed = _parse_uuid(candidate)
if parsed is not None:
return parsed
return None
def _extract_payload_location_metadata(payload):
if not isinstance(payload, dict):
return {}
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
location = payload.get("location") if isinstance(payload.get("location"), dict) else {}
coordinates = payload.get("coordinates") if isinstance(payload.get("coordinates"), dict) else {}
lat = payload.get("lat", payload.get("latitude"))
lon = payload.get("lon", payload.get("lng", payload.get("longitude")))
if lat is None:
lat = location.get("lat", location.get("latitude"))
if lon is None:
lon = location.get("lon", location.get("lng", location.get("longitude")))
if lat is None:
lat = coordinates.get("lat", coordinates.get("latitude"))
if lon is None:
lon = coordinates.get("lon", coordinates.get("lng", coordinates.get("longitude")))
result = {}
if lat is not None:
result["lat"] = lat
if lon is not None:
result["lon"] = lon
for key in ("zone", "depth_cm", "cluster_code", "cluster_label"):
value = payload.get(key, metadata.get(key))
if value not in (None, ""):
result[key] = value
if location:
result["location"] = location
elif coordinates:
result["location"] = coordinates
return result
def _parse_uuid(value):
if value in (None, ""):
return None
try:
return uuid.UUID(str(value))
except (TypeError, ValueError, AttributeError):
return None
def _get_sensor_key(*, sensor):
if sensor.sensor_catalog and sensor.sensor_catalog.code:
return sensor.sensor_catalog.code
return "sensor-7-1"
return sensor.get_sensor_key()
def _get_farm_data_path():
+1 -2
View File
@@ -1,6 +1,6 @@
from django.urls import path
from .views import DeviceCatalogListView, DeviceCodeListView, DeviceCommandView, DeviceComparisonChartView, DeviceDetailView, DeviceLatestPayloadView, DeviceLogListView, DeviceRadarChartView, DeviceSummaryView, DeviceValuesListView, Sensor7In1SummaryView, SensorExternalAPIView, SensorExternalRequestLogListAPIView
from .views import DeviceCatalogListView, DeviceCodeListView, DeviceCommandView, DeviceComparisonChartView, DeviceDetailView, DeviceLatestPayloadView, DeviceLogListView, DeviceRadarChartView, DeviceSummaryView, DeviceValuesListView, SensorExternalAPIView, SensorExternalRequestLogListAPIView
urlpatterns = [
path("catalog/", DeviceCatalogListView.as_view(), name="device-catalog-list"),
@@ -13,7 +13,6 @@ urlpatterns = [
path("devices/<uuid:physical_device_uuid>/radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"),
path("devices/<uuid:physical_device_uuid>/logs/", DeviceLogListView.as_view(), name="device-log-list"),
path("devices/<uuid:physical_device_uuid>/commands/", DeviceCommandView.as_view(), name="device-command"),
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"),
path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"),
path("external/logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
+20 -3
View File
@@ -13,7 +13,7 @@ from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerial
from .authentication import SensorExternalAPIKeyAuthentication
from .sensor_serializers import DeviceSummarySerializer, Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer
from .serializers import DeviceCatalogSerializer, DeviceCodeListResponseSerializer, DeviceCodeQuerySerializer, DeviceCommandRequestSerializer, DeviceCommandResponseSerializer, DeviceDetailSerializer, DeviceLatestPayloadSerializer, DeviceRangeQuerySerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer
from .services import DeviceDataUnavailableError, FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog
from .services import DeviceDataUnavailableError, FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification_for_sensor, execute_device_command, forward_sensor_payload_to_farm_data_for_sensor, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, sync_sensor_runtime_context, validate_output_device_catalog
class DeviceCatalogListView(APIView):
@@ -297,9 +297,26 @@ class SensorExternalAPIView(APIView):
def post(self, request):
serializer = SensorExternalRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
sensor = get_farm_device_by_physical_uuid(
physical_device_uuid=serializer.validated_data["uuid"]
)
if sensor is None:
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
try:
notification = create_sensor_external_notification(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
forward_sensor_payload_to_farm_data(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
runtime_context = sync_sensor_runtime_context(
sensor=sensor,
payload=serializer.validated_data.get("payload"),
)
forward_sensor_payload_to_farm_data_for_sensor(
sensor=sensor,
payload=serializer.validated_data.get("payload"),
runtime_context=runtime_context,
)
notification = create_sensor_external_notification_for_sensor(
sensor=sensor,
payload=serializer.validated_data.get("payload"),
runtime_context=runtime_context,
)
except ValueError as exc:
if "not migrated" in str(exc):
return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,612 @@
# راهنمای فرانت برای API های Location Data
این فایل برای تیم فرانت نوشته شده تا بتواند API های `Location Data` را سریع و دقیق مصرف کند.
مسیرهای اصلی:
- `GET /api/location-data/`
- `POST /api/location-data/`
- `POST /api/location-data/ndvi-health/`
- `GET /api/location-data/remote-sensing/`
- `POST /api/location-data/remote-sensing/`
- `GET /api/location-data/remote-sensing/cluster-blocks/{cluster_uuid}/live/`
- `GET /api/location-data/remote-sensing/cluster-recommendations/`
- `GET /api/location-data/remote-sensing/results/{result_id}/k-options/`
- `POST /api/location-data/remote-sensing/results/{result_id}/k-options/activate/`
- `GET /api/location-data/remote-sensing/runs/{run_id}/status/`
## احراز هویت
همه این endpointها با JWT کار می‌کنند.
نمونه header:
```http
Authorization: Bearer <access_token>
Content-Type: application/json
```
## ساختار عمومی response
تقریبا همه endpointها این فرم را دارند:
```json
{
"code": 200,
"msg": "success",
"data": {}
}
```
قاعده پیشنهادی در فرانت:
1. اول `HTTP status` را چک کنید.
2. بعد `code` را از body چک کنید.
3. در موفقیت، فقط `data` را به state یا UI بدهید.
4. در خطا، `msg` را به عنوان پیام اصلی نمایش دهید.
5. اگر `data` شامل خطای فیلدها بود، آن را برای فرم map کنید.
نمونه خطای validation:
```json
{
"code": 400,
"msg": "داده نامعتبر.",
"data": {
"farm_uuid": ["This field is required."]
}
}
```
نمونه خطای not found:
```json
{
"code": 404,
"msg": "location پیدا نشد.",
"data": null
}
```
---
## 1) دریافت location ذخیره شده
### `GET /api/location-data/`
کاربرد:
- خواندن location ذخیره شده
- دریافت farm boundary
- دریافت block layout
- دریافت subdivisionها و snapshotهای ماهواره ای ذخیره شده
### query params
- `lat` اختیاری
- `lon` اختیاری
- `farm_uuid` اختیاری
### نمونه درخواست
```http
GET /api/location-data/?farm_uuid=<farm_uuid>
```
### نمونه response
```json
{
"code": 200,
"msg": "success",
"data": {
"source": "database",
"id": 12,
"lon": "51.389000",
"lat": "35.689200",
"input_block_count": 2,
"farm_boundary": {},
"block_layout": {},
"block_subdivisions": [],
"satellite_snapshots": []
}
}
```
### استفاده در فرانت
- `farm_boundary` را برای رسم polygon کل مزرعه استفاده کنید.
- `block_layout` را برای رندر blockها استفاده کنید.
- `block_subdivisions` برای نمایش grid/subdivision مفید است.
- `satellite_snapshots` برای summaryهای تاریخی یا cache قابل استفاده است.
---
## 2) ثبت یا به روزرسانی location
### `POST /api/location-data/`
کاربرد:
- ساخت location جدید
- update location قبلی
- ثبت farm boundary
- ثبت block layout
### نمونه body
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"lat": 35.6892,
"lon": 51.389,
"farm_boundary": {
"type": "Polygon",
"coordinates": []
},
"block_layout": {
"blocks": []
}
}
```
### نکات فرانت
- اگر کاربر هنوز boundary را کامل نکرده، این endpoint را صدا نزنید.
- در صورت دریافت `source = created` می‌توانید UI را به عنوان location جدید mark کنید.
- در صورت دریافت `source = database` یعنی رکورد از قبل وجود داشته یا update شده است.
---
## 3) دریافت NDVI health
### `POST /api/location-data/ndvi-health/`
کاربرد:
- گرفتن کارت سلامت پوشش گیاهی مزرعه
- نمایش شاخص NDVI در UI
### body
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### response مهم
```json
{
"code": 200,
"msg": "success",
"data": {
"ndviIndex": 0.63,
"mean_ndvi": 0.63,
"ndvi_map": {},
"vegetation_health_class": "healthy",
"observation_date": "2026-05-12",
"satellite_source": "sentinel-2",
"healthData": [
{
"title": "میانگین NDVI",
"value": 0.63,
"color": "green",
"icon": "leaf"
}
]
}
}
```
### استفاده در فرانت
- `ndviIndex` را به عنوان KPI اصلی نمایش دهید.
- `vegetation_health_class` را برای badge یا رنگ وضعیت استفاده کنید.
- `healthData` را برای کارت های summary استفاده کنید.
- `ndvi_map` اگر لایه نقشه داشت، به map layer وصل شود.
---
## 4) خواندن cache سنجش از دور
### `GET /api/location-data/remote-sensing/`
کاربرد:
- فقط داده cache شده یا ذخیره شده را می‌خواند
- پردازش جدید شروع نمی‌کند
### query params
- `farm_uuid` اجباری
- `page` اختیاری
- `page_size` اختیاری
- `start_date` اختیاری
- `end_date` اختیاری
### نمونه درخواست
```http
GET /api/location-data/remote-sensing/?farm_uuid=<farm_uuid>&page=1&page_size=50
```
### فیلدهای مهم response
- `status`
- `source`
- `location`
- `summary`
- `cells`
- `run`
- `subdivision_result`
- `pagination`
- `metadata`
### رفتار پیشنهادی در فرانت بر اساس `status`
- `success`: داده آماده است و باید render شود.
- `processing`: هنوز نتیجه نهایی آماده نیست؛ loading یا polling state نشان دهید.
- `not_found`: هنوز تحلیل برای این مزرعه ساخته نشده؛ می‌توانید `POST /remote-sensing/` را بزنید.
### استفاده در فرانت
- `cells` برای نقشه سلولی و heatmap مناسب است.
- `summary` برای کارت آماری بالای صفحه مناسب است.
- `subdivision_result.cluster_blocks` برای نمایش cluster polygonها استفاده شود.
- `assignments` برای رنگ آمیزی سلول ها بر اساس label کلاستر مفید است.
---
## 5) شروع تحلیل سنجش از دور
### `POST /api/location-data/remote-sensing/`
کاربرد:
- شروع async processing
- ساخت run و `task_id`
- شروع جریان polling برای UI
### body
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### response
```json
{
"code": 202,
"msg": "تحلیل سنجش‌ازدور در صف قرار گرفت.",
"data": {
"status": "processing",
"source": "processing",
"location": {},
"block_code": "",
"chunk_size_sqm": 900,
"temporal_extent": {
"start_date": "2026-04-12",
"end_date": "2026-05-12"
},
"summary": {
"cell_count": 0,
"ndvi_mean": null,
"ndwi_mean": null,
"soil_vv_db_mean": null
},
"cells": [],
"run": {},
"task_id": "11111111-1111-1111-1111-111111111111"
}
}
```
### قرارداد مهم برای فرانت
- همیشه `task_id` را ذخیره کنید.
- اگر `run.id` موجود بود، برای status endpoint از آن استفاده کنید.
- بعد از این endpoint بلافاصله polling را شروع کنید.
### flow پیشنهادی
```text
POST /remote-sensing/
-> دریافت task_id / run
-> هر چند ثانیه GET /runs/{run_id}/status/
-> وقتی status = completed شد، همان payload را مصرف کن
```
---
## 6) دریافت live metrics برای یک cluster
### `GET /api/location-data/remote-sensing/cluster-blocks/{cluster_uuid}/live/`
کاربرد:
- گرفتن metricهای یک cluster
- استفاده برای panel جزئیات یا modal زنده
### نمونه درخواست
```http
GET /api/location-data/remote-sensing/cluster-blocks/<cluster_uuid>/live/
```
### فیلدهای مهم
- `source`
- `cluster_block`
- `summary`
- `metrics`
- `metadata`
### نکات فرانت
- اگر `source = database` بود، label بزنید که داده cache است.
- اگر `source = openeo` بود، می‌توانید label زنده یا live نمایش دهید.
- `metrics` برای KPIهای سریع مناسب است.
- `cluster_block.geometry` را برای هایلایت روی نقشه استفاده کنید.
---
## 7) پیشنهاد گیاه برای clusterها
### `GET /api/location-data/remote-sensing/cluster-recommendations/`
کاربرد:
- دریافت پیشنهاد محصول برای هر cluster
- نمایش candidate plantها و suggested plant
### query params
- `farm_uuid` اجباری
نمونه:
```http
GET /api/location-data/remote-sensing/cluster-recommendations/?farm_uuid=<farm_uuid>
```
### فیلدهای مهم response
- `farm_uuid`
- `location_id`
- `registered_plants`
- `clusters`
- `evaluated_plant_count`
- `cluster_count`
- `source_metadata`
### نکات مهم برای فرانت
- هر آیتم `clusters` دقیقا مربوط به یک cluster از خروجی KMeans است.
- `candidate_plants` لیست کامل رتبه‌بندی است و `suggested_plant` بهترین آیتم همان لیست است.
- `resolved_metrics` همان متریک نهایی است که برای simulation استفاده شده و بهتر است مبنای نمایش KPI باشد.
- `cluster_block` برای رسم روی نقشه و نمایش geometry، centroid و cellها استفاده می‌شود.
- `source_metadata.has_sensor_metrics` مشخص می‌کند آیا باید در UI بخش سنسورها را نمایش دهید یا نه.
### استفاده در فرانت
برای هر cluster این بخش ها مهم هستند:
- `sub_block_code`
- `cluster_label`
- `temporal_extent`
- `cluster_block`
- `satellite_metrics`
- `sensor_metrics`
- `resolved_metrics`
- `candidate_plants`
- `suggested_plant`
### UI پیشنهادی
- کارت cluster با عنوان `sub_block_code` یا `cluster_label`
- بازه زمانی از `temporal_extent.start_date` تا `temporal_extent.end_date`
- KPIهای `resolved_metrics`
- جدول candidateها با score
- highlight کردن `suggested_plant`
- اگر `candidate_plants` خالی بود، state خالی و بدون recommendation نشان دهید
---
## 8) لیست K optionها
### `GET /api/location-data/remote-sensing/results/{result_id}/k-options/`
کاربرد:
- گرفتن همه Kهای ذخیره شده برای یک subdivision result
### response مهم
```json
{
"code": 200,
"msg": "success",
"data": {
"result_id": 5,
"active_requested_k": 3,
"recommended_requested_k": 4,
"options": []
}
}
```
### استفاده در فرانت
- `active_requested_k` را به عنوان گزینه فعال UI نشان دهید.
- `recommended_requested_k` را با badge پیشنهادی نمایش دهید.
- `options` را برای dropdown یا segmented control استفاده کنید.
---
## 9) فعال سازی یک K
### `POST /api/location-data/remote-sensing/results/{result_id}/k-options/activate/`
### body
```json
{
"requested_k": 4
}
```
### استفاده در فرانت
- وقتی کاربر K جدید را انتخاب می‌کند این endpoint را صدا بزنید.
- بعد از موفقیت، `subdivision_result` برگشتی را جایگزین state قبلی کنید.
- لازم نیست دوباره `GET /remote-sensing/` را صدا بزنید اگر payload کامل برگشت.
---
## 10) polling وضعیت run
### `GET /api/location-data/remote-sensing/runs/{run_id}/status/`
کاربرد:
- فهمیدن این که pipeline در چه مرحله‌ای است
- دریافت نتیجه نهایی به محض completion
### statusهای مهم
- `pending`
- `running`
- `retrying`
- `completed`
- `failed`
### رفتار پیشنهادی در فرانت
- `pending`: queue state
- `running`: progress state
- `retrying`: پیام retry موقت
- `completed`: داده نهایی را render کن
- `failed`: CTA برای retry بده
### نکته مهم
اگر `status = completed` شد، همان response نهایی را مصرف کنید و polling را stop کنید.
---
## فیلدهای مهم برای map
### farm level
- `farm_boundary`
- `block_layout.blocks`
- `block_subdivisions`
### remote sensing level
- `cells[].geometry`
- `subdivision_result.cluster_blocks[].geometry`
- `subdivision_result.assignments[]`
- `cluster_block.geometry`
### پیشنهاد برای لایه های نقشه
1. لایه مرز مزرعه
2. لایه blockها
3. لایه cellها یا heatmap
4. لایه cluster blockها
5. لایه selected cluster highlight
---
## خطاهایی که فرانت باید handle کند
### 400
- ورودی ناقص یا نامعتبر
- باید خطای فیلدی یا toast نشان داده شود
### 404
- مزرعه یا location یا result پیدا نشده
- برای UI بهتر است empty state نمایش داده شود
### 502
- خطا از backend upstream مثل openEO یا AI
- بهتر است retry action داشته باشید
---
## flow پیشنهادی کامل برای صفحه تحلیل
### سناریو اول: فقط نمایش داده موجود
```text
GET /api/location-data/remote-sensing/?farm_uuid=<farm_uuid>
-> اگر status=success : render
-> اگر status=processing : برو به polling
-> اگر status=not_found : دکمه شروع تحلیل نمایش بده
```
### سناریو دوم: کاربر تحلیل را شروع می‌کند
```text
POST /api/location-data/remote-sensing/
-> 202
-> run/task_id را ذخیره کن
-> GET /api/location-data/remote-sensing/runs/{run_id}/status/
-> وقتی completed شد نتیجه را render کن
```
### سناریو سوم: کاربر K را تغییر می‌دهد
```text
GET /results/{result_id}/k-options/
-> انتخاب K
-> POST /results/{result_id}/k-options/activate/
-> subdivision_result جدید را render کن
```
---
## پیشنهاد state management در فرانت
حداقل stateهایی که نیاز دارید:
```ts
{
location: null,
remoteSensing: null,
runStatus: null,
clusterRecommendations: [],
selectedClusterUuid: null,
kOptions: [],
loading: false,
polling: false,
error: null
}
```
---
## نکات نهایی برای تیم فرانت
- برای endpointهای async همیشه polling را در نظر بگیرید.
- `code` را از body نادیده نگیرید.
- روی `status` در remote sensing و run status منطق UI بنویسید.
- داده های هندسی را مستقیم برای map layerها مصرف کنید.
- `cluster_uuid`, `result_id`, `run_id` را بعد از اولین response در state نگه دارید.
---
## فایل مکمل
اگر به جزئیات کامل همه responseها نیاز دارید، این فایل را هم ببینید:
- `docs/location_data_api_responses_fa.md`
+4
View File
@@ -53,6 +53,8 @@ class FarmDeviceSerializer(serializers.ModelSerializer):
"physical_device_uuid",
"name",
"sensor_type",
"cluster_uuid",
"location_metadata",
"is_active",
"specifications",
"power_source",
@@ -116,6 +118,8 @@ class FarmDeviceWriteSerializer(serializers.ModelSerializer):
"physical_device_uuid",
"name",
"sensor_type",
"cluster_uuid",
"location_metadata",
"is_active",
"specifications",
"power_source",
+27 -5
View File
@@ -4,10 +4,10 @@ from django.conf import settings
from django.db import transaction
from crop_zoning.services import (
create_zones_and_dispatch,
get_default_area_feature,
get_initial_zones_payload,
normalize_area_feature,
ensure_latest_area_ready_for_processing,
)
from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
@@ -22,7 +22,11 @@ class FarmDataSyncError(Exception):
def dispatch_farm_zoning(area_feature, farm):
crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), farm=farm)
crop_area = ensure_latest_area_ready_for_processing(
farm_uuid=farm.farm_uuid,
area_feature=normalize_area_feature(area_feature),
owner=farm.owner,
)
return crop_area, get_initial_zones_payload(crop_area)
@@ -80,15 +84,32 @@ def sync_farm_data(
if plant_ids:
request_payload["plant_ids"] = [int(plant_id) for plant_id in plant_ids]
if farm.farm_type_id:
request_payload["farm_type_uuid"] = str(farm.farm_type.uuid)
request_payload["farm_type_name"] = farm.farm_type.name
request_payload["farm_type_description"] = farm.farm_type.description
request_payload["farm_type_metadata"] = (
farm.farm_type.metadata if isinstance(farm.farm_type.metadata, dict) else {}
)
resolved_irrigation_method_id = irrigation_method_id
if resolved_irrigation_method_id is None:
resolved_irrigation_method_id = farm.irrigation_method_id
if resolved_irrigation_method_id is not None:
request_payload["irrigation_method_id"] = int(resolved_irrigation_method_id)
if not any(key in request_payload for key in ("sensor_payload", "plant_ids", "irrigation_method_id")):
if not any(
key in request_payload
for key in (
"sensor_payload",
"plant_ids",
"farm_type_uuid",
"farm_type_name",
"irrigation_method_id",
)
):
raise FarmDataSyncError(
"At least one of `sensor_payload`, `plant_ids`, or `irrigation_method_id` is required for farm data sync."
"At least one of `sensor_payload`, `plant_ids`, `farm_type`, or `irrigation_method_id` is required for farm data sync."
)
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
@@ -97,11 +118,12 @@ def sync_farm_data(
raise FarmDataSyncError("FARM_DATA_API_KEY is not configured.")
logger.warning(
"Farm data sync start: farm_uuid=%s sensor_key=%s has_sensor_payload=%s plant_ids=%s irrigation_method_id=%s boundary_type=%s",
"Farm data sync start: farm_uuid=%s sensor_key=%s has_sensor_payload=%s plant_ids=%s farm_type_uuid=%s irrigation_method_id=%s boundary_type=%s",
farm.farm_uuid,
request_payload.get("sensor_key"),
"sensor_payload" in request_payload,
request_payload.get("plant_ids"),
request_payload.get("farm_type_uuid"),
request_payload.get("irrigation_method_id"),
request_payload["farm_boundary"].get("type") if isinstance(request_payload["farm_boundary"], dict) else None,
)
+3 -1
View File
@@ -97,12 +97,14 @@ class FarmListCreateViewTests(TestCase):
self.assertEqual(len(response.data["data"]["sensors"]), 1)
self.assertEqual(response.data["data"]["sensors"][0]["sensor_catalog_uuid"], str(self.weather_station.uuid))
self.assertEqual(response.data["data"]["sensors"][0]["physical_device_uuid"], physical_device_uuid)
self.assertGreater(response.data["data"]["zoning"]["zone_count"], 1)
self.assertEqual(response.data["data"]["zoning"]["zone_count"], 0)
self.assertEqual(response.data["data"]["zoning"]["zones"], [])
self.assertEqual(
response.data["data"]["zoning"]["zone_count"],
CropArea.objects.get().zone_count,
)
self.assertEqual(CropArea.objects.count(), 1)
self.assertEqual(CropArea.objects.get().geometry, AREA_GEOJSON)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/farm-data/",
+13 -1
View File
@@ -119,7 +119,19 @@ def serialize_products_for_ai(products=None):
"growth_profile": product.growth_profile if isinstance(product.growth_profile, dict) else {},
"is_active": True,
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
"farm_type": product.farm_type.name if product.farm_type_id else DEFAULT_FARM_TYPE_NAME,
"farm_type": {
"uuid": str(product.farm_type.uuid) if product.farm_type_id else None,
"name": product.farm_type.name if product.farm_type_id else DEFAULT_FARM_TYPE_NAME,
"description": product.farm_type.description if product.farm_type_id else "",
"metadata": (
product.farm_type.metadata
if product.farm_type_id and isinstance(product.farm_type.metadata, dict)
else {}
),
"updated_at": product.farm_type.updated_at.isoformat()
if product.farm_type_id and product.farm_type.updated_at
else None,
},
}
)
return payload
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
set -euo pipefail
MODEL="${OPENAI_MODEL:-gpt-4}"
BASE_URL="${OPENAI_BASE_URL:-${GAPGPT_BASE_URL:-https://api.gapgpt.app/v1}}"
API_KEY="${OPENAI_API_KEY:-${GAPGPT_API_KEY:-}}"
EDITOR_CMD="${EDITOR:-vi}"
COMMIT_MESSAGE=""
BRANCH_NAME=""
FINAL_BRANCH_NAME=""
FINAL_BRANCH_MODE=""
usage() {
cat <<'USAGE'
Usage: ai_git_commit.sh
Required environment variables:
`OPENAI_API_KEY` or `GAPGPT_API_KEY`
Optional environment variables:
`OPENAI_BASE_URL` or `GAPGPT_BASE_URL` (default: https://api.gapgpt.app/v1)
`OPENAI_MODEL` Model name (default: gpt-4)
`EDITOR` Editor used for manual commit message edits
USAGE
}
require_command() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Error: '$command_name' is required but not installed." >&2
exit 1
fi
}
ensure_git_repo() {
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "Error: current directory is not a git repository." >&2
exit 1
}
}
get_staged_diff() {
git diff --staged --patch --minimal
}
build_prompt() {
local diff_content="$1"
cat <<PROMPT
You are a senior engineer helping with Git hygiene.
Analyze the staged git diff below and respond with valid JSON only.
Requirements:
- The response must be a JSON object with exactly these keys: commit_message, branch_name.
- commit_message must be a single descriptive paragraph in plain text.
- branch_name must use the format type/short-description.
- branch_name should be lowercase, concise, and use hyphens instead of spaces.
- Do not wrap the JSON in markdown fences.
Staged diff:
${diff_content}
PROMPT
}
call_openai() {
local prompt="$1"
local endpoint="${BASE_URL%/}/chat/completions"
local payload
payload=$(jq -n \
--arg model "$MODEL" \
--arg prompt "$prompt" \
'{
model: $model,
temperature: 0.2,
response_format: {type: "json_object"},
messages: [
{role: "system", content: "You generate commit metadata from git diffs and always return strict JSON."},
{role: "user", content: $prompt}
]
}')
curl --silent --show-error --fail \
--header "Content-Type: application/json" \
--header "Authorization: Bearer ${API_KEY}" \
--data "$payload" \
"$endpoint"
}
extract_ai_content() {
local api_response="$1"
jq -er '.choices[0].message.content' <<<"$api_response"
}
parse_ai_json() {
local ai_json="$1"
COMMIT_MESSAGE=$(jq -er '.commit_message' <<<"$ai_json")
BRANCH_NAME=$(jq -er '.branch_name' <<<"$ai_json")
}
edit_multiline_value() {
local initial_value="$1"
local temp_file
temp_file=$(mktemp)
printf '%s\n' "$initial_value" > "$temp_file"
"$EDITOR_CMD" "$temp_file"
local edited_value
edited_value=$(sed '/^[[:space:]]*$/d' "$temp_file")
rm -f "$temp_file"
if [[ -z "$edited_value" ]]; then
echo "Error: value cannot be empty." >&2
exit 1
fi
printf '%s' "$edited_value"
}
edit_single_line_value() {
local current_value="$1"
local updated_value
read -r -e -i "$current_value" -p "> " updated_value
if [[ -z "$updated_value" ]]; then
echo "Error: value cannot be empty." >&2
exit 1
fi
printf '%s' "$updated_value"
}
confirm_or_edit_commit_message() {
local current_message="$1"
echo
echo "Suggested commit message:"
echo "----------------------------------------"
printf '%s\n' "$current_message"
echo "----------------------------------------"
echo "1) Use as-is"
echo "2) Edit in \$EDITOR (${EDITOR_CMD})"
local choice
read -r -p "Choose an option [1-2]: " choice
case "$choice" in
1) printf '%s' "$current_message" ;;
2) edit_multiline_value "$current_message" ;;
*)
echo "Error: invalid selection." >&2
exit 1
;;
esac
}
list_local_branches() {
git for-each-ref --format='%(refname:short)' refs/heads
}
select_existing_branch() {
mapfile -t branches < <(list_local_branches)
if [[ ${#branches[@]} -eq 0 ]]; then
echo "Error: no local branches found." >&2
exit 1
fi
echo
echo "Select an existing branch:"
select branch in "${branches[@]}"; do
if [[ -n "${branch:-}" ]]; then
printf '%s' "$branch"
return 0
fi
echo "Invalid selection. Try again." >&2
done
}
validate_branch_name() {
local branch_name="$1"
if ! git check-ref-format --branch "$branch_name" >/dev/null 2>&1; then
echo "Error: '$branch_name' is not a valid git branch name." >&2
exit 1
fi
}
choose_branch() {
local suggested_branch="$1"
local branch_choice
local branch_value
local branch_mode
echo
echo "Suggested branch name: $suggested_branch"
echo "1) Create new branch with suggested name"
echo "2) Edit branch name and create new branch"
echo "3) Select an existing branch"
read -r -p "Choose an option [1-3]: " branch_choice
case "$branch_choice" in
1)
branch_value="$suggested_branch"
branch_mode="new"
;;
2)
branch_value=$(edit_single_line_value "$suggested_branch")
branch_mode="new"
;;
3)
branch_value=$(select_existing_branch)
branch_mode="existing"
;;
*)
echo "Error: invalid selection." >&2
exit 1
;;
esac
validate_branch_name "$branch_value"
if git show-ref --verify --quiet "refs/heads/$branch_value"; then
branch_mode="existing"
fi
FINAL_BRANCH_NAME="$branch_value"
FINAL_BRANCH_MODE="$branch_mode"
}
checkout_branch() {
local branch_name="$1"
local branch_mode="$2"
if [[ "$branch_mode" == "new" ]]; then
git checkout -b "$branch_name"
else
git checkout "$branch_name"
fi
}
print_summary() {
local commit_message="$1"
local branch_name="$2"
local branch_mode="$3"
echo
echo "Final plan:"
echo "- Branch: $branch_name ($branch_mode)"
echo "- Commit message:"
printf '%s\n' "$commit_message"
}
main() {
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
require_command git
require_command curl
require_command jq
ensure_git_repo
if [[ -z "$API_KEY" ]]; then
echo "Error: set OPENAI_API_KEY or GAPGPT_API_KEY." >&2
exit 1
fi
local staged_diff
staged_diff=$(get_staged_diff)
if [[ -z "$staged_diff" ]]; then
echo "Error: there are no staged changes to analyze." >&2
exit 1
fi
echo "Analyzing staged changes with ${MODEL}..."
local prompt
prompt=$(build_prompt "$staged_diff")
local api_response
if ! api_response=$(call_openai "$prompt"); then
echo "Error: failed to contact the OpenAI-compatible API." >&2
exit 1
fi
local ai_content
if ! ai_content=$(extract_ai_content "$api_response"); then
echo "Error: API response did not include message content." >&2
exit 1
fi
if ! parse_ai_json "$ai_content"; then
echo "Error: AI response was not valid JSON with the required keys." >&2
exit 1
fi
local final_commit_message
final_commit_message=$(confirm_or_edit_commit_message "$COMMIT_MESSAGE")
choose_branch "$BRANCH_NAME"
print_summary "$final_commit_message" "$FINAL_BRANCH_NAME" "$FINAL_BRANCH_MODE"
local final_confirmation
read -r -p "Proceed with checkout and commit? [y/N]: " final_confirmation
if [[ ! "$final_confirmation" =~ ^[Yy]$ ]]; then
echo "Aborted. No branch switch or commit was made."
exit 0
fi
checkout_branch "$FINAL_BRANCH_NAME" "$FINAL_BRANCH_MODE"
local commit_file
commit_file=$(mktemp)
printf '%s\n' "$final_commit_message" > "$commit_file"
git commit -F "$commit_file"
rm -f "$commit_file"
echo
echo "Done: committed on '$FINAL_BRANCH_NAME'."
}
main "$@"