Compare commits
1 Commits
cb0ff19355
...
sajad
| Author | SHA1 | Date | |
|---|---|---|---|
| a4763265bf |
Binary file not shown.
+1
-3
@@ -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")),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
@@ -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`
|
||||
@@ -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
@@ -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/",
|
||||
|
||||
Reference in New Issue
Block a user