UPDATE
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
EMPTY_YIELD_HARVEST_SUMMARY = {
|
||||
"yield_prediction_card": {
|
||||
"id": "yield_prediction",
|
||||
"title": "پیشبینی عملکرد",
|
||||
"subtitle": "این فصل",
|
||||
"stats": None,
|
||||
"avatarColor": "secondary",
|
||||
"avatarIcon": "tabler-chart-bar",
|
||||
"chipText": "بدون داده",
|
||||
"chipColor": "secondary",
|
||||
"status": "empty",
|
||||
"source": "db",
|
||||
},
|
||||
"yield_prediction_chart": {
|
||||
"categories": [],
|
||||
"series": [],
|
||||
"summary": [],
|
||||
"status": "empty",
|
||||
"source": "db",
|
||||
},
|
||||
"harvest_prediction_card": {
|
||||
"date": None,
|
||||
"dateFormatted": None,
|
||||
"daysUntil": None,
|
||||
"description": "داده پیشبینی برداشت هنوز ثبت نشده است.",
|
||||
"optimalWindowStart": None,
|
||||
"optimalWindowEnd": None,
|
||||
"status": "empty",
|
||||
"source": "db",
|
||||
},
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from .mock_data import HARVEST_PREDICTION_CARD, YIELD_PREDICTION_CARD, YIELD_PREDICTION_CHART
|
||||
from .defaults import EMPTY_YIELD_HARVEST_SUMMARY
|
||||
from .models import YieldHarvestPredictionLog
|
||||
|
||||
|
||||
def get_yield_harvest_summary_data(farm=None):
|
||||
data = {
|
||||
"yield_prediction_card": deepcopy(YIELD_PREDICTION_CARD),
|
||||
"yield_prediction_chart": deepcopy(YIELD_PREDICTION_CHART),
|
||||
"harvest_prediction_card": deepcopy(HARVEST_PREDICTION_CARD),
|
||||
}
|
||||
data = deepcopy(EMPTY_YIELD_HARVEST_SUMMARY)
|
||||
|
||||
if farm is None:
|
||||
return data
|
||||
@@ -18,6 +14,13 @@ def get_yield_harvest_summary_data(farm=None):
|
||||
if log is None:
|
||||
return data
|
||||
|
||||
data["yield_prediction_card"]["status"] = "success"
|
||||
data["yield_prediction_card"]["source"] = "db"
|
||||
data["yield_prediction_chart"]["status"] = "success"
|
||||
data["yield_prediction_chart"]["source"] = "db"
|
||||
data["harvest_prediction_card"]["status"] = "success"
|
||||
data["harvest_prediction_card"]["source"] = "db"
|
||||
|
||||
if log.yield_stats:
|
||||
data["yield_prediction_card"]["stats"] = log.yield_stats
|
||||
if log.yield_chip_text:
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
|
||||
|
||||
from config.observability import METRICS
|
||||
from external_api_adapter.adapter import AdapterResponse
|
||||
from farm_hub.models import FarmHub, FarmType, Product
|
||||
from fertilization.models import FertilizationPlan
|
||||
@@ -42,6 +43,9 @@ class CropSimulationViewTests(TestCase):
|
||||
self.farm.products.add(self.product)
|
||||
self.api_client.force_authenticate(user=self.user)
|
||||
|
||||
def tearDown(self):
|
||||
METRICS.clear()
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_growth_queues_simulation_task(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
@@ -664,6 +668,60 @@ class CropSimulationViewTests(TestCase):
|
||||
self.assertEqual(sent_query["irrigation_plan"]["id"], irrigation_plan.id)
|
||||
self.assertEqual(sent_query["fertilization_plan"]["id"], fertilization_plan.id)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_yield_harvest_summary_records_empty_result_metric(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {"result": {}}})
|
||||
|
||||
response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(METRICS["yield_harvest.ai.empty_result|operation=yield_harvest_summary"], 1)
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_yield_harvest_summary_persists_seeded_log_from_realistic_ai_contract(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={
|
||||
"data": {
|
||||
"result": {
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"yield_prediction": {"predicted_yield_tons": 5.1, "unit": "tons"},
|
||||
"harvest_prediction_card": {
|
||||
"harvest_date": "2026-09-28",
|
||||
"days_until": 152,
|
||||
"optimalWindowStart": "2026-09-25",
|
||||
"optimalWindowEnd": "2026-10-01",
|
||||
},
|
||||
"yield_prediction_chart": {"series": [{"name": "yield", "data": []}]},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(self.farm.yield_harvest_prediction_logs.exists())
|
||||
log = self.farm.yield_harvest_prediction_logs.latest("id")
|
||||
self.assertEqual(log.yield_stats, "5.1")
|
||||
self.assertEqual(str(log.harvest_date), "2026-09-28")
|
||||
|
||||
@patch("yield_harvest.views.external_api_request")
|
||||
def test_yield_prediction_provider_unavailable_returns_explicit_failure(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=503,
|
||||
data={"message": "provider unavailable"},
|
||||
)
|
||||
|
||||
response = self.api_client.post(
|
||||
"/api/yield-harvest/yield-prediction/",
|
||||
{"farm_uuid": str(self.farm.farm_uuid)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.json()["data"]["message"], "provider unavailable")
|
||||
|
||||
def test_crop_simulation_rejects_foreign_farm_uuid(self):
|
||||
request = self.factory.post(
|
||||
"/api/yield-harvest/crop-simulation/yield-prediction/",
|
||||
|
||||
+53
-8
@@ -1,11 +1,15 @@
|
||||
"""Yield & Harvest Prediction and Crop Simulation API views."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
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.observability import classify_exception, log_event, observe_operation, record_metric
|
||||
from config.swagger import code_response, farm_uuid_query_param
|
||||
from external_api_adapter import request as external_api_request
|
||||
from farm_hub.models import FarmHub
|
||||
@@ -23,6 +27,8 @@ from .serializers import (
|
||||
YieldPredictionSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YieldHarvestSummaryView(APIView):
|
||||
"""
|
||||
@@ -110,16 +116,31 @@ class YieldHarvestSummaryView(APIView):
|
||||
return plan_error
|
||||
query.update(ai_payload)
|
||||
|
||||
adapter_response = external_api_request(
|
||||
"ai",
|
||||
"/api/crop-simulation/yield-harvest-summary/",
|
||||
method="GET",
|
||||
query=query,
|
||||
)
|
||||
with observe_operation(source="backend.yield_harvest", provider="ai", operation="yield_harvest_summary"):
|
||||
started_at = time.monotonic()
|
||||
adapter_response = external_api_request(
|
||||
"ai",
|
||||
"/api/crop-simulation/yield-harvest-summary/",
|
||||
method="GET",
|
||||
query=query,
|
||||
)
|
||||
if adapter_response.status_code >= 400:
|
||||
record_metric("yield_harvest.ai.failure", status_code=adapter_response.status_code, operation="yield_harvest_summary")
|
||||
return CropSimulationBaseView._error_response(adapter_response)
|
||||
|
||||
summary = CropSimulationBaseView._extract_result(adapter_response.data)
|
||||
if not summary:
|
||||
record_metric("yield_harvest.ai.empty_result", operation="yield_harvest_summary")
|
||||
log_event(
|
||||
level=logging.WARNING,
|
||||
message="yield harvest summary returned empty result",
|
||||
source="backend.yield_harvest",
|
||||
provider="ai",
|
||||
operation="yield_harvest_summary",
|
||||
result_status="empty",
|
||||
duration_ms=(time.monotonic() - started_at) * 1000,
|
||||
farm_uuid=str(farm.farm_uuid),
|
||||
)
|
||||
|
||||
self._persist_log(farm.farm_uuid, summary)
|
||||
|
||||
@@ -134,8 +155,21 @@ class YieldHarvestSummaryView(APIView):
|
||||
if farm_uuid:
|
||||
try:
|
||||
farm = FarmHub.objects.get(farm_uuid=farm_uuid)
|
||||
except (FarmHub.DoesNotExist, Exception):
|
||||
pass
|
||||
except FarmHub.DoesNotExist:
|
||||
logger.warning("yield_harvest log persistence skipped because farm was not found farm_uuid=%s", farm_uuid)
|
||||
except Exception as exc:
|
||||
failure = classify_exception(exc)
|
||||
log_event(
|
||||
level=logging.ERROR,
|
||||
message="yield_harvest log persistence failed",
|
||||
source="backend.yield_harvest",
|
||||
provider="db",
|
||||
operation="persist_log",
|
||||
result_status="error",
|
||||
error_code=failure.error_code,
|
||||
farm_uuid=str(farm_uuid),
|
||||
)
|
||||
return
|
||||
|
||||
yield_card = summary.get("yield_prediction") or summary.get("yield_prediction_card") or {}
|
||||
harvest_card = summary.get("harvest_prediction_card", {})
|
||||
@@ -178,6 +212,7 @@ class CropSimulationBaseView(APIView):
|
||||
@staticmethod
|
||||
def _extract_result(adapter_data):
|
||||
if not isinstance(adapter_data, dict):
|
||||
record_metric("yield_harvest.ai.invalid_payload", operation="extract_result")
|
||||
return {}
|
||||
|
||||
data = adapter_data.get("data")
|
||||
@@ -199,6 +234,16 @@ class CropSimulationBaseView(APIView):
|
||||
if isinstance(adapter_response.data, dict)
|
||||
else {"message": str(adapter_response.data)}
|
||||
)
|
||||
log_event(
|
||||
level=logging.ERROR,
|
||||
message="yield_harvest upstream request failed",
|
||||
source="backend.yield_harvest",
|
||||
provider="ai",
|
||||
operation="external_api",
|
||||
result_status="error",
|
||||
error_code="provider_error",
|
||||
status_code=adapter_response.status_code,
|
||||
)
|
||||
return Response(
|
||||
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
|
||||
status=adapter_response.status_code,
|
||||
|
||||
Reference in New Issue
Block a user