This commit is contained in:
alex wiesner
2026-03-13 20:15:22 +00:00
parent e68c95b2dd
commit c4d39f2812
33 changed files with 2383 additions and 162 deletions

View File

@@ -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

View File

@@ -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(

View 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

View File

@@ -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)