Complete admin backend with login, where all integrations (weather, news, Home Assistant, Vikunja, Unraid, MQTT) can be configured via web UI instead of ENV variables. Two-layer config: ENV seeds DB on first start, then DB is source of truth. Auto-migration system on startup. Backend: db.py shared pool, auth.py JWT, settings_service CRUD, seed_service, admin router (protected), test_connections per integration, config.py rewrite. Frontend: react-router v6, login page, admin layout with sidebar, 8 settings pages (General, Weather, News, HA, Vikunja, Unraid, MQTT, ChangePassword), shared IntegrationForm + TestButton components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
85 lines
2.6 KiB
Python
85 lines
2.6 KiB
Python
"""Weather data router -- primary + secondary locations and hourly forecast."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any, Dict, List
|
|
|
|
from fastapi import APIRouter
|
|
|
|
from server.cache import cache
|
|
from server.config import get_settings
|
|
from server.services.weather_service import fetch_hourly_forecast, fetch_weather
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api", tags=["weather"])
|
|
|
|
CACHE_KEY = "weather"
|
|
|
|
|
|
@router.get("/weather")
|
|
async def get_weather() -> Dict[str, Any]:
|
|
"""Return weather for both configured locations plus an hourly forecast.
|
|
|
|
The response shape is::
|
|
|
|
{
|
|
"primary": { ... weather dict or error stub },
|
|
"secondary": { ... weather dict or error stub },
|
|
"hourly": [ ... forecast entries or empty list ],
|
|
}
|
|
"""
|
|
|
|
# --- cache hit? -----------------------------------------------------------
|
|
cached = await cache.get(CACHE_KEY)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
# --- cache miss -- fetch all three in parallel ----------------------------
|
|
primary_data: Dict[str, Any] = {}
|
|
secondary_data: Dict[str, Any] = {}
|
|
hourly_data: List[Dict[str, Any]] = []
|
|
|
|
results = await asyncio.gather(
|
|
_safe_fetch_weather(get_settings().weather_location),
|
|
_safe_fetch_weather(get_settings().weather_location_secondary),
|
|
_safe_fetch_hourly(get_settings().weather_location),
|
|
return_exceptions=False, # we handle errors inside the helpers
|
|
)
|
|
|
|
primary_data = results[0]
|
|
secondary_data = results[1]
|
|
hourly_data = results[2]
|
|
|
|
payload: Dict[str, Any] = {
|
|
"primary": primary_data,
|
|
"secondary": secondary_data,
|
|
"hourly": hourly_data,
|
|
}
|
|
|
|
await cache.set(CACHE_KEY, payload, get_settings().weather_cache_ttl)
|
|
return payload
|
|
|
|
|
|
# -- internal helpers ---------------------------------------------------------
|
|
|
|
async def _safe_fetch_weather(location: str) -> Dict[str, Any]:
|
|
"""Fetch weather for *location*, returning an error stub on failure."""
|
|
try:
|
|
data = await fetch_weather(location)
|
|
return data
|
|
except Exception as exc:
|
|
logger.exception("Failed to fetch weather for %s", location)
|
|
return {"error": True, "message": str(exc), "location": location}
|
|
|
|
|
|
async def _safe_fetch_hourly(location: str) -> List[Dict[str, Any]]:
|
|
"""Fetch hourly forecast for *location*, returning ``[]`` on failure."""
|
|
try:
|
|
data = await fetch_hourly_forecast(location)
|
|
return data
|
|
except Exception as exc:
|
|
logger.exception("Failed to fetch hourly forecast for %s", location)
|
|
return []
|