This commit is contained in:
2026-05-13 22:29:18 +03:30
parent 78d0c52b11
commit a4763265bf
14 changed files with 2699 additions and 682 deletions
+221 -174
View File
@@ -1,215 +1,262 @@
from copy import deepcopy
from uuid import UUID
from django.core.exceptions import ImproperlyConfigured
from django.http import Http404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from config.swagger import code_response
from external_api_adapter.adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
from farm_hub.models import FarmHub
from .serializers import (
FarmUUIDRequestSerializer,
KOptionActivateSerializer,
LocationDataUpsertSerializer,
)
from .services import (
create_zones_and_dispatch,
ensure_latest_area_ready_for_processing,
get_latest_cultivation_risk_payload,
get_cultivation_risk_payload,
get_default_area_feature,
get_initial_zones_payload,
get_latest_area_payload,
get_latest_soil_quality_payload,
get_latest_water_need_payload,
get_products_payload,
get_soil_quality_payload,
get_water_need_payload,
get_zone_details_payload,
get_zone_page_request_params,
AI_CLUSTER_RECOMMENDATIONS_PATH,
AI_LOCATION_DATA_PATH,
AI_REMOTE_SENSING_PATH,
)
AREA_QUERY_PARAMETERS = [
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=True,
description="UUID مزرعه برای گرفتن يا ساخت آخرين پردازش محدوده همان مزرعه.",
default="11111111-1111-1111-1111-111111111111"),
OpenApiParameter(
name="page",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="شماره صفحه زون ها. مقدار پيش فرض 1 است.",
),
OpenApiParameter(
name="page_size",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="تعداد زون در هر صفحه. مقدار پيش فرض 10 است.",
),
AI_PROXY_ERROR_MESSAGE = "ارتباط با سرویس AI ناموفق بود."
FARM_NOT_FOUND_MESSAGE = "مزرعه پیدا نشد."
QUERY_FARM_NOT_FOUND_MESSAGE = "location پیدا نشد."
SUCCESS_RESPONSE = code_response("LocationDataGenericSuccess", data=serializers.JSONField())
ERROR_RESPONSE = code_response("LocationDataGenericError", data=serializers.JSONField())
LOCATION_DATA_QUERY_PARAMETERS = [
OpenApiParameter("lat", OpenApiTypes.NUMBER, OpenApiParameter.QUERY, required=False),
OpenApiParameter("lon", OpenApiTypes.NUMBER, OpenApiParameter.QUERY, required=False),
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False),
]
REMOTE_SENSING_QUERY_PARAMETERS = [
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=True),
OpenApiParameter("page", OpenApiTypes.INT, OpenApiParameter.QUERY, required=False),
OpenApiParameter("page_size", OpenApiTypes.INT, OpenApiParameter.QUERY, required=False),
OpenApiParameter("start_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False),
OpenApiParameter("end_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False),
]
CLUSTER_BLOCK_LIVE_QUERY_PARAMETERS = [
OpenApiParameter("start_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False),
OpenApiParameter("end_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False),
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False),
]
OPTIONAL_FARM_UUID_QUERY_PARAMETER = [
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False),
]
class BaseAreaDataView(APIView):
payload_getter = None
class AILocationDataProxyView(APIView):
ai_path = AI_LOCATION_DATA_PATH
farm_uuid_locations = ()
farm_not_found_message = FARM_NOT_FOUND_MESSAGE
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
def _build_path(self, **kwargs):
return self.ai_path.format(**kwargs)
def _get_payload(self, request):
if not request.data:
return None
if isinstance(request.data, dict):
return deepcopy(request.data)
return request.data
def _get_query(self, request):
if not request.query_params:
return None
query = {}
for key, values in request.query_params.lists():
query[key] = values if len(values) > 1 else values[0]
return query
def _parse_uuid(self, value):
if not value:
return None
try:
page, page_size = get_zone_page_request_params(request.query_params)
crop_area = ensure_latest_area_ready_for_processing(farm_uuid=farm_uuid, owner=request.user)
except ValueError as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except ImproperlyConfigured as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return UUID(str(value))
except (TypeError, ValueError, AttributeError):
return None
def _extract_farm_uuid(self, request, payload, query):
for location in self.farm_uuid_locations:
if location == "body" and isinstance(payload, dict) and payload.get("farm_uuid"):
parsed = self._parse_uuid(payload.get("farm_uuid"))
if parsed is not None:
return parsed
if location == "query" and isinstance(query, dict) and query.get("farm_uuid"):
parsed = self._parse_uuid(query.get("farm_uuid"))
if parsed is not None:
return parsed
return None
def _ensure_farm_access(self, request, farm_uuid):
if farm_uuid is None:
return None
if FarmHub.objects.filter(farm_uuid=farm_uuid, owner=request.user).exists():
return None
return Response(
{"status": "success", "data": self.payload_getter(crop_area, page=page, page_size=page_size)},
status=status.HTTP_200_OK,
{"code": 404, "msg": self.farm_not_found_message, "data": None},
status=status.HTTP_404_NOT_FOUND,
)
class AreaView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_area_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningAreaResponse", data=serializers.JSONField()),
400: status_response("CropZoningAreaValidationError", data=serializers.JSONField()),
500: status_response("CropZoningAreaServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class WaterNeedView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_water_need_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningWaterNeedResponse", data=serializers.JSONField()),
400: status_response("CropZoningWaterNeedValidationError", data=serializers.JSONField()),
500: status_response("CropZoningWaterNeedServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class SoilQualityView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_soil_quality_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningSoilQualityResponse", data=serializers.JSONField()),
400: status_response("CropZoningSoilQualityValidationError", data=serializers.JSONField()),
500: status_response("CropZoningSoilQualityServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class CultivationRiskView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_cultivation_risk_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningCultivationRiskResponse", data=serializers.JSONField()),
400: status_response("CropZoningCultivationRiskValidationError", data=serializers.JSONField()),
500: status_response("CropZoningCultivationRiskServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class ProductsView(APIView):
@extend_schema(
tags=["Crop Zoning"],
responses={200: status_response("CropZoningProductsResponse", data=serializers.JSONField())},
)
def get(self, request):
return Response({"status": "success", "data": get_products_payload()}, status=status.HTTP_200_OK)
class ZonesInitialView(APIView):
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
)
def post(self, request):
area_feature = (
request.data.get("area")
or request.data.get("area_geojson")
or request.data.get("boundary")
or get_default_area_feature()
def _build_proxy_error(self, exc):
return Response(
{"code": 502, "msg": AI_PROXY_ERROR_MESSAGE, "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
cell_side_km = request.data.get("cell_side_km")
def _proxy(self, request, *, method, **path_kwargs):
payload = self._get_payload(request)
query = self._get_query(request)
farm_uuid = self._extract_farm_uuid(request, payload, query)
farm_error = self._ensure_farm_access(request, farm_uuid)
if farm_error is not None:
return farm_error
try:
crop_area, _zones = create_zones_and_dispatch(area_feature, cell_side_km=cell_side_km)
except ValueError as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except ImproperlyConfigured as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
adapter_response = external_api_request(
"ai",
self._build_path(**path_kwargs),
method=method,
payload=payload,
query=query,
)
except (ExternalAPIRequestError, ImproperlyConfigured) as exc:
return self._build_proxy_error(exc)
return Response({"status": "success", "data": get_initial_zones_payload(crop_area)}, status=status.HTTP_200_OK)
response_payload = adapter_response.data
if not isinstance(response_payload, dict):
response_payload = {
"code": adapter_response.status_code,
"msg": "success" if adapter_response.status_code < 400 else "error",
"data": response_payload,
}
return Response(response_payload, status=adapter_response.status_code)
class ZonesWaterNeedView(APIView):
class LocationDataView(AILocationDataProxyView):
farm_uuid_locations = ("query", "body")
farm_not_found_message = QUERY_FARM_NOT_FOUND_MESSAGE
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesWaterNeedResponse", data=serializers.JSONField())},
tags=["Location Data"],
parameters=LOCATION_DATA_QUERY_PARAMETERS,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request):
return self._proxy(request, method="GET")
@extend_schema(
tags=["Location Data"],
request=LocationDataUpsertSerializer,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def post(self, request):
zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_water_need_payload(zone_ids)}, status=status.HTTP_200_OK)
return self._proxy(request, method="POST")
class ZonesSoilQualityView(APIView):
class LocationDataNdviHealthView(AILocationDataProxyView):
ai_path = f"{AI_LOCATION_DATA_PATH}ndvi-health/"
farm_uuid_locations = ("body",)
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesSoilQualityResponse", data=serializers.JSONField())},
tags=["Location Data"],
request=FarmUUIDRequestSerializer,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def post(self, request):
zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_soil_quality_payload(zone_ids)}, status=status.HTTP_200_OK)
return self._proxy(request, method="POST")
class ZonesCultivationRiskView(APIView):
class LocationDataRemoteSensingView(AILocationDataProxyView):
ai_path = AI_REMOTE_SENSING_PATH
farm_uuid_locations = ("query", "body")
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesCultivationRiskResponse", data=serializers.JSONField())},
tags=["Location Data"],
parameters=REMOTE_SENSING_QUERY_PARAMETERS,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request):
return self._proxy(request, method="GET")
@extend_schema(
tags=["Location Data"],
request=FarmUUIDRequestSerializer,
responses={202: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def post(self, request):
zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_cultivation_risk_payload(zone_ids)}, status=status.HTTP_200_OK)
return self._proxy(request, method="POST")
class ZoneDetailsView(APIView):
class ClusterBlockLiveView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}cluster-blocks/{{cluster_uuid}}/live/"
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Crop Zoning"],
responses={200: status_response("CropZoningZoneDetailsResponse", data=serializers.JSONField())},
tags=["Location Data"],
parameters=CLUSTER_BLOCK_LIVE_QUERY_PARAMETERS,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE, 502: ERROR_RESPONSE},
)
def get(self, request, zone_id):
try:
data = get_zone_details_payload(zone_id)
except Exception as exc:
if exc.__class__.__name__ == "DoesNotExist":
raise Http404("Zone not found")
raise
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
def get(self, request, cluster_uuid):
return self._proxy(request, method="GET", cluster_uuid=cluster_uuid)
class ClusterRecommendationsView(AILocationDataProxyView):
ai_path = AI_CLUSTER_RECOMMENDATIONS_PATH
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Location Data"],
parameters=REMOTE_SENSING_QUERY_PARAMETERS[:1],
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request):
return self._proxy(request, method="GET")
class KOptionsView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}results/{{result_id}}/k-options/"
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Location Data"],
parameters=OPTIONAL_FARM_UUID_QUERY_PARAMETER,
responses={200: SUCCESS_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request, result_id):
return self._proxy(request, method="GET", result_id=result_id)
class KOptionsActivateView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}results/{{result_id}}/k-options/activate/"
farm_uuid_locations = ("query", "body")
@extend_schema(
tags=["Location Data"],
parameters=OPTIONAL_FARM_UUID_QUERY_PARAMETER,
request=KOptionActivateSerializer,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def post(self, request, result_id):
return self._proxy(request, method="POST", result_id=result_id)
class RunStatusView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}runs/{{run_id}}/status/"
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Location Data"],
parameters=OPTIONAL_FARM_UUID_QUERY_PARAMETER,
responses={200: SUCCESS_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request, run_id):
return self._proxy(request, method="GET", run_id=run_id)