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
+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)