UMBRA/backend/app/routers/weather.py
Kyle Pope 4b5649758a Address code review findings for weather coordinates feature
- CRIT-1: Add lat/lon validation ([-90,90] and [-180,180]) in Pydantic schema
- WARN-1: Replace deprecated get_event_loop() with get_running_loop()
- SUG-1: Add GeoSearchResult response model to /search endpoint
- SUG-2: Dashboard weather query enables on coordinates too, not just city
- SUG-3: Clean up debounce timer on component unmount
- SUG-4: Fix geocoding URL from HTTP to HTTPS
- SUG-5: Add fallback display when weather_city is null but coords exist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:17:41 +08:00

138 lines
4.7 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
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 = {}
class GeoSearchResult(BaseModel):
name: str
state: str
country: str
lat: float
lon: float
def _fetch_json(url: str) -> dict:
with urllib.request.urlopen(url, timeout=10) as resp:
return json.loads(resp.read().decode())
@router.get("/search", response_model=list[GeoSearchResult])
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_running_loop()
url = f"https://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_running_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")