- CRIT-1: Change weather route from /weather to / (was doubling prefix) - CRIT-2: Use run_in_executor for urllib calls + parallel fetch - WARN-1: Invalidate weather cache when city changes - WARN-2: Sanitize error messages to prevent API key leakage - SUG-2: Only enable weather query when city is configured - SUG-4: Remove duplicate Bell import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
87 lines
3.0 KiB
Python
87 lines
3.0 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
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("/")
|
|
async def get_weather(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: Settings = Depends(get_current_session)
|
|
):
|
|
# Get city from settings
|
|
result = await db.execute(select(Settings))
|
|
settings_row = result.scalar_one_or_none()
|
|
city = settings_row.weather_city if settings_row else None
|
|
if not city:
|
|
raise HTTPException(status_code=400, detail="No weather city configured")
|
|
|
|
# Check cache (also invalidate if city changed)
|
|
now = datetime.now()
|
|
if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("city") == city:
|
|
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()
|
|
|
|
# Current weather + forecast in parallel via thread pool
|
|
current_url = f"https://api.openweathermap.org/data/2.5/weather?q={urllib.parse.quote(city)}&units=metric&appid={api_key}"
|
|
forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?q={urllib.parse.quote(city)}&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["city"] = city
|
|
|
|
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")
|