changes
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from django.db.models import Q
|
||||
|
||||
from .models import (
|
||||
@@ -345,6 +346,8 @@ class CalendarLocationSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class LocationSerializer(CustomModelSerializer):
|
||||
name = serializers.CharField(required=True)
|
||||
location = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||
images = serializers.SerializerMethodField()
|
||||
visits = VisitSerializer(many=True, read_only=False, required=False)
|
||||
attachments = AttachmentSerializer(many=True, read_only=True)
|
||||
@@ -426,6 +429,19 @@ class LocationSerializer(CustomModelSerializer):
|
||||
# Filter out None values from the serialized data
|
||||
return [image for image in serializer.data if image is not None]
|
||||
|
||||
@staticmethod
|
||||
def _truncate_to_model_max_length(value, field_name):
|
||||
if value is None:
|
||||
return value
|
||||
max_length = Location._meta.get_field(field_name).max_length
|
||||
return value[:max_length]
|
||||
|
||||
def validate_name(self, value):
|
||||
return self._truncate_to_model_max_length(value, "name")
|
||||
|
||||
def validate_location(self, value):
|
||||
return self._truncate_to_model_max_length(value, "location")
|
||||
|
||||
def validate_collections(self, collections):
|
||||
"""Validate that collections are compatible with the location being created/updated"""
|
||||
|
||||
@@ -511,6 +527,33 @@ class LocationSerializer(CustomModelSerializer):
|
||||
category_data["name"] = name
|
||||
return category_data
|
||||
|
||||
@staticmethod
|
||||
def _normalize_coordinate_input(value):
|
||||
if value in (None, ""):
|
||||
return value
|
||||
|
||||
try:
|
||||
coordinate = Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return value
|
||||
|
||||
return coordinate.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if self.instance is None:
|
||||
normalized_data = data.copy()
|
||||
|
||||
for field_name in ("latitude", "longitude"):
|
||||
if field_name not in normalized_data:
|
||||
continue
|
||||
normalized_data[field_name] = self._normalize_coordinate_input(
|
||||
normalized_data.get(field_name)
|
||||
)
|
||||
|
||||
data = normalized_data
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def get_or_create_category(self, category_data):
|
||||
user = self.context["request"].user
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from adventures.models import (
|
||||
Note,
|
||||
Transportation,
|
||||
)
|
||||
from adventures.utils.weather import fetch_daily_temperature
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
@@ -61,7 +62,11 @@ class WeatherViewTests(APITestCase):
|
||||
mock_fetch_temperature.return_value = {
|
||||
"date": future_date,
|
||||
"available": True,
|
||||
"temperature_low_c": 19.0,
|
||||
"temperature_high_c": 26.0,
|
||||
"temperature_c": 22.5,
|
||||
"is_estimate": False,
|
||||
"source": "forecast",
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
@@ -73,18 +78,44 @@ class WeatherViewTests(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["results"][0]["date"], future_date)
|
||||
self.assertTrue(response.json()["results"][0]["available"])
|
||||
self.assertEqual(response.json()["results"][0]["temperature_low_c"], 19.0)
|
||||
self.assertEqual(response.json()["results"][0]["temperature_high_c"], 26.0)
|
||||
self.assertFalse(response.json()["results"][0]["is_estimate"])
|
||||
self.assertEqual(response.json()["results"][0]["source"], "forecast")
|
||||
self.assertEqual(response.json()["results"][0]["temperature_c"], 22.5)
|
||||
mock_fetch_temperature.assert_called_once_with(future_date, 12.34, 56.78)
|
||||
|
||||
@patch("adventures.views.weather_view.requests.get")
|
||||
def test_daily_temperatures_far_future_returns_unavailable_when_upstream_has_no_data(
|
||||
@patch("adventures.utils.weather.requests.get")
|
||||
def test_daily_temperatures_far_future_uses_historical_estimate(
|
||||
self, mock_requests_get
|
||||
):
|
||||
future_date = (timezone.now().date() + timedelta(days=3650)).isoformat()
|
||||
mocked_response = Mock()
|
||||
mocked_response.raise_for_status.return_value = None
|
||||
mocked_response.json.return_value = {"daily": {}}
|
||||
mock_requests_get.return_value = mocked_response
|
||||
|
||||
archive_no_data = Mock()
|
||||
archive_no_data.raise_for_status.return_value = None
|
||||
archive_no_data.json.return_value = {"daily": {}}
|
||||
|
||||
forecast_no_data = Mock()
|
||||
forecast_no_data.raise_for_status.return_value = None
|
||||
forecast_no_data.json.return_value = {"daily": {}}
|
||||
|
||||
historical_data = Mock()
|
||||
historical_data.raise_for_status.return_value = None
|
||||
historical_data.json.return_value = {
|
||||
"daily": {
|
||||
"temperature_2m_max": [15.0, 18.0, 20.0],
|
||||
"temperature_2m_min": [7.0, 9.0, 11.0],
|
||||
}
|
||||
}
|
||||
|
||||
call_sequence = [archive_no_data, forecast_no_data, historical_data]
|
||||
|
||||
def mock_get(*args, **kwargs):
|
||||
if call_sequence:
|
||||
return call_sequence.pop(0)
|
||||
return historical_data
|
||||
|
||||
mock_requests_get.side_effect = mock_get
|
||||
|
||||
response = self.client.post(
|
||||
"/api/weather/daily-temperatures/",
|
||||
@@ -93,13 +124,17 @@ class WeatherViewTests(APITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json()["results"][0],
|
||||
{"date": future_date, "available": False, "temperature_c": None},
|
||||
)
|
||||
self.assertEqual(mock_requests_get.call_count, 2)
|
||||
result = response.json()["results"][0]
|
||||
self.assertTrue(result["available"])
|
||||
self.assertEqual(result["date"], future_date)
|
||||
self.assertEqual(result["temperature_low_c"], 9.0)
|
||||
self.assertEqual(result["temperature_high_c"], 17.7)
|
||||
self.assertEqual(result["temperature_c"], 13.3)
|
||||
self.assertTrue(result["is_estimate"])
|
||||
self.assertEqual(result["source"], "historical_estimate")
|
||||
self.assertGreaterEqual(mock_requests_get.call_count, 3)
|
||||
|
||||
@patch("adventures.views.weather_view.requests.get")
|
||||
@patch("adventures.utils.weather.requests.get")
|
||||
def test_daily_temperatures_accepts_zero_lat_lon(self, mock_requests_get):
|
||||
today = timezone.now().date().isoformat()
|
||||
mocked_response = Mock()
|
||||
@@ -121,9 +156,43 @@ class WeatherViewTests(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["results"][0]["date"], today)
|
||||
self.assertTrue(response.json()["results"][0]["available"])
|
||||
self.assertEqual(response.json()["results"][0]["temperature_low_c"], 10.0)
|
||||
self.assertEqual(response.json()["results"][0]["temperature_high_c"], 20.0)
|
||||
self.assertFalse(response.json()["results"][0]["is_estimate"])
|
||||
self.assertEqual(response.json()["results"][0]["source"], "archive")
|
||||
self.assertEqual(response.json()["results"][0]["temperature_c"], 15.0)
|
||||
|
||||
|
||||
class WeatherHelperTests(TestCase):
|
||||
@patch("adventures.utils.weather.requests.get")
|
||||
def test_fetch_daily_temperature_returns_unavailable_when_all_sources_fail(
|
||||
self, mock_requests_get
|
||||
):
|
||||
mocked_response = Mock()
|
||||
mocked_response.raise_for_status.return_value = None
|
||||
mocked_response.json.return_value = {"daily": {}}
|
||||
mock_requests_get.return_value = mocked_response
|
||||
|
||||
result = fetch_daily_temperature(
|
||||
date=(timezone.now().date() + timedelta(days=6000)).isoformat(),
|
||||
latitude=40.7128,
|
||||
longitude=-74.0060,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"date": result["date"],
|
||||
"available": False,
|
||||
"temperature_low_c": None,
|
||||
"temperature_high_c": None,
|
||||
"temperature_c": None,
|
||||
"is_estimate": False,
|
||||
"source": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class MCPAuthTests(APITestCase):
|
||||
def test_mcp_unauthenticated_access_is_rejected(self):
|
||||
unauthenticated_client = APIClient()
|
||||
@@ -131,6 +200,52 @@ class MCPAuthTests(APITestCase):
|
||||
self.assertIn(response.status_code, [401, 403])
|
||||
|
||||
|
||||
class LocationPayloadHardeningTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="location-hardening-user",
|
||||
email="location-hardening@example.com",
|
||||
password="password123",
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_create_location_truncates_overlong_name_and_location(self):
|
||||
overlong_name = "N" * 250
|
||||
overlong_location = "L" * 250
|
||||
|
||||
response = self.client.post(
|
||||
"/api/locations/",
|
||||
{
|
||||
"name": overlong_name,
|
||||
"location": overlong_location,
|
||||
"is_public": False,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(len(response.data["name"]), 200)
|
||||
self.assertEqual(len(response.data["location"]), 200)
|
||||
self.assertEqual(response.data["name"], overlong_name[:200])
|
||||
self.assertEqual(response.data["location"], overlong_location[:200])
|
||||
|
||||
def test_create_location_accepts_high_precision_coordinates(self):
|
||||
response = self.client.post(
|
||||
"/api/locations/",
|
||||
{
|
||||
"name": "Precision test",
|
||||
"is_public": False,
|
||||
"latitude": 51.5007292,
|
||||
"longitude": -0.1246254,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["latitude"], "51.500729")
|
||||
self.assertEqual(response.data["longitude"], "-0.124625")
|
||||
|
||||
|
||||
class CollectionViewSetTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create_user(
|
||||
|
||||
172
backend/server/adventures/utils/weather.py
Normal file
172
backend/server/adventures/utils/weather.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import logging
|
||||
from datetime import date as date_cls
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
|
||||
OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
|
||||
HISTORICAL_YEARS_BACK = 5
|
||||
HISTORICAL_WINDOW_DAYS = 7
|
||||
|
||||
|
||||
def _base_payload(date: str) -> dict:
|
||||
return {
|
||||
"date": date,
|
||||
"available": False,
|
||||
"temperature_low_c": None,
|
||||
"temperature_high_c": None,
|
||||
"temperature_c": None,
|
||||
"is_estimate": False,
|
||||
"source": None,
|
||||
}
|
||||
|
||||
|
||||
def _coerce_temperature(max_values, min_values):
|
||||
if not max_values or not min_values:
|
||||
return None
|
||||
|
||||
try:
|
||||
low = float(min_values[0])
|
||||
high = float(max_values[0])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
|
||||
avg = (low + high) / 2
|
||||
return {
|
||||
"temperature_low_c": round(low, 1),
|
||||
"temperature_high_c": round(high, 1),
|
||||
"temperature_c": round(avg, 1),
|
||||
}
|
||||
|
||||
|
||||
def _request_daily_range(
|
||||
url: str, latitude: float, longitude: float, start_date: str, end_date: str
|
||||
):
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
params={
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"daily": "temperature_2m_max,temperature_2m_min",
|
||||
"timezone": "UTC",
|
||||
},
|
||||
timeout=8,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_direct_temperature(date: str, latitude: float, longitude: float):
|
||||
for source, url in (
|
||||
("archive", OPEN_METEO_ARCHIVE_URL),
|
||||
("forecast", OPEN_METEO_FORECAST_URL),
|
||||
):
|
||||
data = _request_daily_range(url, latitude, longitude, date, date)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
daily = data.get("daily") or {}
|
||||
temperatures = _coerce_temperature(
|
||||
daily.get("temperature_2m_max") or [],
|
||||
daily.get("temperature_2m_min") or [],
|
||||
)
|
||||
if not temperatures:
|
||||
continue
|
||||
|
||||
return {
|
||||
**temperatures,
|
||||
"available": True,
|
||||
"is_estimate": False,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_historical_estimate(date: str, latitude: float, longitude: float):
|
||||
try:
|
||||
target_date = date_cls.fromisoformat(date)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
all_max: list[float] = []
|
||||
all_min: list[float] = []
|
||||
|
||||
for years_back in range(1, HISTORICAL_YEARS_BACK + 1):
|
||||
year = target_date.year - years_back
|
||||
try:
|
||||
same_day = target_date.replace(year=year)
|
||||
except ValueError:
|
||||
# Leap-day fallback: use Feb 28 for non-leap years
|
||||
same_day = target_date.replace(year=year, day=28)
|
||||
|
||||
start = same_day.fromordinal(same_day.toordinal() - HISTORICAL_WINDOW_DAYS)
|
||||
end = same_day.fromordinal(same_day.toordinal() + HISTORICAL_WINDOW_DAYS)
|
||||
data = _request_daily_range(
|
||||
OPEN_METEO_ARCHIVE_URL,
|
||||
latitude,
|
||||
longitude,
|
||||
start.isoformat(),
|
||||
end.isoformat(),
|
||||
)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
daily = data.get("daily") or {}
|
||||
max_values = daily.get("temperature_2m_max") or []
|
||||
min_values = daily.get("temperature_2m_min") or []
|
||||
pair_count = min(len(max_values), len(min_values))
|
||||
|
||||
for index in range(pair_count):
|
||||
try:
|
||||
all_max.append(float(max_values[index]))
|
||||
all_min.append(float(min_values[index]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if not all_max or not all_min:
|
||||
return None
|
||||
|
||||
avg_max = sum(all_max) / len(all_max)
|
||||
avg_min = sum(all_min) / len(all_min)
|
||||
avg = (avg_max + avg_min) / 2
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"temperature_low_c": round(avg_min, 1),
|
||||
"temperature_high_c": round(avg_max, 1),
|
||||
"temperature_c": round(avg, 1),
|
||||
"is_estimate": True,
|
||||
"source": "historical_estimate",
|
||||
}
|
||||
|
||||
|
||||
def fetch_daily_temperature(date: str, latitude: float, longitude: float):
|
||||
payload = _base_payload(date)
|
||||
|
||||
direct = _fetch_direct_temperature(date, latitude, longitude)
|
||||
if direct:
|
||||
return {**payload, **direct}
|
||||
|
||||
historical_estimate = _fetch_historical_estimate(date, latitude, longitude)
|
||||
if historical_estimate:
|
||||
return {**payload, **historical_estimate}
|
||||
|
||||
logger.info(
|
||||
"No weather data available for date=%s lat=%s lon=%s",
|
||||
date,
|
||||
latitude,
|
||||
longitude,
|
||||
)
|
||||
return payload
|
||||
@@ -1,22 +1,17 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import date as date_cls
|
||||
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from adventures.utils.weather import fetch_daily_temperature
|
||||
|
||||
|
||||
class WeatherViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
|
||||
OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
CACHE_TIMEOUT_SECONDS = 60 * 60 * 6
|
||||
MAX_DAYS_PER_REQUEST = 60
|
||||
|
||||
@@ -39,7 +34,15 @@ class WeatherViewSet(viewsets.ViewSet):
|
||||
for entry in days:
|
||||
if not isinstance(entry, dict):
|
||||
results.append(
|
||||
{"date": None, "available": False, "temperature_c": None}
|
||||
{
|
||||
"date": None,
|
||||
"available": False,
|
||||
"temperature_low_c": None,
|
||||
"temperature_high_c": None,
|
||||
"temperature_c": None,
|
||||
"is_estimate": False,
|
||||
"source": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -49,14 +52,30 @@ class WeatherViewSet(viewsets.ViewSet):
|
||||
|
||||
if not date or latitude is None or longitude is None:
|
||||
results.append(
|
||||
{"date": date, "available": False, "temperature_c": None}
|
||||
{
|
||||
"date": date,
|
||||
"available": False,
|
||||
"temperature_low_c": None,
|
||||
"temperature_high_c": None,
|
||||
"temperature_c": None,
|
||||
"is_estimate": False,
|
||||
"source": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
parsed_date = self._parse_date(date)
|
||||
if parsed_date is None:
|
||||
results.append(
|
||||
{"date": date, "available": False, "temperature_c": None}
|
||||
{
|
||||
"date": date,
|
||||
"available": False,
|
||||
"temperature_low_c": None,
|
||||
"temperature_high_c": None,
|
||||
"temperature_c": None,
|
||||
"is_estimate": False,
|
||||
"source": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -65,7 +84,15 @@ class WeatherViewSet(viewsets.ViewSet):
|
||||
lon = float(longitude)
|
||||
except (TypeError, ValueError):
|
||||
results.append(
|
||||
{"date": date, "available": False, "temperature_c": None}
|
||||
{
|
||||
"date": date,
|
||||
"available": False,
|
||||
"temperature_low_c": None,
|
||||
"temperature_high_c": None,
|
||||
"temperature_c": None,
|
||||
"is_estimate": False,
|
||||
"source": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -82,57 +109,9 @@ class WeatherViewSet(viewsets.ViewSet):
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
def _fetch_daily_temperature(self, date: str, latitude: float, longitude: float):
|
||||
base_payload = {
|
||||
"date": date,
|
||||
"available": False,
|
||||
"temperature_c": None,
|
||||
}
|
||||
|
||||
for url in (self.OPEN_METEO_ARCHIVE_URL, self.OPEN_METEO_FORECAST_URL):
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
params={
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"start_date": date,
|
||||
"end_date": date,
|
||||
"daily": "temperature_2m_max,temperature_2m_min",
|
||||
"timezone": "UTC",
|
||||
},
|
||||
timeout=8,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except requests.RequestException:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
daily = data.get("daily") or {}
|
||||
max_values = daily.get("temperature_2m_max") or []
|
||||
min_values = daily.get("temperature_2m_min") or []
|
||||
if not max_values or not min_values:
|
||||
continue
|
||||
|
||||
try:
|
||||
avg = (float(max_values[0]) + float(min_values[0])) / 2
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return {
|
||||
"date": date,
|
||||
"available": True,
|
||||
"temperature_c": round(avg, 1),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"No weather data available for date=%s lat=%s lon=%s",
|
||||
date,
|
||||
latitude,
|
||||
longitude,
|
||||
return fetch_daily_temperature(
|
||||
date=date, latitude=latitude, longitude=longitude
|
||||
)
|
||||
return base_payload
|
||||
|
||||
def _cache_key(self, date: str, latitude: float, longitude: float) -> str:
|
||||
rounded_lat = round(latitude, 3)
|
||||
|
||||
Reference in New Issue
Block a user