UPDATE
This commit is contained in:
@@ -6,7 +6,9 @@ from django.db import transaction
|
|||||||
|
|
||||||
from location_data.models import SoilLocation
|
from location_data.models import SoilLocation
|
||||||
from location_data.serializers import SoilDepthDataSerializer
|
from location_data.serializers import SoilDepthDataSerializer
|
||||||
|
from location_data.tasks import fetch_soil_data_for_coordinates
|
||||||
from plant.serializers import PlantSerializer
|
from plant.serializers import PlantSerializer
|
||||||
|
from weather.services import update_weather_for_location
|
||||||
from weather.models import WeatherForecast
|
from weather.models import WeatherForecast
|
||||||
|
|
||||||
from .models import SensorData
|
from .models import SensorData
|
||||||
@@ -17,6 +19,10 @@ DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"]
|
|||||||
DECIMAL_PRECISION = Decimal("0.000001")
|
DECIMAL_PRECISION = Decimal("0.000001")
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalDataSyncError(Exception):
|
||||||
|
"""خطا در همگامسازی داده از سرویسهای بیرونی."""
|
||||||
|
|
||||||
|
|
||||||
def get_farm_details(farm_uuid: str):
|
def get_farm_details(farm_uuid: str):
|
||||||
farm = (
|
farm = (
|
||||||
SensorData.objects.select_related("center_location", "weather_forecast")
|
SensorData.objects.select_related("center_location", "weather_forecast")
|
||||||
@@ -104,6 +110,38 @@ def resolve_weather_for_location(location: SoilLocation) -> WeatherForecast | No
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_location_and_weather_data(location: SoilLocation) -> tuple[SoilLocation, WeatherForecast | None]:
|
||||||
|
"""
|
||||||
|
اگر داده خاک یا آبوهوا برای location موجود نباشد، از سرویس مربوطه
|
||||||
|
واکشی و در دیتابیس ذخیره میشود.
|
||||||
|
"""
|
||||||
|
if not location.is_complete:
|
||||||
|
try:
|
||||||
|
soil_result = fetch_soil_data_for_coordinates(
|
||||||
|
latitude=float(location.latitude),
|
||||||
|
longitude=float(location.longitude),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ExternalDataSyncError(f"خطا در واکشی داده خاک: {exc}") from exc
|
||||||
|
|
||||||
|
if soil_result.get("status") != "completed":
|
||||||
|
raise ExternalDataSyncError(
|
||||||
|
soil_result.get("error") or "واکشی داده خاک کامل نشد."
|
||||||
|
)
|
||||||
|
location.refresh_from_db()
|
||||||
|
|
||||||
|
weather_forecast = resolve_weather_for_location(location)
|
||||||
|
if weather_forecast is None:
|
||||||
|
weather_result = update_weather_for_location(location)
|
||||||
|
if weather_result.get("status") not in {"success", "no_data"}:
|
||||||
|
raise ExternalDataSyncError(
|
||||||
|
weather_result.get("error") or "واکشی داده آبوهوا کامل نشد."
|
||||||
|
)
|
||||||
|
weather_forecast = resolve_weather_for_location(location)
|
||||||
|
|
||||||
|
return location, weather_forecast
|
||||||
|
|
||||||
|
|
||||||
def _flatten_sensor_metrics(sensor_payload: dict | None) -> dict:
|
def _flatten_sensor_metrics(sensor_payload: dict | None) -> dict:
|
||||||
if not isinstance(sensor_payload, dict):
|
if not isinstance(sensor_payload, dict):
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
from unittest.mock import patch
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@@ -193,3 +194,68 @@ class FarmDataUpsertApiTests(TestCase):
|
|||||||
self.assertEqual(str(farm.center_location.latitude), "50.010000")
|
self.assertEqual(str(farm.center_location.latitude), "50.010000")
|
||||||
self.assertEqual(str(farm.center_location.longitude), "50.010000")
|
self.assertEqual(str(farm.center_location.longitude), "50.010000")
|
||||||
self.assertIsNone(farm.weather_forecast_id)
|
self.assertIsNone(farm.weather_forecast_id)
|
||||||
|
|
||||||
|
@patch("farm_data.services.update_weather_for_location")
|
||||||
|
@patch("farm_data.services.fetch_soil_data_for_coordinates")
|
||||||
|
def test_post_fetches_missing_location_and_weather_data(
|
||||||
|
self,
|
||||||
|
mock_fetch_soil_data_for_coordinates,
|
||||||
|
mock_update_weather_for_location,
|
||||||
|
):
|
||||||
|
missing_boundary = square_boundary_for_center(36.0, 52.0)
|
||||||
|
farm_uuid = uuid.uuid4()
|
||||||
|
|
||||||
|
def soil_side_effect(latitude, longitude, task_id="", progress_callback=None):
|
||||||
|
location = SoilLocation.objects.get(
|
||||||
|
latitude="36.000000",
|
||||||
|
longitude="52.000000",
|
||||||
|
)
|
||||||
|
SoilDepthData.objects.update_or_create(
|
||||||
|
soil_location=location,
|
||||||
|
depth_label="0-5cm",
|
||||||
|
defaults={"clay": 20.0},
|
||||||
|
)
|
||||||
|
SoilDepthData.objects.update_or_create(
|
||||||
|
soil_location=location,
|
||||||
|
depth_label="5-15cm",
|
||||||
|
defaults={"clay": 18.0},
|
||||||
|
)
|
||||||
|
SoilDepthData.objects.update_or_create(
|
||||||
|
soil_location=location,
|
||||||
|
depth_label="15-30cm",
|
||||||
|
defaults={"clay": 16.0},
|
||||||
|
)
|
||||||
|
return {"status": "completed", "location_id": location.id, "depths": ["0-5cm", "5-15cm", "15-30cm"]}
|
||||||
|
|
||||||
|
def weather_side_effect(location):
|
||||||
|
WeatherForecast.objects.update_or_create(
|
||||||
|
location=location,
|
||||||
|
forecast_date=date(2026, 4, 12),
|
||||||
|
defaults={
|
||||||
|
"temperature_min": 10.0,
|
||||||
|
"temperature_max": 20.0,
|
||||||
|
"temperature_mean": 15.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"status": "success", "location_id": location.id, "days_updated": 1}
|
||||||
|
|
||||||
|
mock_fetch_soil_data_for_coordinates.side_effect = soil_side_effect
|
||||||
|
mock_update_weather_for_location.side_effect = weather_side_effect
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/farm-data/",
|
||||||
|
data={
|
||||||
|
"farm_uuid": str(farm_uuid),
|
||||||
|
"farm_boundary": missing_boundary,
|
||||||
|
"sensor_payload": {"sensor-7-1": {"soil_moisture": 44.0}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
mock_fetch_soil_data_for_coordinates.assert_called_once()
|
||||||
|
mock_update_weather_for_location.assert_called_once()
|
||||||
|
|
||||||
|
farm = SensorData.objects.get(farm_uuid=farm_uuid)
|
||||||
|
self.assertEqual(farm.center_location.depths.count(), 3)
|
||||||
|
self.assertIsNotNone(farm.weather_forecast_id)
|
||||||
|
|||||||
+15
-2
@@ -21,9 +21,10 @@ from .serializers import (
|
|||||||
SensorParameterSerializer,
|
SensorParameterSerializer,
|
||||||
)
|
)
|
||||||
from .services import (
|
from .services import (
|
||||||
|
ExternalDataSyncError,
|
||||||
|
ensure_location_and_weather_data,
|
||||||
get_farm_details,
|
get_farm_details,
|
||||||
resolve_center_location_from_boundary,
|
resolve_center_location_from_boundary,
|
||||||
resolve_weather_for_location,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +94,10 @@ class FarmDataUpsertView(APIView):
|
|||||||
SensorDataValidationErrorSerializer,
|
SensorDataValidationErrorSerializer,
|
||||||
"داده ورودی نامعتبر است.",
|
"داده ورودی نامعتبر است.",
|
||||||
),
|
),
|
||||||
|
502: build_response(
|
||||||
|
SensorDataNotFoundSerializer,
|
||||||
|
"واکشی داده خاک یا آبوهوا از سرویس بیرونی ناموفق بود.",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
examples=[
|
examples=[
|
||||||
OpenApiExample(
|
OpenApiExample(
|
||||||
@@ -171,7 +176,15 @@ class FarmDataUpsertView(APIView):
|
|||||||
{"code": 400, "msg": "داده نامعتبر.", "data": {"farm_boundary": [str(exc)]}},
|
{"code": 400, "msg": "داده نامعتبر.", "data": {"farm_boundary": [str(exc)]}},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
weather_forecast = resolve_weather_for_location(center_location)
|
try:
|
||||||
|
center_location, weather_forecast = ensure_location_and_weather_data(
|
||||||
|
center_location
|
||||||
|
)
|
||||||
|
except ExternalDataSyncError as exc:
|
||||||
|
return Response(
|
||||||
|
{"code": 502, "msg": str(exc), "data": None},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
farm_data, created = SensorData.objects.get_or_create(
|
farm_data, created = SensorData.objects.get_or_create(
|
||||||
|
|||||||
+49
-28
@@ -60,11 +60,15 @@ def _parse_response_to_fields(data: dict) -> dict:
|
|||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
@app.task(bind=True)
|
def fetch_soil_data_for_coordinates(
|
||||||
def fetch_soil_data_task(self, latitude: float, longitude: float):
|
latitude: float,
|
||||||
|
longitude: float,
|
||||||
|
task_id: str = "",
|
||||||
|
progress_callback=None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
واکشی دادههای خاک برای مختصات دادهشده از SoilGrids و ذخیره در DB.
|
واکشی سنکرون داده خاک برای مختصات دادهشده و ذخیره در DB.
|
||||||
برای هر عمق (0-5cm, 5-15cm, 15-30cm) یک ریکوئست جدا زده میشود.
|
این helper هم توسط Celery task و هم توسط endpointهای sync استفاده میشود.
|
||||||
"""
|
"""
|
||||||
lat = Decimal(str(round(float(latitude), 6)))
|
lat = Decimal(str(round(float(latitude), 6)))
|
||||||
lon = Decimal(str(round(float(longitude), 6)))
|
lon = Decimal(str(round(float(longitude), 6)))
|
||||||
@@ -73,31 +77,23 @@ def fetch_soil_data_task(self, latitude: float, longitude: float):
|
|||||||
location, created = SoilLocation.objects.select_for_update().get_or_create(
|
location, created = SoilLocation.objects.select_for_update().get_or_create(
|
||||||
latitude=lat,
|
latitude=lat,
|
||||||
longitude=lon,
|
longitude=lon,
|
||||||
defaults={"task_id": self.request.id},
|
defaults={"task_id": task_id},
|
||||||
)
|
)
|
||||||
if not created:
|
if not created and task_id:
|
||||||
location.task_id = self.request.id
|
location.task_id = task_id
|
||||||
location.save(update_fields=["task_id"])
|
location.save(update_fields=["task_id"])
|
||||||
|
|
||||||
for i, depth in enumerate(DEPTHS):
|
for i, depth in enumerate(DEPTHS):
|
||||||
self.update_state(
|
if progress_callback is not None:
|
||||||
state="PROGRESS",
|
progress_callback(
|
||||||
meta={
|
state="PROGRESS",
|
||||||
"current": i + 1,
|
meta={
|
||||||
"total": len(DEPTHS),
|
"current": i + 1,
|
||||||
"message": f"در حال واکشی عمق {depth}...",
|
"total": len(DEPTHS),
|
||||||
},
|
"message": f"در حال واکشی عمق {depth}...",
|
||||||
)
|
},
|
||||||
try:
|
)
|
||||||
data = _fetch_soilgrids(float(lon), float(lat), depth)
|
data = _fetch_soilgrids(float(lon), float(lat), depth)
|
||||||
except requests.RequestException as e:
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"location_id": location.id,
|
|
||||||
"depth": depth,
|
|
||||||
"error": str(e),
|
|
||||||
}
|
|
||||||
|
|
||||||
fields = _parse_response_to_fields(data)
|
fields = _parse_response_to_fields(data)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
SoilDepthData.objects.update_or_create(
|
SoilDepthData.objects.update_or_create(
|
||||||
@@ -106,12 +102,37 @@ def fetch_soil_data_task(self, latitude: float, longitude: float):
|
|||||||
defaults=fields,
|
defaults=fields,
|
||||||
)
|
)
|
||||||
|
|
||||||
with transaction.atomic():
|
if task_id:
|
||||||
location.task_id = ""
|
with transaction.atomic():
|
||||||
location.save(update_fields=["task_id"])
|
location.task_id = ""
|
||||||
|
location.save(update_fields=["task_id"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"location_id": location.id,
|
"location_id": location.id,
|
||||||
"depths": DEPTHS,
|
"depths": DEPTHS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(bind=True)
|
||||||
|
def fetch_soil_data_task(self, latitude: float, longitude: float):
|
||||||
|
"""
|
||||||
|
واکشی دادههای خاک برای مختصات دادهشده از SoilGrids و ذخیره در DB.
|
||||||
|
برای هر عمق (0-5cm, 5-15cm, 15-30cm) یک ریکوئست جدا زده میشود.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return fetch_soil_data_for_coordinates(
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
task_id=self.request.id,
|
||||||
|
progress_callback=self.update_state,
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
lat = Decimal(str(round(float(latitude), 6)))
|
||||||
|
lon = Decimal(str(round(float(longitude), 6)))
|
||||||
|
location = SoilLocation.objects.filter(latitude=lat, longitude=lon).first()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"location_id": getattr(location, "id", None),
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|||||||
+30
-4
@@ -5,6 +5,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
@@ -42,10 +43,35 @@ def fetch_weather_from_api(latitude: float, longitude: float) -> dict | None:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# TODO: اتصال واقعی به API هواشناسی
|
params = {
|
||||||
# api_url = settings.WEATHER_API_BASE_URL
|
"latitude": latitude,
|
||||||
# api_key = settings.WEATHER_API_KEY
|
"longitude": longitude,
|
||||||
return None
|
"forecast_days": 7,
|
||||||
|
"timezone": "auto",
|
||||||
|
"daily": [
|
||||||
|
"temperature_2m_max",
|
||||||
|
"temperature_2m_min",
|
||||||
|
"temperature_2m_mean",
|
||||||
|
"precipitation_sum",
|
||||||
|
"precipitation_probability_max",
|
||||||
|
"relative_humidity_2m_mean",
|
||||||
|
"wind_speed_10m_max",
|
||||||
|
"et0_fao_evapotranspiration",
|
||||||
|
"weather_code",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
headers = {"accept": "application/json"}
|
||||||
|
if settings.WEATHER_API_KEY:
|
||||||
|
headers["Authorization"] = f"Bearer {settings.WEATHER_API_KEY}"
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
settings.WEATHER_API_BASE_URL,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
def parse_weather_response(data: dict) -> list[dict]:
|
def parse_weather_response(data: dict) -> list[dict]:
|
||||||
|
|||||||
Reference in New Issue
Block a user