This commit is contained in:
2026-05-13 22:29:18 +03:30
parent 78d0c52b11
commit a4763265bf
14 changed files with 2699 additions and 682 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),
}
+157 -370
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,
from .views import (
ClusterBlockLiveView,
ClusterRecommendationsView,
KOptionsActivateView,
KOptionsView,
LocationDataNdviHealthView,
LocationDataRemoteSensingView,
LocationDataView,
RunStatusView,
)
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"]),
)
@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",
)
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}",
)
response = AreaView.as_view()(self._request_with_pagination(page=2, page_size=1))
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="",
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["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="",
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())
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data, {"code": 404, "msg": "location پیدا نشد.", "data": None})
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",
@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"},
},
)
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,
)
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",
response = LocationDataNdviHealthView.as_view()(
self._post("/api/location-data/ndvi-health/", {"farm_uuid": str(self.farm.farm_uuid)})
)
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",
self.assertEqual(response.status_code, 200)
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,
)
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
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}")
)
def test_water_need_view_requires_farm_uuid(self):
request = self.factory.get("/api/crop-zoning/water-need/")
force_authenticate(request, user=self.user)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data, {"code": 404, "msg": "مزرعه پیدا نشد.", "data": None})
response = WaterNeedView.as_view()(request)
@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.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "farm_uuid is required.")
response = LocationDataRemoteSensingView.as_view()(
self._post("/api/location-data/remote-sensing/", {"farm_uuid": str(self.farm.farm_uuid)})
)
def test_water_need_view_returns_area_style_payload(self):
self._create_completed_zone()
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,
)
response = WaterNeedView.as_view()(self._request("/api/crop-zoning/water-need/"))
@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"}}),
]
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])
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,
)
def test_soil_quality_view_returns_area_style_payload(self):
self._create_completed_zone()
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")
response = SoilQualityView.as_view()(self._request("/api/crop-zoning/soil-quality/"))
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,
)
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",
),
]
+221 -174
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 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()
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,
)
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"),
]
-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"),
]
+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"),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,589 @@
# راهنمای فرانت برای 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` اجباری
### فیلدهای مهم response
- `registered_plants`
- `clusters`
- `evaluated_plant_count`
- `cluster_count`
### استفاده در فرانت
برای هر cluster این بخش ها مهم هستند:
- `satellite_metrics`
- `sensor_metrics`
- `resolved_metrics`
- `candidate_plants`
- `suggested_plant`
### UI پیشنهادی
- کارت cluster با عنوان `sub_block_code` یا `cluster_label`
- KPIهای `resolved_metrics`
- جدول candidateها با score
- highlight کردن `suggested_plant`
---
## 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`
+6 -2
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)
+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/",