Replace plain-text city input with geocoding search that resolves lat/lon coordinates for accurate OpenWeatherMap queries. Users can now search, see multiple results with state/country detail, and select the exact location. - Add GET /api/weather/search endpoint (OWM Geocoding API) - Add weather_lat/weather_lon columns to settings model + migration - Use lat/lon for weather API calls when available, fall back to city name - Replace settings text input with debounced search + dropdown selector - Show selected location as chip with clear button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
129 lines
4.5 KiB
Python
129 lines
4.5 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from datetime import datetime, timedelta
|
|
import asyncio
|
|
import urllib.request
|
|
import urllib.parse
|
|
import urllib.error
|
|
import json
|
|
|
|
from app.database import get_db
|
|
from app.models.settings import Settings
|
|
from app.config import settings as app_settings
|
|
from app.routers.auth import get_current_session
|
|
|
|
router = APIRouter()
|
|
|
|
_cache: dict = {}
|
|
|
|
|
|
def _fetch_json(url: str) -> dict:
|
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
|
return json.loads(resp.read().decode())
|
|
|
|
|
|
@router.get("/search")
|
|
async def search_locations(
|
|
q: str = Query(..., min_length=1, max_length=100),
|
|
current_user: Settings = Depends(get_current_session)
|
|
):
|
|
api_key = app_settings.OPENWEATHERMAP_API_KEY
|
|
if not api_key:
|
|
raise HTTPException(status_code=500, detail="Weather API key not configured")
|
|
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
url = f"http://api.openweathermap.org/geo/1.0/direct?q={urllib.parse.quote(q)}&limit=5&appid={api_key}"
|
|
results = await loop.run_in_executor(None, _fetch_json, url)
|
|
|
|
return [
|
|
{
|
|
"name": r.get("name", ""),
|
|
"state": r.get("state", ""),
|
|
"country": r.get("country", ""),
|
|
"lat": r.get("lat"),
|
|
"lon": r.get("lon"),
|
|
}
|
|
for r in results
|
|
]
|
|
except urllib.error.URLError:
|
|
raise HTTPException(status_code=502, detail="Geocoding service unavailable")
|
|
except (KeyError, json.JSONDecodeError):
|
|
raise HTTPException(status_code=502, detail="Invalid geocoding data")
|
|
|
|
|
|
@router.get("/")
|
|
async def get_weather(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: Settings = Depends(get_current_session)
|
|
):
|
|
# Get settings
|
|
result = await db.execute(select(Settings))
|
|
settings_row = result.scalar_one_or_none()
|
|
city = settings_row.weather_city if settings_row else None
|
|
lat = settings_row.weather_lat if settings_row else None
|
|
lon = settings_row.weather_lon if settings_row else None
|
|
|
|
if not city and (lat is None or lon is None):
|
|
raise HTTPException(status_code=400, detail="No weather location configured")
|
|
|
|
# Build cache key from coordinates or city
|
|
use_coords = lat is not None and lon is not None
|
|
cache_key = f"{lat},{lon}" if use_coords else city
|
|
|
|
# Check cache
|
|
now = datetime.now()
|
|
if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key:
|
|
return _cache["data"]
|
|
|
|
api_key = app_settings.OPENWEATHERMAP_API_KEY
|
|
if not api_key:
|
|
raise HTTPException(status_code=500, detail="Weather API key not configured")
|
|
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
|
|
# Build query params based on coordinates or city name
|
|
if use_coords:
|
|
location_params = f"lat={lat}&lon={lon}"
|
|
else:
|
|
location_params = f"q={urllib.parse.quote(city)}"
|
|
|
|
current_url = f"https://api.openweathermap.org/data/2.5/weather?{location_params}&units=metric&appid={api_key}"
|
|
forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?{location_params}&units=metric&cnt=8&appid={api_key}"
|
|
|
|
current_data, forecast_data = await asyncio.gather(
|
|
loop.run_in_executor(None, _fetch_json, current_url),
|
|
loop.run_in_executor(None, _fetch_json, forecast_url),
|
|
)
|
|
|
|
rain_chance = 0
|
|
for item in forecast_data.get("list", []):
|
|
pop = item.get("pop", 0)
|
|
if pop > rain_chance:
|
|
rain_chance = pop
|
|
|
|
weather_result = {
|
|
"temp": round(current_data["main"]["temp"]),
|
|
"temp_min": round(current_data["main"]["temp_min"]),
|
|
"temp_max": round(current_data["main"]["temp_max"]),
|
|
"description": current_data["weather"][0]["description"],
|
|
"rain_chance": round(rain_chance * 100),
|
|
"sunrise": current_data["sys"]["sunrise"],
|
|
"sunset": current_data["sys"]["sunset"],
|
|
"city": current_data["name"],
|
|
}
|
|
|
|
# Cache for 1 hour
|
|
_cache["data"] = weather_result
|
|
_cache["expires_at"] = now + timedelta(hours=1)
|
|
_cache["cache_key"] = cache_key
|
|
|
|
return weather_result
|
|
|
|
except urllib.error.URLError:
|
|
raise HTTPException(status_code=502, detail="Weather service unavailable")
|
|
except (KeyError, json.JSONDecodeError):
|
|
raise HTTPException(status_code=502, detail="Invalid weather data")
|