diff --git a/celerybeat-schedule b/celerybeat-schedule index 289f8ae..83ee978 100644 Binary files a/celerybeat-schedule and b/celerybeat-schedule differ diff --git a/config/urls.py b/config/urls.py index 44f71ca..e70c3a3 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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")), diff --git a/crop_zoning/serializers.py b/crop_zoning/serializers.py new file mode 100644 index 0000000..9335eb1 --- /dev/null +++ b/crop_zoning/serializers.py @@ -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) diff --git a/crop_zoning/services.py b/crop_zoning/services.py index 161b138..7082df1 100644 --- a/crop_zoning/services.py +++ b/crop_zoning/services.py @@ -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), } diff --git a/crop_zoning/tests.py b/crop_zoning/tests.py index 5ce6089..a194443 100644 --- a/crop_zoning/tests.py +++ b/crop_zoning/tests.py @@ -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/") diff --git a/crop_zoning/urls.py b/crop_zoning/urls.py index 23f485d..ef17d0a 100644 --- a/crop_zoning/urls.py +++ b/crop_zoning/urls.py @@ -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//details/", - ZoneDetailsView.as_view(), - name="crop-zoning-zone-details", + "remote-sensing/cluster-blocks//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//k-options/", + KOptionsView.as_view(), + name="location-data-k-options", + ), + path( + "remote-sensing/results//k-options/activate/", + KOptionsActivateView.as_view(), + name="location-data-k-options-activate", + ), + path( + "remote-sensing/runs//status/", + RunStatusView.as_view(), + name="location-data-run-status", ), ] diff --git a/crop_zoning/views.py b/crop_zoning/views.py index 8dc936c..1447b07 100644 --- a/crop_zoning/views.py +++ b/crop_zoning/views.py @@ -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) diff --git a/device_hub/comparison_urls.py b/device_hub/comparison_urls.py deleted file mode 100644 index 6e1ee73..0000000 --- a/device_hub/comparison_urls.py +++ /dev/null @@ -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"), -] diff --git a/device_hub/sensor_7_in_1_urls.py b/device_hub/sensor_7_in_1_urls.py deleted file mode 100644 index 7d9b79f..0000000 --- a/device_hub/sensor_7_in_1_urls.py +++ /dev/null @@ -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"), -] diff --git a/device_hub/urls.py b/device_hub/urls.py index 8d98c4d..c83e7d2 100644 --- a/device_hub/urls.py +++ b/device_hub/urls.py @@ -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//radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"), path("devices//logs/", DeviceLogListView.as_view(), name="device-log-list"), path("devices//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"), diff --git a/docs/location_data_api_responses_fa.md b/docs/location_data_api_responses_fa.md new file mode 100644 index 0000000..14e290a --- /dev/null +++ b/docs/location_data_api_responses_fa.md @@ -0,0 +1,1031 @@ +# مستند کامل response های Location Data + +این فایل، response همه endpointهای اصلی `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/` + +## 1) ساختار عمومی همه response ها + +تقریبا همه endpointها این envelope را دارند: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +توضیح فیلدها: + +- `code`: کد منطقی response در body +- `msg`: پیام کوتاه +- `data`: payload اصلی + +در خطاها معمولا یکی از این دو حالت برمی‌گردد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "field_name": ["error message"] + } +} +``` + +یا: + +```json +{ + "code": 404, + "msg": "location پیدا نشد.", + "data": null +} +``` + +## 2) `GET /api/location-data/` + +کاربرد: + +- خواندن ساختار ذخیره‌شده مزرعه +- خواندن بلوک‌ها +- خواندن subdivisionها +- خواندن snapshotهای ماهواره‌ای ذخیره/تجمیع‌شده + +### response موفق `200` + +```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": [] + } +} +``` + +### توضیح فیلدهای `data` + +- `source`: در این endpoint همیشه از دیتابیس است و معمولا مقدار آن `database` است +- `id`: شناسه داخلی `SoilLocation` +- `lon`: طول جغرافیایی location +- `lat`: عرض جغرافیایی location +- `input_block_count`: تعداد بلوک‌های تعریف‌شده برای این مزرعه +- `farm_boundary`: مرز کل مزرعه به صورت GeoJSON +- `block_layout`: ساختار کلی بلوک‌ها، وضعیت الگوریتم، sub-blockها و metadata سطح مزرعه +- `block_subdivisions`: لیست subdivisionهای سطح بلوک +- `satellite_snapshots`: خلاصه‌های سنجش‌ازدور هر بلوک و هر sub-block + +### ساختار هر آیتم `block_subdivisions` + +```json +{ + "block_code": "block-1", + "chunk_size_sqm": 900, + "grid_points": [], + "centroid_points": [], + "grid_point_count": 0, + "centroid_count": 0, + "elbow_plot": null, + "status": "defined", + "metadata": {}, + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +توضیح: + +- `block_code`: کد بلوک +- `chunk_size_sqm`: اندازه هر سلول تحلیل +- `grid_points`: نقاط grid تولیدشده +- `centroid_points`: centroidهای grid +- `grid_point_count`: تعداد نقاط grid +- `centroid_count`: تعداد centroidها +- `elbow_plot`: تصویر elbow plot اگر ساخته شده باشد +- `status`: وضعیت subdivision مثل `defined`، `created`، `subdivided` +- `metadata`: داده‌های تکمیلی + +### `400` + +وقتی `lat` یا `lon` نامعتبر باشد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "lat": ["..."], + "lon": ["..."] + } +} +``` + +### `404` + +وقتی location پیدا نشود: + +```json +{ + "code": 404, + "msg": "location پیدا نشد.", + "data": null +} +``` + +## 3) `POST /api/location-data/` + +کاربرد: + +- ثبت یا به‌روزرسانی مزرعه +- ثبت مرز مزرعه +- ثبت بلوک‌های کشاورز + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "source": "created", + "id": 12, + "lon": "51.389000", + "lat": "35.689200", + "input_block_count": 2, + "farm_boundary": {}, + "block_layout": {}, + "block_subdivisions": [], + "satellite_snapshots": [] + } +} +``` + +### توضیح `source` + +- `created`: این location تازه ساخته شده +- `database`: location از قبل وجود داشته و فقط update شده یا همان داده قبلی برگشته + +### `400` + +حالت اول: body نامعتبر باشد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_boundary": ["مختصات گوشه‌های کل زمین باید ارسال شود."] + } +} +``` + +حالت دوم: مرز کل مزرعه نه در request آمده و نه قبلا ذخیره شده: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_boundary": [ + "برای ثبت location باید گوشه‌های کل زمین ارسال یا قبلاً ذخیره شده باشد." + ] + } +} +``` + +## 4) `POST /api/location-data/ndvi-health/` + +کاربرد: + +- برگرداندن وضعیت سلامت پوشش گیاهی مزرعه بر اساس NDVI + +### response موفق `200` + +```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`: شاخص اصلی NDVI برای UI +- `mean_ndvi`: میانگین NDVI محاسبه‌شده +- `ndvi_map`: داده نقشه یا لایه NDVI +- `vegetation_health_class`: کلاس سلامت پوشش گیاهی +- `observation_date`: تاریخ مشاهده +- `satellite_source`: منبع داده ماهواره‌ای +- `healthData`: کارت‌های خلاصه برای نمایش در فرانت + +### ساختار هر آیتم `healthData` + +- `title`: عنوان آیتم +- `value`: مقدار عددی یا ساختار JSON +- `color`: رنگ پیشنهادی UI +- `icon`: آیکون پیشنهادی UI + +### `400` + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["..."] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "مزرعه پیدا نشد.", + "data": null +} +``` + +## 5) `GET /api/location-data/remote-sensing/` + +کاربرد: + +- فقط نتایج cache شده remote sensing و subdivision را می‌خواند +- هیچ پردازش جدیدی اجرا نمی‌کند + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "status": "success", + "source": "database", + "location": {}, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_extent": { + "start_date": "2026-04-12", + "end_date": "2026-05-12" + }, + "summary": { + "cell_count": 12, + "ndvi_mean": 0.54, + "ndwi_mean": 0.21, + "soil_vv_db_mean": -8.92 + }, + "cells": [], + "run": {}, + "subdivision_result": {}, + "pagination": {}, + "metadata": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "cache_hit": true + } + } +} +``` + +### حالت‌های مهم `status` + +- `success`: داده کامل در DB موجود است +- `processing`: run در حال انجام است و هنوز observation نهایی کامل نشده +- `not_found`: runی وجود داشته ولی observation قابل استفاده برنگشته + +### توضیح فیلدها + +- `status`: وضعیت نتیجه +- `source`: معمولا `database` یا `processing` +- `location`: همان ساختار `SoilLocationResponse` +- `block_code`: برای full farm معمولا رشته خالی `""` +- `chunk_size_sqm`: اندازه سلول تحلیل +- `temporal_extent.start_date`: شروع بازه تحلیل +- `temporal_extent.end_date`: پایان بازه تحلیل +- `summary`: خلاصه آماری observationها +- `cells`: observationهای صفحه فعلی +- `run`: اطلاعات run مرتبط +- `subdivision_result`: نتیجه clustering و KMeans +- `pagination`: اطلاعات صفحه‌بندی `cells` و گاهی `assignments` +- `metadata.cache_hit`: نشان می‌دهد پاسخ از cache/DB آمده + +### ساختار `summary` + +- `cell_count`: تعداد سلول‌ها +- `ndvi_mean`: میانگین NDVI +- `ndwi_mean`: میانگین NDWI +- `soil_vv_db_mean`: میانگین `soil_vv_db` + +### ساختار هر آیتم `cells` + +```json +{ + "cell_code": "cell-1", + "block_code": "", + "chunk_size_sqm": 900, + "centroid_lat": "35.689500", + "centroid_lon": "51.389500", + "geometry": {}, + "temporal_start": "2026-04-12", + "temporal_end": "2026-05-12", + "ndvi": 0.61, + "ndwi": 0.22, + "soil_vv": 0.13, + "soil_vv_db": -8.860566, + "metadata": {} +} +``` + +### ساختار `run` + +```json +{ + "id": 10, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_start": "2026-04-12", + "temporal_end": "2026-05-12", + "status": "success", + "status_label": "completed", + "pipeline_status": "completed", + "stage": "completed", + "selected_features": ["ndvi", "ndwi", "soil_vv_db"], + "requested_cluster_count": null, + "metadata": {}, + "error_message": "", + "started_at": null, + "finished_at": null, + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +### ساختار `subdivision_result` + +```json +{ + "id": 5, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_start": "2026-04-12", + "temporal_end": "2026-05-12", + "cluster_count": 3, + "selected_features": ["ndvi", "ndwi", "soil_vv_db"], + "skipped_cell_codes": [], + "metadata": {}, + "available_k_options": [], + "cluster_blocks": [], + "assignments": [], + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +### ساختار هر `assignment` + +```json +{ + "cell_code": "cell-1", + "cluster_label": 0, + "centroid_lat": "35.689500", + "centroid_lon": "51.389500", + "raw_feature_values": { + "ndvi": 0.61 + }, + "scaled_feature_values": { + "ndvi": 0.21 + } +} +``` + +### ساختار هر `cluster_block` + +```json +{ + "uuid": "11111111-1111-1111-1111-111111111111", + "sub_block_code": "cluster-0", + "cluster_label": 0, + "chunk_size_sqm": 900, + "centroid_lat": "35.689500", + "centroid_lon": "51.389500", + "center_cell_code": "cell-1", + "center_cell_lat": "35.689500", + "center_cell_lon": "51.389500", + "cell_count": 4, + "cell_codes": ["cell-1", "cell-2"], + "geometry": {}, + "metadata": {}, + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +### `400` + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["..."] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "location پیدا نشد.", + "data": null +} +``` + +## 6) `POST /api/location-data/remote-sensing/` + +کاربرد: + +- اجرای async تحلیل سنجش‌ازدور +- ساخت run و task قابل polling +- اگر داده قبلا در DB موجود باشد هم یک `task_id` tracking برمی‌گرداند تا status بلافاصله نتیجه را بدهد + +### response موفق `202` + +```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 جدید ساخته شده + +- `status = processing` +- `source = processing` +- `task_id` مربوط به Celery run جدید است + +#### حالت دوم: data از قبل در DB وجود دارد + +- باز هم `202` برمی‌گردد +- `status` ممکن است `success` باشد +- `source` معمولا `database` است +- `task_id` برای polling ساخته می‌شود +- `GET /runs/{run_id}/status/` بلافاصله نتیجه کامل را می‌دهد + +### `400` + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["..."] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "location پیدا نشد.", + "data": null +} +``` + +## 7) `GET /api/location-data/remote-sensing/cluster-blocks/{cluster_uuid}/live/` + +کاربرد: + +- دریافت metricهای زنده یا cache شده برای یک cluster block + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "status": "success", + "source": "database", + "cluster_block": {}, + "temporal_extent": { + "start_date": "2026-04-12", + "end_date": "2026-05-12" + }, + "selected_features": ["ndvi", "ndwi", "soil_vv_db"], + "summary": { + "cell_count": 2, + "ndvi_mean": 0.54, + "ndwi_mean": 0.17, + "soil_vv_db_mean": -9.0 + }, + "metrics": { + "ndvi": 0.54, + "ndwi": 0.17, + "soil_vv": 0.14, + "soil_vv_db": -9.0 + }, + "metadata": { + "requested_cluster_uuid": "11111111-1111-1111-1111-111111111111", + "cache_hit": true + } + } +} +``` + +### توضیح فیلدها + +- `source`: اگر از observationهای DB آمده باشد `database` و اگر مستقیم از openEO آمده باشد `openeo` +- `cluster_block`: ساختار کامل sub-block +- `selected_features`: metricهایی که برای تحلیل استفاده می‌شوند +- `summary`: خلاصه آماری cluster +- `metrics`: metric تجمیع‌شده همان cluster +- `metadata`: اطلاعات تکمیلی مثل backend، source_result_id، source_run_id + +### `400` + +- پارامترهای تاریخ نامعتبر باشند +- یا هندسه cluster معتبر نباشد + +نمونه: + +```json +{ + "code": 400, + "msg": "هندسه زیر‌بلاک KMeans نامعتبر است.", + "data": { + "cluster_uuid": ["11111111-1111-1111-1111-111111111111"] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "زیر‌بلاک KMeans پیدا نشد.", + "data": null +} +``` + +### `502` + +وقتی openEO پاسخ ندهد: + +```json +{ + "code": 502, + "msg": "خواندن داده از openEO ناموفق بود.", + "data": { + "detail": "..." + } +} +``` + +## 8) `GET /api/location-data/remote-sensing/cluster-recommendations/` + +کاربرد: + +- مقایسه گیاه‌های ثبت‌شده در `farm_data` +- استفاده از داده کلاسترها +- استفاده از `crop_simulation` +- پیشنهاد بهترین گیاه برای هر cluster + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "location_id": 12, + "evaluated_plant_count": 2, + "cluster_count": 2, + "registered_plants": [], + "clusters": [], + "source_metadata": {} + } +} +``` + +### توضیح فیلدهای سطح بالا + +- `farm_uuid`: شناسه مزرعه +- `location_id`: شناسه داخلی location +- `evaluated_plant_count`: تعداد گیاه‌هایی که وارد simulation شده‌اند +- `cluster_count`: تعداد clusterهای بررسی‌شده +- `registered_plants`: گیاه‌های ثبت‌شده روی مزرعه +- `clusters`: خروجی نهایی هر cluster +- `source_metadata`: metadata کلی پاسخ + +### ساختار هر آیتم `registered_plants` + +```json +{ + "plant_id": 101, + "plant_name": "Tomato", + "position": 0, + "stage": "vegetative" +} +``` + +### ساختار هر آیتم `clusters` + +```json +{ + "block_code": "block-1", + "cluster_uuid": "11111111-1111-1111-1111-111111111111", + "sub_block_code": "cluster-0", + "cluster_label": 0, + "temporal_extent": { + "start_date": "2026-04-12", + "end_date": "2026-05-12" + }, + "cluster_block": {}, + "satellite_metrics": { + "ndvi": 0.51, + "ndwi": 0.24, + "soil_vv": 0.13 + }, + "sensor_metrics": {}, + "resolved_metrics": { + "ndvi": 0.51, + "ndwi": 0.24, + "soil_vv": 0.13 + }, + "candidate_plants": [], + "suggested_plant": {}, + "source_metadata": {} +} +``` + +### ساختار هر آیتم `candidate_plants` + +```json +{ + "plant_id": 101, + "plant_name": "Tomato", + "position": 0, + "stage": "vegetative", + "score": 150.0, + "predicted_yield": 150.0, + "predicted_yield_tons": 0.15, + "biomass": 300.0, + "max_lai": 4.2, + "simulation_engine": "pcse", + "simulation_model_name": "Wofost81_NWLP_CWB_CNB", + "simulation_warning": null, + "supporting_metrics": {} +} +``` + +### `400` + +وقتی مزرعه گیاه ثبت‌شده نداشته باشد یا پیش‌نیاز simulation کامل نباشد: + +```json +{ + "code": 400, + "msg": "برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.", + "data": null +} +``` + +### `404` + +وقتی مزرعه یا خروجی KMeans پیدا نشود: + +```json +{ + "code": 404, + "msg": "برای این مزرعه هنوز خروجی KMeans در location_data ثبت نشده است.", + "data": null +} +``` + +## 9) `GET /api/location-data/remote-sensing/results/{result_id}/k-options/` + +کاربرد: + +- لیست همه Kهای ذخیره‌شده برای یک subdivision result + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "result_id": 5, + "active_requested_k": 3, + "recommended_requested_k": 4, + "options": [] + } +} +``` + +### توضیح فیلدها + +- `result_id`: شناسه subdivision result +- `active_requested_k`: K فعال فعلی +- `recommended_requested_k`: K پیشنهادی سیستم +- `options`: لیست کامل گزینه‌ها + +### ساختار هر آیتم `options` + +```json +{ + "id": 11, + "requested_k": 3, + "effective_cluster_count": 3, + "is_active": true, + "is_recommended": false, + "selection_source": "user", + "metadata": {}, + "cluster_blocks": [], + "created_at": "2026-05-13T14:00:00Z", + "updated_at": "2026-05-13T14:00:00Z" +} +``` + +### ساختار هر `cluster_blocks` داخل option + +```json +{ + "cluster_label": 0, + "sub_block_code": "cluster-0", + "chunk_size_sqm": 900, + "centroid_lat": "35.689500", + "centroid_lon": "51.389500", + "center_cell_code": "cell-1", + "center_cell_lat": "35.689500", + "center_cell_lon": "51.389500", + "cell_count": 4, + "cell_codes": ["cell-1", "cell-2"], + "geometry": {}, + "metadata": {} +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "subdivision result پیدا نشد.", + "data": null +} +``` + +## 10) `POST /api/location-data/remote-sensing/results/{result_id}/k-options/activate/` + +کاربرد: + +- فعال‌سازی یکی از Kهای ذخیره‌شده + +### response موفق `200` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "result_id": 5, + "activated_requested_k": 4, + "subdivision_result": {} + } +} +``` + +### توضیح فیلدها + +- `result_id`: شناسه result +- `activated_requested_k`: K که الان active شده +- `subdivision_result`: خروجی کامل subdivision بعد از sync شدن روی K جدید + +### `400` + +حالت اول: body نامعتبر + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "requested_k": ["..."] + } +} +``` + +حالت دوم: K داخل optionها وجود ندارد + +```json +{ + "code": 400, + "msg": "K انتخابی برای این subdivision result موجود نیست.", + "data": { + "requested_k": [7] + } +} +``` + +### `404` + +```json +{ + "code": 404, + "msg": "subdivision result پیدا نشد.", + "data": null +} +``` + +## 11) `GET /api/location-data/remote-sensing/runs/{run_id}/status/` + +کاربرد: + +- polling وضعیت run +- دیدن stageهای pipeline +- اگر run کامل شده باشد، دیدن نتیجه نهایی +- اگر run از نوع cache-hit باشد، دیدن نتیجه کامل DB بلافاصله + +### response موفق `200` در حالت pending/running + +```json +{ + "code": 200, + "msg": "success", + "data": { + "status": "running", + "source": "database", + "run": {}, + "task_id": "11111111-1111-1111-1111-111111111111", + "task": { + "current_stage": "fetching_remote_metrics", + "current_stage_details": {}, + "timestamps": {}, + "stages": [], + "metric_progress": {}, + "celery": { + "state": "STARTED", + "ready": false, + "successful": false, + "failed": false, + "info": {} + } + } + } +} +``` + +### response موفق `200` در حالت completed + +```json +{ + "code": 200, + "msg": "success", + "data": { + "status": "completed", + "source": "database", + "run": {}, + "task_id": "11111111-1111-1111-1111-111111111111", + "task": { + "current_stage": "completed", + "current_stage_details": {}, + "timestamps": {}, + "stages": [] + }, + "location": {}, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_extent": { + "start_date": "2026-04-12", + "end_date": "2026-05-12" + }, + "summary": { + "cell_count": 12, + "ndvi_mean": 0.54, + "ndwi_mean": 0.21, + "soil_vv_db_mean": -8.92 + }, + "cells": [], + "subdivision_result": {}, + "pagination": {} + } +} +``` + +### توضیح فیلدهای `task` + +- `current_stage`: stage فعلی pipeline +- `current_stage_details`: جزئیات همان stage +- `timestamps`: زمان ورود به stageها +- `stages`: تاریخچه stageها +- `metric_progress`: پیشرفت metricها هنگام fetch داده +- `retry`: اطلاعات retry اگر task در حال retry باشد +- `last_error`: آخرین خطا +- `failure_reason`: علت fail شدن task +- `celery.state`: وضعیت Celery مثل `PENDING`، `STARTED`، `RETRY` +- `celery.ready`: آیا task تمام شده +- `celery.successful`: آیا task موفق بوده +- `celery.failed`: آیا task fail شده +- `celery.info`: اطلاعات خام Celery + +### مقادیر متداول `status` + +- `pending` +- `running` +- `retrying` +- `completed` +- `failed` + +### `404` + +```json +{ + "code": 404, + "msg": "run با این task_id پیدا نشد.", + "data": null +} +``` + +## 12) نکات مهم برای فرانت + +- در همه endpointها اول `code` و بعد `data` را چک کنید. +- در `POST /remote-sensing/` همیشه انتظار `task_id` داشته باشید. +- در `POST /remote-sensing/` اگر داده قبلا موجود باشد هم ممکن است `202` بگیرید، چون سیستم برای polling یک run قابل پیگیری می‌سازد. +- در `GET /remote-sensing/runs/{run_id}/status/` اگر `status = completed` شد، همان response نهایی را استفاده کنید و دیگر لازم نیست `GET /remote-sensing/` را دوباره صدا بزنید. +- در `GET /remote-sensing/cluster-blocks/{cluster_uuid}/live/` مقدار `source` مهم است: + - `database`: از cache + - `openeo`: از backend زنده +- در responseهای subdivision، pagination ممکن است هم برای `cells` باشد و هم برای `assignments`. + +## 13) محل فایل + +این مستند در این مسیر ذخیره شده است: + +- `docs/location_data_api_responses_fa.md` diff --git a/docs/location_data_frontend_api_reference_fa.md b/docs/location_data_frontend_api_reference_fa.md new file mode 100644 index 0000000..ec699ec --- /dev/null +++ b/docs/location_data_frontend_api_reference_fa.md @@ -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 +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= +``` + +### نمونه 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=&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//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= +-> اگر 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` diff --git a/farm_hub/services.py b/farm_hub/services.py index ea512c1..12a6918 100644 --- a/farm_hub/services.py +++ b/farm_hub/services.py @@ -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) diff --git a/farm_hub/tests.py b/farm_hub/tests.py index 90e7352..16b8923 100644 --- a/farm_hub/tests.py +++ b/farm_hub/tests.py @@ -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/",