This commit is contained in:
2026-05-05 21:01:58 +03:30
parent 39efd537bf
commit 4e28bacad6
54 changed files with 2729 additions and 1115 deletions
+28
View File
@@ -0,0 +1,28 @@
CONFIG_RESPONSE_TEMPLATE = {
"farmInfo": {
"soilType": None,
"waterQuality": None,
"climateZone": None,
},
"cropOptions": [
{"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"},
{"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"},
{"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"},
{"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"},
{"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"},
{"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"},
],
"status": "success",
"source": "default_template",
}
IRRIGATION_DASHBOARD_TEMPLATE = {
"title": "آبیاری",
"subtitle": "داده توصیه آبیاری هنوز ثبت نشده است.",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
"status": "empty",
"source": "db",
"warnings": ["No persisted irrigation recommendation is available for this farm."],
}
+87 -18
View File
@@ -1,12 +1,30 @@
from copy import deepcopy
import logging
from .mock_data import IRRIGATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA, WATER_NEED_PREDICTION
from config.failure_contract import StructuredServiceError
from .defaults import IRRIGATION_DASHBOARD_TEMPLATE
from .models import IrrigationPlan, IrrigationRecommendationRequest
logger = logging.getLogger(__name__)
class IrrigationDataUnavailableError(StructuredServiceError):
def __init__(self, *, error_code: str, message: str, details: dict | None = None):
super().__init__(
error_code=error_code,
message=message,
source="db",
details=details,
)
def _extract_result(response_payload):
if not isinstance(response_payload, dict):
return {}
raise IrrigationDataUnavailableError(
error_code="invalid_payload",
message="Irrigation recommendation payload must be a JSON object.",
)
data = response_payload.get("data")
if isinstance(data, dict):
@@ -22,24 +40,47 @@ def _extract_result(response_payload):
if any(key in response_payload for key in ("plan", "water_balance", "timeline", "sections")):
return response_payload
return {}
return None
def _get_latest_result(farm):
if farm is None:
return {}
raise IrrigationDataUnavailableError(
error_code="missing_farm",
message="Farm instance is required for irrigation result lookup.",
)
for request in IrrigationRecommendationRequest.objects.filter(farm=farm).order_by("-created_at", "-id"):
result = _extract_result(request.response_payload)
try:
result = _extract_result(request.response_payload)
except IrrigationDataUnavailableError as exc:
logger.error(
"Invalid irrigation response payload for farm_id=%s request_id=%s: %s",
getattr(farm, "id", None),
request.id,
exc,
)
raise IrrigationDataUnavailableError(
error_code=exc.contract.error_code,
message=f"Invalid irrigation recommendation payload for request_id={request.id}.",
details={"farm_id": getattr(farm, "id", None), "request_id": request.id},
) from exc
if result:
return result
return {}
raise IrrigationDataUnavailableError(
error_code="no_data",
message=f"No irrigation recommendation result found for farm_id={getattr(farm, 'id', None)}.",
details={"farm_id": getattr(farm, "id", None)},
)
def get_active_plan_payload(farm):
if farm is None:
return {}
raise IrrigationDataUnavailableError(
error_code="missing_farm",
message="Farm instance is required for active irrigation plan lookup.",
)
plan = (
IrrigationPlan.objects.filter(farm=farm, is_active=True, is_deleted=False)
@@ -47,15 +88,17 @@ def get_active_plan_payload(farm):
.first()
)
if plan is None or not isinstance(plan.plan_payload, dict):
return {}
raise IrrigationDataUnavailableError(
error_code="no_active_plan",
message=f"No active irrigation plan payload found for farm_id={getattr(farm, 'id', None)}.",
details={"farm_id": getattr(farm, "id", None)},
)
return deepcopy(plan.plan_payload)
def build_active_plan_context(farm):
plan_payload = get_active_plan_payload(farm)
if not plan_payload:
return {}
context = {"plan_payload": plan_payload}
@@ -200,24 +243,37 @@ def _normalize_sections(raw_sections):
def build_recommendation_response(adapter_payload):
result = _extract_result(adapter_payload)
fallback_plan = RECOMMEND_RESPONSE_DATA.get("plan", {})
if not isinstance(result, dict):
raise IrrigationDataUnavailableError(
error_code="no_result",
message="Irrigation recommendation payload did not include a result object.",
)
if not isinstance(result.get("plan"), dict):
raise IrrigationDataUnavailableError(
error_code="invalid_payload",
message="Irrigation recommendation payload is missing a valid `plan` object.",
)
return {
"plan": _normalize_plan(result.get("plan") or fallback_plan),
response = {
"plan": _normalize_plan(result.get("plan")),
"water_balance": _normalize_water_balance(result.get("water_balance")),
"timeline": _normalize_timeline(result.get("timeline")),
"sections": _normalize_sections(result.get("sections")),
}
return response
def get_water_need_prediction_data(farm=None):
default_data = deepcopy(WATER_NEED_PREDICTION)
result = _get_latest_result(farm)
water_balance = result.get("water_balance", {})
daily = water_balance.get("daily", [])
if not daily:
return default_data
raise IrrigationDataUnavailableError(
error_code="empty_daily_data",
message=f"Water need prediction data is missing daily entries for farm_id={getattr(farm, 'id', None)}.",
details={"farm_id": getattr(farm, "id", None)},
)
categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)]
series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily]
@@ -231,9 +287,19 @@ def get_water_need_prediction_data(farm=None):
def get_irrigation_dashboard_recommendation(farm=None):
default_item = deepcopy(IRRIGATION_DASHBOARD_RECOMMENDATION)
result = _get_latest_result(farm)
plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {})
default_item = deepcopy(IRRIGATION_DASHBOARD_TEMPLATE)
try:
result = _get_latest_result(farm)
except IrrigationDataUnavailableError as exc:
logger.info(
"Irrigation dashboard recommendation unavailable for farm_id=%s: %s",
getattr(farm, "id", None),
exc,
)
return default_item
plan = result.get("plan")
if not isinstance(plan, dict):
return default_item
best_time = plan.get("bestTimeOfDay") or "05:00 - 07:00"
frequency = plan.get("frequencyPerWeek")
@@ -252,5 +318,8 @@ def get_irrigation_dashboard_recommendation(farm=None):
default_item["title"] = f"آبیاری: {best_time}"
if subtitle_parts:
default_item["subtitle"] = ". ".join(subtitle_parts)
default_item["status"] = "success"
default_item["source"] = "db"
default_item["warnings"] = []
return default_item
+84
View File
@@ -9,6 +9,7 @@ from farm_hub.models import FarmHub, FarmType
from farmer_calendar.models import FarmerCalendarEvent
from .models import IrrigationPlan, IrrigationRecommendationRequest
from .services import IrrigationDataUnavailableError, build_recommendation_response
from .views import (
IrrigationMethodListView,
IrrigationPlanDetailView,
@@ -22,6 +23,37 @@ from .views import (
)
class IrrigationServiceFailureTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="irrigation-service-user",
password="secret123",
email="irrigation-service@example.com",
phone_number="09120000009",
)
self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Service Farm")
def test_get_water_need_prediction_raises_structured_error_for_missing_daily_entries(self):
IrrigationRecommendationRequest.objects.create(
farm=self.farm,
response_payload={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}},
)
from .services import get_water_need_prediction_data
with self.assertRaises(IrrigationDataUnavailableError) as exc_info:
get_water_need_prediction_data(self.farm)
self.assertEqual(exc_info.exception.contract.error_code, "empty_daily_data")
def test_build_recommendation_response_rejects_non_object_payload(self):
with self.assertRaises(IrrigationDataUnavailableError) as exc_info:
build_recommendation_response(["not-a-dict"])
self.assertEqual(exc_info.exception.contract.error_code, "invalid_payload")
class WaterStressViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
@@ -72,6 +104,8 @@ class WaterStressViewTests(TestCase):
self.assertEqual(response.data["data"]["waterStressIndex"], 12)
self.assertEqual(response.data["data"]["level"], "پایین")
self.assertEqual(response.data["data"]["sourceMetric"], {"soilMoisture": 24})
self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy")
self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation_water_stress")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/water-stress/",
@@ -93,6 +127,27 @@ class WaterStressViewTests(TestCase):
self.assertEqual(response.data["code"], 404)
self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.")
@patch("irrigation.views.external_api_request")
def test_post_returns_upstream_failure_without_masking_as_empty(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=503,
data={"message": "AI unavailable", "status": "error"},
)
request = self.factory.post(
"/api/irrigation/water-stress/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = WaterStressView.as_view()(request)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.data["data"]["message"], "AI unavailable")
self.assertNotEqual(response.data.get("data"), [])
self.assertNotEqual(response.data.get("data"), {})
class IrrigationPlanFromTextViewTests(TestCase):
def setUp(self):
@@ -136,6 +191,8 @@ class IrrigationPlanFromTextViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["status"], "completed")
self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy")
self.assertEqual(response.data["meta"]["ownership"], "backend")
self.assertEqual(IrrigationPlan.objects.count(), 1)
plan = IrrigationPlan.objects.get()
self.assertEqual(plan.source, IrrigationPlan.SOURCE_FREE_TEXT)
@@ -184,6 +241,8 @@ class IrrigationMethodListViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"][0]["name"], "Drip")
self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy")
self.assertTrue(response.data["meta"]["live"])
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/",
@@ -208,6 +267,7 @@ class IrrigationMethodListViewTests(TestCase):
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["data"]["name"], "Drip")
self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/",
@@ -319,6 +379,30 @@ class RecommendViewTests(TestCase):
self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93)
self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1)
self.assertEqual(response.data["data"]["sections"][0]["type"], "warning")
@patch("irrigation.views.external_api_request")
def test_recommend_view_persists_real_response_and_never_returns_fake_success_on_invalid_payload(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}},
)
request = self.factory.post(
"/api/irrigation/recommend/",
{
"farm_uuid": str(self.farm.farm_uuid),
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
},
format="json",
)
force_authenticate(request, user=self.user)
response = RecommendView.as_view()(request)
self.assertEqual(response.status_code, 502)
self.assertEqual(IrrigationRecommendationRequest.objects.count(), 1)
self.assertEqual(IrrigationRecommendationRequest.objects.get().status, IrrigationRecommendationRequest.STATUS_ERROR)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/recommend/",
+108 -19
View File
@@ -12,13 +12,14 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from config.integration_contract import build_integration_meta
from config.swagger import code_response, status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from farmer_calendar import PLAN_TYPE_IRRIGATION, delete_plan_events, sync_plan_events
from water.serializers import WaterStressIndexSerializer
from water.views import WaterStressIndexView
from .mock_data import CONFIG_RESPONSE_DATA
from .defaults import CONFIG_RESPONSE_TEMPLATE
from .models import IrrigationPlan, IrrigationRecommendationRequest
from .serializers import (
FreeTextPlanParserRequestSerializer,
@@ -36,6 +37,7 @@ from .serializers import (
)
from .services import build_recommendation_response
from .services import build_active_plan_context
from .services import IrrigationDataUnavailableError
logger = logging.getLogger(__name__)
@@ -86,7 +88,7 @@ class ConfigView(FarmAccessMixin, APIView):
)
def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
data = dict(CONFIG_RESPONSE_DATA)
data = dict(CONFIG_RESPONSE_TEMPLATE)
data["farm_uuid"] = str(farm.farm_uuid)
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
@@ -128,7 +130,19 @@ class IrrigationMethodListView(APIView):
)
return Response(
{"code": 200, "msg": "success", "data": self._extract_methods(adapter_response.data)},
{
"code": 200,
"msg": "success",
"data": self._extract_methods(adapter_response.data),
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
@@ -157,7 +171,19 @@ class IrrigationMethodListView(APIView):
payload = response_data.get("data", response_data)
return Response(
{"code": adapter_response.status_code, "msg": "success", "data": payload},
{
"code": adapter_response.status_code,
"msg": "success",
"data": payload,
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation",
ownership="ai",
live=True,
cached=False,
),
},
status=adapter_response.status_code,
)
@@ -188,7 +214,10 @@ class RecommendView(FarmAccessMixin, APIView):
@staticmethod
def _enrich_ai_payload(payload, farm):
enriched_payload = payload.copy()
active_plan_context = build_active_plan_context(farm)
try:
active_plan_context = build_active_plan_context(farm)
except IrrigationDataUnavailableError:
active_plan_context = None
if active_plan_context:
enriched_payload["active_irrigation_plan"] = active_plan_context
return enriched_payload
@@ -224,16 +253,6 @@ class RecommendView(FarmAccessMixin, APIView):
)
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
recommendation_data = build_recommendation_response(response_data)
logger.warning(
"Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s",
str(farm.farm_uuid),
adapter_response.status_code,
sorted(response_data.keys()) if isinstance(response_data, dict) else None,
len(recommendation_data["sections"]),
)
recommendation = IrrigationRecommendationRequest.objects.create(
farm=farm,
crop_id=payload.get("plant_name", ""),
@@ -256,6 +275,23 @@ class RecommendView(FarmAccessMixin, APIView):
},
status=adapter_response.status_code,
)
try:
recommendation_data = build_recommendation_response(response_data)
except IrrigationDataUnavailableError as exc:
recommendation.status = IrrigationRecommendationRequest.STATUS_ERROR
recommendation.save(update_fields=["status"])
return Response(
{"code": 502, "msg": "error", "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
logger.warning(
"Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s",
str(farm.farm_uuid),
adapter_response.status_code,
sorted(response_data.keys()) if isinstance(response_data, dict) else None,
len(recommendation_data["sections"]),
)
self._create_plan_from_recommendation(recommendation, recommendation_data)
@@ -272,6 +308,14 @@ class RecommendView(FarmAccessMixin, APIView):
"code": 200,
"msg": "success",
"data": recommendation_data,
"meta": build_integration_meta(
flow_type="backend_owned_data_with_ai_enrichment",
source_type="provider",
source_service="ai_irrigation",
ownership="backend",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
@@ -329,7 +373,13 @@ class RecommendationDetailView(FarmAccessMixin, APIView):
if recommendation is None:
return Response({"code": 404, "msg": "Recommendation not found."}, status=status.HTTP_404_NOT_FOUND)
data = build_recommendation_response(recommendation.response_payload)
try:
data = build_recommendation_response(recommendation.response_payload)
except IrrigationDataUnavailableError as exc:
return Response(
{"code": 502, "msg": "error", "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
request_payload = recommendation.request_payload if isinstance(recommendation.request_payload, dict) else {}
data["recommendation_uuid"] = str(recommendation.uuid)
data["crop_id"] = recommendation.crop_id
@@ -338,7 +388,22 @@ class RecommendationDetailView(FarmAccessMixin, APIView):
data["irrigation_method_name"] = str(request_payload.get("irrigation_method_name") or "")
data["status"] = recommendation.status
data["status_label"] = recommendation.get_status_display()
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
return Response(
{
"code": 200,
"msg": "success",
"data": data,
"meta": build_integration_meta(
flow_type="backend_owned_data_with_ai_enrichment",
source_type="db",
source_service="backend_irrigation",
ownership="backend",
live=False,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class WaterStressView(APIView):
@@ -392,7 +457,19 @@ class WaterStressView(APIView):
stress_payload = WaterStressIndexView.extract_stress_payload(adapter_response.data, farm.farm_uuid)
return Response(
{"code": 200, "msg": "success", "data": stress_payload},
{
"code": 200,
"msg": "success",
"data": stress_payload,
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation_water_stress",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
@@ -471,7 +548,19 @@ class PlanFromTextView(FarmAccessMixin, APIView):
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
return Response(
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
{
"code": 200,
"msg": response_data.get("msg", "موفق"),
"data": response_data.get("data", response_data),
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation_plan_parser",
ownership="backend" if final_plan and farm_uuid else "ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)