UPDATE
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date, timedelta
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError: # pragma: no cover - handled when live adapter is used
|
||||
requests = None
|
||||
|
||||
|
||||
DEFAULT_FORECAST_DAYS = 7
|
||||
DAILY_FIELDS = [
|
||||
"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",
|
||||
]
|
||||
WMO_CODES = [0, 1, 2, 3, 45, 51, 61, 63, 65, 80, 95]
|
||||
|
||||
|
||||
def _clamp(value: float, lower: float, upper: float) -> float:
|
||||
return max(lower, min(upper, value))
|
||||
|
||||
|
||||
class BaseWeatherAdapter(ABC):
|
||||
source_name = "base"
|
||||
|
||||
@abstractmethod
|
||||
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
|
||||
"""Return daily forecast data in Open-Meteo compatible shape."""
|
||||
|
||||
|
||||
class OpenMeteoWeatherAdapter(BaseWeatherAdapter):
|
||||
source_name = "open-meteo"
|
||||
|
||||
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
|
||||
if requests is None:
|
||||
raise RuntimeError("requests package is required for OpenMeteoWeatherAdapter")
|
||||
|
||||
params = {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"forecast_days": days,
|
||||
"timezone": "auto",
|
||||
"daily": DAILY_FIELDS,
|
||||
}
|
||||
headers = {"accept": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
response = requests.get(
|
||||
self.base_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
class MockWeatherAdapter(BaseWeatherAdapter):
|
||||
source_name = "mock"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
delay_seconds: float = 0.8,
|
||||
seed_namespace: str = "croplogic-weather",
|
||||
):
|
||||
self.delay_seconds = max(0.0, delay_seconds)
|
||||
self.seed_namespace = seed_namespace
|
||||
|
||||
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
|
||||
if self.delay_seconds:
|
||||
time.sleep(self.delay_seconds)
|
||||
|
||||
climate = self._layered_noise(latitude, longitude, "climate")
|
||||
humidity_bias = self._layered_noise(latitude, longitude, "humidity")
|
||||
rain_bias = self._layered_noise(latitude, longitude, "rain")
|
||||
wind_bias = self._layered_noise(latitude, longitude, "wind")
|
||||
temp_bias = self._layered_noise(latitude, longitude, "temp")
|
||||
|
||||
start = date.today()
|
||||
payload = {field: [] for field in DAILY_FIELDS}
|
||||
payload["time"] = []
|
||||
|
||||
for day_index in range(days):
|
||||
current_date = start + timedelta(days=day_index)
|
||||
seasonal_wave = math.sin(((current_date.timetuple().tm_yday / 365.0) * math.tau) - 0.55)
|
||||
daily_wave = math.sin((day_index / max(days, 1)) * math.tau)
|
||||
short_term = self._layered_noise(
|
||||
latitude + (day_index * 0.11),
|
||||
longitude - (day_index * 0.09),
|
||||
f"day:{day_index}",
|
||||
)
|
||||
|
||||
temp_mean = _clamp(
|
||||
17.0
|
||||
+ (seasonal_wave * 11.0)
|
||||
+ ((temp_bias - 0.5) * 8.0)
|
||||
+ (daily_wave * 2.8)
|
||||
+ ((short_term - 0.5) * 2.5),
|
||||
-6.0,
|
||||
43.0,
|
||||
)
|
||||
diurnal_range = _clamp(
|
||||
8.0 + ((1 - humidity_bias) * 4.2) + ((1 - rain_bias) * 2.0) + (short_term * 1.1),
|
||||
5.0,
|
||||
16.0,
|
||||
)
|
||||
temperature_max = _clamp(temp_mean + (diurnal_range / 2.0), -3.0, 48.0)
|
||||
temperature_min = _clamp(temp_mean - (diurnal_range / 2.0), -12.0, 35.0)
|
||||
|
||||
humidity_mean = _clamp(
|
||||
34.0
|
||||
+ (humidity_bias * 34.0)
|
||||
+ (rain_bias * 12.0)
|
||||
- ((temperature_max - 22.0) * 0.9),
|
||||
18.0,
|
||||
96.0,
|
||||
)
|
||||
precipitation_probability = _clamp(
|
||||
10.0
|
||||
+ (rain_bias * 45.0)
|
||||
+ ((humidity_mean - 45.0) * 0.45)
|
||||
+ (max(0.0, 0.5 - temp_bias) * 18.0)
|
||||
+ ((short_term - 0.5) * 18.0),
|
||||
0.0,
|
||||
100.0,
|
||||
)
|
||||
precipitation = self._precipitation_amount(
|
||||
precipitation_probability=precipitation_probability,
|
||||
rain_bias=rain_bias,
|
||||
humidity_mean=humidity_mean,
|
||||
short_term=short_term,
|
||||
)
|
||||
wind_speed = _clamp(
|
||||
8.0
|
||||
+ (wind_bias * 17.0)
|
||||
+ ((1 - rain_bias) * 2.5)
|
||||
+ (abs(daily_wave) * 3.0)
|
||||
+ (short_term * 2.0),
|
||||
3.0,
|
||||
42.0,
|
||||
)
|
||||
et0 = _clamp(
|
||||
1.0
|
||||
+ (max(temp_mean, 0.0) * 0.11)
|
||||
+ ((1 - (humidity_mean / 100.0)) * 1.7)
|
||||
+ (wind_speed * 0.03)
|
||||
- (precipitation * 0.05),
|
||||
0.3,
|
||||
11.0,
|
||||
)
|
||||
weather_code = self._weather_code(
|
||||
precipitation=precipitation,
|
||||
probability=precipitation_probability,
|
||||
humidity=humidity_mean,
|
||||
wind_speed=wind_speed,
|
||||
cloudiness=(humidity_bias + rain_bias + (1 - temp_bias)) / 3.0,
|
||||
)
|
||||
|
||||
payload["time"].append(current_date.isoformat())
|
||||
payload["temperature_2m_max"].append(round(temperature_max, 1))
|
||||
payload["temperature_2m_min"].append(round(temperature_min, 1))
|
||||
payload["temperature_2m_mean"].append(round(temp_mean, 1))
|
||||
payload["precipitation_sum"].append(round(precipitation, 1))
|
||||
payload["precipitation_probability_max"].append(round(precipitation_probability, 0))
|
||||
payload["relative_humidity_2m_mean"].append(round(humidity_mean, 1))
|
||||
payload["wind_speed_10m_max"].append(round(wind_speed, 1))
|
||||
payload["et0_fao_evapotranspiration"].append(round(et0, 2))
|
||||
payload["weather_code"].append(weather_code)
|
||||
|
||||
return {"latitude": latitude, "longitude": longitude, "daily": payload}
|
||||
|
||||
def _precipitation_amount(
|
||||
self,
|
||||
precipitation_probability: float,
|
||||
rain_bias: float,
|
||||
humidity_mean: float,
|
||||
short_term: float,
|
||||
) -> float:
|
||||
trigger = precipitation_probability / 100.0
|
||||
if trigger < 0.24:
|
||||
return 0.0
|
||||
|
||||
amount = (
|
||||
((trigger - 0.2) ** 1.55) * 18.0
|
||||
+ (rain_bias * 1.6)
|
||||
+ ((humidity_mean - 50.0) * 0.035)
|
||||
+ (short_term * 1.3)
|
||||
)
|
||||
return _clamp(amount, 0.0, 34.0)
|
||||
|
||||
def _weather_code(
|
||||
self,
|
||||
precipitation: float,
|
||||
probability: float,
|
||||
humidity: float,
|
||||
wind_speed: float,
|
||||
cloudiness: float,
|
||||
) -> int:
|
||||
if precipitation >= 10:
|
||||
return 65
|
||||
if precipitation >= 4:
|
||||
return 63
|
||||
if precipitation > 0.6:
|
||||
return 61
|
||||
if probability >= 65 and humidity >= 70:
|
||||
return 51
|
||||
if cloudiness >= 0.8:
|
||||
return 3
|
||||
if cloudiness >= 0.62:
|
||||
return 2
|
||||
if cloudiness >= 0.48 or wind_speed >= 28:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def _layered_noise(self, latitude: float, longitude: float, key: str) -> float:
|
||||
regional = self._smooth_noise(latitude, longitude, f"{key}:regional", scale=2.4)
|
||||
local = self._smooth_noise(latitude, longitude, f"{key}:local", scale=0.45)
|
||||
micro = self._smooth_noise(latitude, longitude, f"{key}:micro", scale=0.12)
|
||||
return _clamp((regional * 0.58) + (local * 0.27) + (micro * 0.15), 0.0, 1.0)
|
||||
|
||||
def _smooth_noise(self, latitude: float, longitude: float, key: str, scale: float) -> float:
|
||||
grid_x = longitude / scale
|
||||
grid_y = latitude / scale
|
||||
x0 = math.floor(grid_x)
|
||||
y0 = math.floor(grid_y)
|
||||
tx = grid_x - x0
|
||||
ty = grid_y - y0
|
||||
|
||||
v00 = self._cell_noise(key, x0, y0)
|
||||
v10 = self._cell_noise(key, x0 + 1, y0)
|
||||
v01 = self._cell_noise(key, x0, y0 + 1)
|
||||
v11 = self._cell_noise(key, x0 + 1, y0 + 1)
|
||||
|
||||
tx = tx * tx * (3.0 - (2.0 * tx))
|
||||
ty = ty * ty * (3.0 - (2.0 * ty))
|
||||
top = (v00 * (1 - tx)) + (v10 * tx)
|
||||
bottom = (v01 * (1 - tx)) + (v11 * tx)
|
||||
return (top * (1 - ty)) + (bottom * ty)
|
||||
|
||||
def _cell_noise(self, key: str, grid_x: int, grid_y: int) -> float:
|
||||
seed_input = f"{self.seed_namespace}:{key}:{grid_x}:{grid_y}"
|
||||
digest = hashlib.sha256(seed_input.encode("ascii")).digest()
|
||||
seed = int.from_bytes(digest[:8], "big", signed=False)
|
||||
return random.Random(seed).random()
|
||||
|
||||
|
||||
def get_weather_adapter() -> BaseWeatherAdapter:
|
||||
from django.conf import settings
|
||||
|
||||
provider = getattr(settings, "WEATHER_DATA_PROVIDER", "mock")
|
||||
if provider == "open-meteo":
|
||||
return OpenMeteoWeatherAdapter(
|
||||
base_url=settings.WEATHER_API_BASE_URL,
|
||||
api_key=settings.WEATHER_API_KEY,
|
||||
timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60),
|
||||
)
|
||||
if provider == "mock":
|
||||
return MockWeatherAdapter(
|
||||
delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8)
|
||||
)
|
||||
raise ValueError(f"Unsupported weather data provider: {provider}")
|
||||
@@ -25,3 +25,12 @@ class WeatherConfig(AppConfig):
|
||||
|
||||
def get_water_need_service(self):
|
||||
return self.water_need_service
|
||||
|
||||
@cached_property
|
||||
def weather_data_adapter(self):
|
||||
from .adapters import get_weather_adapter
|
||||
|
||||
return get_weather_adapter()
|
||||
|
||||
def get_weather_data_adapter(self):
|
||||
return self.weather_data_adapter
|
||||
|
||||
+29
-95
@@ -5,12 +5,12 @@
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.apps import apps
|
||||
from django.db import transaction
|
||||
|
||||
from location_data.models import SoilLocation
|
||||
|
||||
from .adapters import DEFAULT_FORECAST_DAYS
|
||||
from .models import WeatherForecast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,60 +18,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def fetch_weather_from_api(latitude: float, longitude: float) -> dict | None:
|
||||
"""
|
||||
اتصال به API هواشناسی و دریافت پیشبینی ۷ روزه.
|
||||
|
||||
TODO: پیادهسازی اتصال واقعی به API (مثلاً Open-Meteo).
|
||||
در حال حاضر این تابع خالی است و None برمیگرداند.
|
||||
|
||||
پارامترها:
|
||||
latitude: عرض جغرافیایی
|
||||
longitude: طول جغرافیایی
|
||||
|
||||
خروجی مورد انتظار (وقتی پیادهسازی شود):
|
||||
{
|
||||
"daily": {
|
||||
"time": ["2025-07-01", "2025-07-02", ...],
|
||||
"temperature_2m_max": [35.2, 36.1, ...],
|
||||
"temperature_2m_min": [22.1, 23.0, ...],
|
||||
"temperature_2m_mean": [28.6, 29.5, ...],
|
||||
"precipitation_sum": [0.0, 2.5, ...],
|
||||
"precipitation_probability_max": [0, 60, ...],
|
||||
"relative_humidity_2m_mean": [30.0, 45.0, ...],
|
||||
"wind_speed_10m_max": [15.0, 20.0, ...],
|
||||
"et0_fao_evapotranspiration": [6.5, 5.8, ...],
|
||||
"weather_code": [0, 61, ...],
|
||||
}
|
||||
}
|
||||
واکشی پیشبینی هواشناسی از provider فعال.
|
||||
خروجی در قالب سازگار با Open-Meteo daily format برگردانده میشود.
|
||||
"""
|
||||
params = {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"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,
|
||||
adapter = apps.get_app_config("weather").get_weather_data_adapter()
|
||||
return adapter.fetch_forecast(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
days=DEFAULT_FORECAST_DAYS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def parse_weather_response(data: dict) -> list[dict]:
|
||||
@@ -83,37 +38,28 @@ def parse_weather_response(data: dict) -> list[dict]:
|
||||
times = daily.get("time", [])
|
||||
forecasts = []
|
||||
|
||||
for i, date_str in enumerate(times):
|
||||
for index, date_str in enumerate(times):
|
||||
forecasts.append(
|
||||
{
|
||||
"forecast_date": date_str,
|
||||
"temperature_max": _safe_index(
|
||||
daily.get("temperature_2m_max"), i
|
||||
),
|
||||
"temperature_min": _safe_index(
|
||||
daily.get("temperature_2m_min"), i
|
||||
),
|
||||
"temperature_mean": _safe_index(
|
||||
daily.get("temperature_2m_mean"), i
|
||||
),
|
||||
"precipitation": _safe_index(
|
||||
daily.get("precipitation_sum"), i
|
||||
),
|
||||
"temperature_max": _safe_index(daily.get("temperature_2m_max"), index),
|
||||
"temperature_min": _safe_index(daily.get("temperature_2m_min"), index),
|
||||
"temperature_mean": _safe_index(daily.get("temperature_2m_mean"), index),
|
||||
"precipitation": _safe_index(daily.get("precipitation_sum"), index),
|
||||
"precipitation_probability": _safe_index(
|
||||
daily.get("precipitation_probability_max"), i
|
||||
daily.get("precipitation_probability_max"),
|
||||
index,
|
||||
),
|
||||
"humidity_mean": _safe_index(
|
||||
daily.get("relative_humidity_2m_mean"), i
|
||||
),
|
||||
"wind_speed_max": _safe_index(
|
||||
daily.get("wind_speed_10m_max"), i
|
||||
daily.get("relative_humidity_2m_mean"),
|
||||
index,
|
||||
),
|
||||
"wind_speed_max": _safe_index(daily.get("wind_speed_10m_max"), index),
|
||||
"et0": _safe_index(
|
||||
daily.get("et0_fao_evapotranspiration"), i
|
||||
),
|
||||
"weather_code": _safe_index(
|
||||
daily.get("weather_code"), i
|
||||
daily.get("et0_fao_evapotranspiration"),
|
||||
index,
|
||||
),
|
||||
"weather_code": _safe_index(daily.get("weather_code"), index),
|
||||
}
|
||||
)
|
||||
return forecasts
|
||||
@@ -147,24 +93,21 @@ def update_weather_for_location(location: SoilLocation) -> dict:
|
||||
}
|
||||
|
||||
if data is None:
|
||||
logger.info(
|
||||
"Weather API returned no data for location %s (stub mode).",
|
||||
location.id,
|
||||
)
|
||||
logger.info("Weather provider returned no data for location %s.", location.id)
|
||||
return {
|
||||
"status": "no_data",
|
||||
"location_id": location.id,
|
||||
"message": "API connection not implemented yet.",
|
||||
"message": "Weather provider returned no data.",
|
||||
}
|
||||
|
||||
forecasts = parse_weather_response(data)
|
||||
|
||||
with transaction.atomic():
|
||||
for fc in forecasts:
|
||||
for forecast in forecasts:
|
||||
WeatherForecast.objects.update_or_create(
|
||||
location=location,
|
||||
forecast_date=fc.pop("forecast_date"),
|
||||
defaults=fc,
|
||||
forecast_date=forecast.pop("forecast_date"),
|
||||
defaults=forecast,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -180,14 +123,13 @@ def update_weather_for_all_locations() -> list[dict]:
|
||||
"""
|
||||
results = []
|
||||
for location in SoilLocation.objects.all():
|
||||
result = update_weather_for_location(location)
|
||||
results.append(result)
|
||||
results.append(update_weather_for_location(location))
|
||||
return results
|
||||
|
||||
|
||||
def get_forecast_for_location(
|
||||
location: SoilLocation,
|
||||
days: int = 7,
|
||||
days: int = DEFAULT_FORECAST_DAYS,
|
||||
) -> list[WeatherForecast]:
|
||||
"""
|
||||
دریافت پیشبینیهای ذخیرهشده برای یک location (تا N روز آینده).
|
||||
@@ -207,14 +149,6 @@ def should_irrigate_today(location: SoilLocation) -> dict:
|
||||
"""
|
||||
بررسی ساده: آیا فردا باران میبارد؟
|
||||
اگر بارش فردا بیشتر از آستانه باشد → آبیاری لازم نیست.
|
||||
|
||||
خروجی:
|
||||
{
|
||||
"needs_irrigation": bool | None,
|
||||
"tomorrow_precipitation": float | None,
|
||||
"tomorrow_date": str,
|
||||
"reason": str,
|
||||
}
|
||||
"""
|
||||
tomorrow = date.today() + timedelta(days=1)
|
||||
forecast = WeatherForecast.objects.filter(
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.apps import apps
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
|
||||
from location_data.models import SoilLocation
|
||||
from weather.adapters import MockWeatherAdapter, OpenMeteoWeatherAdapter
|
||||
from weather.models import WeatherForecast
|
||||
from weather.services import fetch_weather_from_api, update_weather_for_location
|
||||
|
||||
|
||||
class MockWeatherAdapterTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.adapter = MockWeatherAdapter(delay_seconds=0)
|
||||
|
||||
def test_same_coordinate_returns_same_forecast(self):
|
||||
first = self.adapter.fetch_forecast(35.71, 51.4)
|
||||
second = self.adapter.fetch_forecast(35.71, 51.4)
|
||||
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def test_nearby_coordinates_produce_nearby_forecast(self):
|
||||
first = self.adapter.fetch_forecast(35.71, 51.4)
|
||||
second = self.adapter.fetch_forecast(35.715, 51.405)
|
||||
|
||||
first_daily = first["daily"]
|
||||
second_daily = second["daily"]
|
||||
self.assertLess(
|
||||
abs(first_daily["temperature_2m_mean"][0] - second_daily["temperature_2m_mean"][0]),
|
||||
2.5,
|
||||
)
|
||||
self.assertLess(
|
||||
abs(first_daily["relative_humidity_2m_mean"][0] - second_daily["relative_humidity_2m_mean"][0]),
|
||||
8.0,
|
||||
)
|
||||
self.assertLess(
|
||||
abs(first_daily["wind_speed_10m_max"][0] - second_daily["wind_speed_10m_max"][0]),
|
||||
6.0,
|
||||
)
|
||||
|
||||
def test_shape_matches_open_meteo_daily_contract(self):
|
||||
forecast = self.adapter.fetch_forecast(35.71, 51.4)
|
||||
daily = forecast["daily"]
|
||||
|
||||
self.assertEqual(len(daily["time"]), 7)
|
||||
self.assertEqual(len(daily["temperature_2m_max"]), 7)
|
||||
self.assertEqual(len(daily["weather_code"]), 7)
|
||||
|
||||
|
||||
class WeatherAdapterSelectionTests(SimpleTestCase):
|
||||
def tearDown(self):
|
||||
apps.get_app_config("weather").__dict__.pop("weather_data_adapter", None)
|
||||
|
||||
@override_settings(WEATHER_DATA_PROVIDER="mock", WEATHER_MOCK_DELAY_SECONDS=0)
|
||||
def test_app_config_returns_mock_adapter(self):
|
||||
config = apps.get_app_config("weather")
|
||||
config.__dict__.pop("weather_data_adapter", None)
|
||||
|
||||
adapter = config.get_weather_data_adapter()
|
||||
|
||||
self.assertIsInstance(adapter, MockWeatherAdapter)
|
||||
|
||||
@override_settings(WEATHER_DATA_PROVIDER="open-meteo", WEATHER_TIMEOUT_SECONDS=12)
|
||||
def test_app_config_returns_live_adapter(self):
|
||||
config = apps.get_app_config("weather")
|
||||
config.__dict__.pop("weather_data_adapter", None)
|
||||
|
||||
adapter = config.get_weather_data_adapter()
|
||||
|
||||
self.assertIsInstance(adapter, OpenMeteoWeatherAdapter)
|
||||
self.assertEqual(adapter.timeout, 12)
|
||||
|
||||
|
||||
@override_settings(WEATHER_DATA_PROVIDER="mock", WEATHER_MOCK_DELAY_SECONDS=0)
|
||||
class WeatherServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.location = SoilLocation.objects.create(
|
||||
latitude="35.710000",
|
||||
longitude="51.400000",
|
||||
)
|
||||
|
||||
def test_fetch_weather_from_api_uses_mock_provider(self):
|
||||
payload = fetch_weather_from_api(35.71, 51.4)
|
||||
|
||||
self.assertIn("daily", payload)
|
||||
self.assertEqual(len(payload["daily"]["time"]), 7)
|
||||
|
||||
def test_update_weather_for_location_persists_seven_days(self):
|
||||
result = update_weather_for_location(self.location)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["days_updated"], 7)
|
||||
self.assertEqual(
|
||||
WeatherForecast.objects.filter(location=self.location).count(),
|
||||
7,
|
||||
)
|
||||
self.assertTrue(
|
||||
WeatherForecast.objects.filter(
|
||||
location=self.location,
|
||||
precipitation__isnull=False,
|
||||
weather_code__isnull=False,
|
||||
).exists()
|
||||
)
|
||||
Reference in New Issue
Block a user