Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ NEXTAUTH_URL=http://localhost:3000
# IMPORTANT: Generate a secure secret for production
NEXTAUTH_SECRET=change-me-in-production-use-openssl-rand-hex-32

# Approximate location from IP when browser geolocation is denied/unavailable.
# Off by default: enabling it sends the user's IP to a third-party provider.
# Build-time frontend variables (set before building the frontend image).
# NEXT_PUBLIC_ENABLE_IP_LOCATION_FALLBACK=true
# NEXT_PUBLIC_NETWORK_LOCATION_URL=https://ipapi.co/json/

# Authentication (OIDC Provider)
# ============================================================================
# Leave all empty to use development credential-based login (default)
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,15 @@ See the [k8s/](k8s/) directory for Kubernetes manifests including:
| `BG_REMOVAL_MODEL` | rembg model name (default: `u2net`) | No |
| `BG_REMOVAL_URL` | URL for HTTP bg removal provider | If http |
| `BG_REMOVAL_API_KEY` | API key for HTTP bg removal provider | No |
| `NEXT_PUBLIC_ENABLE_IP_LOCATION_FALLBACK` | Enable IP-based approximate location when browser geolocation is denied/unavailable. Off by default (sends the user's IP to a third party). Set to `true` to enable | No |
| `NEXT_PUBLIC_NETWORK_LOCATION_URL` | Override the IP geolocation provider (default: `https://ipapi.co/json/`). Only used when the fallback above is enabled | No |

See [.env.example](.env.example) for all options.

### Location Detection (Privacy Note)

The Settings page can fill in your coordinates from the browser's Geolocation API. If that is denied or unavailable, an optional fallback can approximate your location from your IP address via a third-party service (`ipapi.co` by default). This fallback is **disabled by default** because it sends the user's IP to an external provider; enable it with `NEXT_PUBLIC_ENABLE_IP_LOCATION_FALLBACK=true` and optionally point `NEXT_PUBLIC_NETWORK_LOCATION_URL` at a provider you trust. These are build-time frontend variables, so set them before building the frontend image. Geocoding a location name you type yourself still uses OpenStreetMap Nominatim regardless of this setting.

### Background Removal (Optional)

Remove image backgrounds from wardrobe items. Two backends supported:
Expand Down
36 changes: 29 additions & 7 deletions backend/app/api/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
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 +56,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 and 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.",
)

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 +109,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 and 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.",
)

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
28 changes: 22 additions & 6 deletions backend/app/services/recommendation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@
from app.services.ai_service import AIService
from app.services.item_scorer import get_season, score_items
from app.services.suggestion_cache import pop_suggestion, push_suggestions
from app.services.weather_service import WeatherData, WeatherService, WeatherServiceError
from app.services.weather_service import (
GeocodingServiceError,
WeatherData,
WeatherService,
WeatherServiceError,
)
from app.utils.clothing import deduplicate_by_body_slot
from app.utils.prompts import load_prompt
from app.utils.timezone import get_user_today
Expand Down Expand Up @@ -629,16 +634,27 @@ async def generate_recommendation(
exclude_items = list(set(exclude_items) | rejected_ids)
logger.info(f"Auto-excluding {len(rejected_ids)} rejected items for user {user.id}")

# Get weather
if weather_override:
weather = weather_override
else:
if user.location_lat is None or user.location_lon is None:
lat = float(user.location_lat) if user.location_lat is not None else None
lon = float(user.location_lon) if user.location_lon is not None else None

if (lat is None and lon is None) and user.location_name:
try:
geocoded = await self.weather_service.geocode_location_name(user.location_name)
except GeocodingServiceError as e:
logger.error(f"Geocoding failed for outfit generation: {e}")
raise ValueError(
"Could not resolve location. Please update your location in settings."
) from e
if geocoded:
lat, lon, _ = geocoded

if lat is None or lon is None:
raise ValueError("User location not set. Please set location in settings.")
try:
weather = await self.weather_service.get_current_weather(
float(user.location_lat), float(user.location_lon)
)
weather = await self.weather_service.get_current_weather(lat, lon)
except WeatherServiceError as e:
logger.error(f"Weather service failed: {e}")
raise ValueError(
Expand Down
112 changes: 112 additions & 0 deletions backend/app/services/weather_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,119 @@ class DailyForecast:
CACHE_TTL = 3600 # 1 hour
CACHE_PREFIX = "weather:"

GEOCODE_CACHE_TTL = 60 * 60 * 24 * 30 # 30 days; geocoding a place name rarely changes
GEOCODE_NEGATIVE_CACHE_TTL = 3600 # 1 hour for not-found results; avoids hammering Nominatim
GEOCODE_CACHE_PREFIX = "geocode:"


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:
query = location_name.strip()
if not query:
return None

cached = await self._geocode_cache_get(query)
if cached is not None:
if not cached:
return None
return cached

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 or not isinstance(data, list):
await self._geocode_cache_set_miss(query)
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")
result = (latitude, longitude, display_name)
await self._geocode_cache_set(query, result)
return result

@staticmethod
def _geocode_cache_key(query: str) -> str:
return f"{GEOCODE_CACHE_PREFIX}{query.strip().lower()}"

async def _geocode_cache_get(
self, query: str
) -> tuple[float, float, str | None] | tuple[()] | None:
try:
redis = await get_redis()
raw = await redis.get(self._geocode_cache_key(query))
except aioredis.RedisError:
logger.debug(f"Redis unavailable for geocode cache read ({query!r})")
return None
if raw is None:
return None
try:
data = json.loads(raw)
except ValueError:
logger.debug(f"Geocode cache value for {query!r} could not be decoded; discarding")
return None
if data is None:
return ()
return data["lat"], data["lon"], data.get("display_name")

async def _geocode_cache_set_miss(self, query: str) -> None:
try:
redis = await get_redis()
await redis.set(
self._geocode_cache_key(query),
json.dumps(None),
ex=GEOCODE_NEGATIVE_CACHE_TTL,
)
except aioredis.RedisError:
logger.debug(f"Redis unavailable for geocode cache write ({query!r})")

async def _geocode_cache_set(self, query: str, result: tuple[float, float, str | None]) -> None:
lat, lon, display_name = result
try:
redis = await get_redis()
await redis.set(
self._geocode_cache_key(query),
json.dumps({"lat": lat, "lon": lon, "display_name": display_name}),
ex=GEOCODE_CACHE_TTL,
)
except aioredis.RedisError:
logger.debug(f"Redis unavailable for geocode cache write ({query!r})")

@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 +457,7 @@ async def check_health(self) -> dict:

class WeatherServiceError(Exception):
pass


class GeocodingServiceError(WeatherServiceError):
pass
Loading
Loading