Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions backend/app/api/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@

from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field

from app.models.user import User
from app.services.weather_service import WeatherService, WeatherServiceError
from app.services.weather_service import GeocodingServiceError, WeatherService, WeatherServiceError
from app.utils.auth import get_current_user

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/weather", tags=["Weather"])

GEOCODING_FAILURE_DETAIL = (
"Unable to geocode saved location name. Please try again later or update your location in settings."
)


class WeatherResponse(BaseModel):
temperature: float = Field(description="Temperature in Celsius")
Expand Down Expand Up @@ -54,18 +57,28 @@ async def get_current_weather(
latitude: float | None = Query(None, ge=-90, le=90),
longitude: float | None = Query(None, ge=-180, le=180),
) -> WeatherResponse:
# Use provided coordinates or fall back to user's location
weather_service = WeatherService()
lat = latitude if latitude is not None else current_user.location_lat
lon = longitude if longitude is not None else current_user.location_lon

if (lat is None or lon is None) and current_user.location_name:
try:
geocoded = await weather_service.geocode_location_name(current_user.location_name)
except GeocodingServiceError as e:
logger.error(f"Geocoding service error: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=GEOCODING_FAILURE_DETAIL,
) from None
if geocoded:
lat, lon, _ = geocoded

if lat is None or lon is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Location not set. Please provide coordinates or set your location in settings.",
)
Comment thread
gzhang33 marked this conversation as resolved.

weather_service = WeatherService()

try:
weather = await weather_service.get_current_weather(float(lat), float(lon))
except WeatherServiceError as e:
Expand Down Expand Up @@ -97,18 +110,28 @@ async def get_weather_forecast(
longitude: float | None = Query(None, ge=-180, le=180),
days: int = Query(7, ge=1, le=16),
) -> ForecastResponse:
# Use provided coordinates or fall back to user's location
weather_service = WeatherService()
lat = latitude if latitude is not None else current_user.location_lat
lon = longitude if longitude is not None else current_user.location_lon

if (lat is None or lon is None) and current_user.location_name:
try:
geocoded = await weather_service.geocode_location_name(current_user.location_name)
except GeocodingServiceError as e:
logger.error(f"Geocoding service error: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=GEOCODING_FAILURE_DETAIL,
) from None
if geocoded:
lat, lon, _ = geocoded

if lat is None or lon is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Location not set. Please provide coordinates or set your location in settings.",
)
Comment thread
gzhang33 marked this conversation as resolved.

weather_service = WeatherService()

try:
forecast = await weather_service.get_daily_forecast(float(lat), float(lon), days)
except WeatherServiceError as e:
Expand Down
4 changes: 4 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class Settings(BaseSettings):

# Weather
openmeteo_url: str = Field(default="https://api.open-meteo.com/v1")
geocoding_user_agent: str | None = Field(default=None)

# Notifications - default ntfy channel (used when user has none configured)
ntfy_server: str | None = None
Expand Down Expand Up @@ -110,6 +111,9 @@ def get_auth_mode(self) -> str:
return "oidc"
return "unknown"

def get_geocoding_user_agent(self) -> str:
return self.geocoding_user_agent or "Wardrowbe/1.0"


@lru_cache
def get_settings() -> Settings:
Expand Down
47 changes: 47 additions & 0 deletions backend/app/services/weather_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,49 @@ class WeatherService:
def __init__(self):
self.base_url = settings.openmeteo_url

async def geocode_location_name(self, location_name: str) -> tuple[float, float, str | None] | None:
"""Resolve a free-form location name to coordinates using Nominatim."""
query = location_name.strip()
if not query:
return None

params = {
"q": query,
"format": "jsonv2",
"limit": 1,
}

headers = {
"User-Agent": settings.get_geocoding_user_agent(),
}
Comment thread
gzhang33 marked this conversation as resolved.

async with httpx.AsyncClient(timeout=10.0, follow_redirects=True, headers=headers) as client:
try:
response = await client.get("https://nominatim.openstreetmap.org/search", params=params)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as e:
logger.error(f"Geocoding error for {query!r}: {e}")
raise GeocodingServiceError(f"Failed to geocode location {query!r}: {e}") from None
except ValueError as e:
logger.error(f"Geocoding returned invalid JSON for {query!r}: {e}")
raise GeocodingServiceError(
f"Failed to decode geocoding response for location {query!r}: {e}"
) from None

if not data:
return None

first = data[0]
try:
latitude = float(first["lat"])
longitude = float(first["lon"])
except (KeyError, TypeError, ValueError):
return None

display_name = first.get("display_name")
return latitude, longitude, display_name

@staticmethod
def _cache_key(lat: float, lon: float) -> str:
return f"{CACHE_PREFIX}{round(lat, 2)},{round(lon, 2)}"
Expand Down Expand Up @@ -349,3 +392,7 @@ async def check_health(self) -> dict:

class WeatherServiceError(Exception):
pass


class GeocodingServiceError(WeatherServiceError):
pass
106 changes: 106 additions & 0 deletions backend/tests/test_weather_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from datetime import datetime
from unittest.mock import AsyncMock, patch

import pytest
from httpx import AsyncClient

from app.api.weather import GEOCODING_FAILURE_DETAIL
from app.services.weather_service import GeocodingServiceError


class TestWeatherApi:
@pytest.mark.asyncio
async def test_current_weather_uses_location_name_without_persisting_coordinates(
self, client: AsyncClient, test_user, auth_headers, db_session
):
test_user.location_name = "New York City"
test_user.location_lat = None
test_user.location_lon = None
await db_session.commit()

geocode_mock = AsyncMock(return_value=(40.7128, -74.0060, "New York City"))
weather_mock = AsyncMock(
return_value=type(
"Weather",
(),
{
"temperature": 12.5,
"feels_like": 7.3,
"humidity": 50,
"precipitation_chance": 10,
"precipitation_mm": 0.0,
"wind_speed": 23.4,
"condition": "partly cloudy",
"condition_code": 2,
"is_day": True,
"uv_index": 1.8,
"timestamp": datetime(2026, 5, 12, 15, 21, 35),
},
)()
)

with (
patch("app.api.weather.WeatherService.geocode_location_name", geocode_mock),
patch("app.api.weather.WeatherService.get_current_weather", weather_mock),
):
response = await client.get("/api/v1/weather/current", headers=auth_headers)

assert response.status_code == 200
geocode_mock.assert_awaited_once_with("New York City")
weather_mock.assert_awaited_once_with(40.7128, -74.0060)

await db_session.refresh(test_user)
assert test_user.location_lat is None
assert test_user.location_lon is None

@pytest.mark.asyncio
async def test_forecast_returns_400_when_location_missing(
self, client: AsyncClient, test_user, auth_headers, db_session
):
test_user.location_name = None
test_user.location_lat = None
test_user.location_lon = None
await db_session.commit()

response = await client.get("/api/v1/weather/forecast", headers=auth_headers)

assert response.status_code == 400
assert response.json()["detail"] == (
"Location not set. Please provide coordinates or set your location in settings."
)

@pytest.mark.asyncio
async def test_current_weather_returns_503_when_saved_location_geocoding_fails(
self, client: AsyncClient, test_user, auth_headers, db_session
):
test_user.location_name = "New York City"
test_user.location_lat = None
test_user.location_lon = None
await db_session.commit()

geocode_mock = AsyncMock(side_effect=GeocodingServiceError("geocoder unavailable"))

with patch("app.api.weather.WeatherService.geocode_location_name", geocode_mock):
response = await client.get("/api/v1/weather/current", headers=auth_headers)

assert response.status_code == 503
assert response.json()["detail"] == GEOCODING_FAILURE_DETAIL
geocode_mock.assert_awaited_once_with("New York City")

@pytest.mark.asyncio
async def test_forecast_returns_503_when_saved_location_geocoding_fails(
self, client: AsyncClient, test_user, auth_headers, db_session
):
test_user.location_name = "New York City"
test_user.location_lat = None
test_user.location_lon = None
await db_session.commit()

geocode_mock = AsyncMock(side_effect=GeocodingServiceError("geocoder unavailable"))

with patch("app.api.weather.WeatherService.geocode_location_name", geocode_mock):
response = await client.get("/api/v1/weather/forecast", headers=auth_headers)

assert response.status_code == 503
assert response.json()["detail"] == GEOCODING_FAILURE_DETAIL
geocode_mock.assert_awaited_once_with("New York City")
14 changes: 14 additions & 0 deletions backend/tests/test_weather_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from app.services.weather_service import (
CACHE_PREFIX,
GeocodingServiceError,
WMO_CODES,
WeatherData,
WeatherService,
Expand Down Expand Up @@ -237,6 +238,19 @@ async def test_handles_missing_hourly_precipitation(self, weather_service, mock_
assert result.precipitation_chance == 0


class TestGeocodeLocationName:
@pytest.mark.asyncio
async def test_raises_on_invalid_json_response(self, weather_service):
request = httpx.Request("GET", "https://nominatim.openstreetmap.org/search")
mock_response = httpx.Response(200, text="<html>rate limited</html>", request=request)

with patch("httpx.AsyncClient.get", return_value=mock_response):
with pytest.raises(
GeocodingServiceError, match="Failed to decode geocoding response"
):
await weather_service.geocode_location_name("New York City")


class TestGetDailyForecast:
@pytest.mark.asyncio
async def test_returns_forecast_days(self, weather_service, mock_redis):
Expand Down
Loading