initial commit

This commit is contained in:
TheOnlyMace
2026-06-18 23:45:08 +02:00
commit 7ba9687619
96 changed files with 4309 additions and 0 deletions

0
api/app/__init__.py Normal file
View File

87
api/app/auth.py Normal file
View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import os
import secrets
import time
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import bcrypt
from jose import JWTError, jwt
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "")
ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "")
ADMIN_JWT_EXPIRE_HOURS = int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8"))
ADMIN_LOGIN_MAX_ATTEMPTS = int(os.getenv("ADMIN_LOGIN_MAX_ATTEMPTS", "5"))
ADMIN_LOGIN_WINDOW_SECONDS = int(os.getenv("ADMIN_LOGIN_WINDOW_SECONDS", "300"))
bearer_scheme = HTTPBearer(auto_error=False)
_login_attempts: dict[str, list[float]] = {}
def admin_configured() -> bool:
return bool(ADMIN_PASSWORD_HASH and ADMIN_JWT_SECRET and len(ADMIN_JWT_SECRET) >= 32)
def verify_password(plain_password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(plain_password.encode("utf-8"), password_hash.encode("utf-8"))
except (ValueError, TypeError):
return False
def hash_password(plain_password: str) -> str:
return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def _client_key(request: Request) -> str:
return request.client.host if request.client else "unknown"
def check_login_rate_limit(request: Request) -> None:
key = _client_key(request)
now = time.time()
attempts = [ts for ts in _login_attempts.get(key, []) if now - ts < ADMIN_LOGIN_WINDOW_SECONDS]
if len(attempts) >= ADMIN_LOGIN_MAX_ATTEMPTS:
raise HTTPException(status_code=429, detail="Too many login attempts. Try again later.")
_login_attempts[key] = attempts
def record_failed_login(request: Request) -> None:
key = _client_key(request)
_login_attempts.setdefault(key, []).append(time.time())
def create_access_token(username: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=ADMIN_JWT_EXPIRE_HOURS)
payload = {"sub": username, "exp": expire, "scope": "admin"}
return jwt.encode(payload, ADMIN_JWT_SECRET, algorithm="HS256")
def decode_access_token(token: str) -> dict[str, Any]:
return jwt.decode(token, ADMIN_JWT_SECRET, algorithms=["HS256"])
async def require_admin(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> dict[str, Any]:
if not admin_configured():
raise HTTPException(status_code=503, detail="Admin access is not configured")
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
try:
payload = decode_access_token(credentials.credentials)
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
if payload.get("sub") != ADMIN_USERNAME or payload.get("scope") != "admin":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token scope")
return payload
def generate_secret(length: int = 48) -> str:
return secrets.token_urlsafe(length)

57
api/app/cache.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import json
import logging
from typing import Any
import redis.asyncio as redis
from app.config import REDIS_URL
logger = logging.getLogger(__name__)
_client: redis.Redis | None = None
async def init_redis() -> None:
global _client
try:
_client = redis.from_url(REDIS_URL, decode_responses=True)
await _client.ping()
logger.info("Redis connected")
except Exception as exc:
logger.warning("Redis unavailable, running without Redis cache: %s", exc)
_client = None
async def close_redis() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
def redis_available() -> bool:
return _client is not None
async def cache_get(key: str) -> Any | None:
if _client is None:
return None
raw = await _client.get(key)
if raw is None:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw
async def cache_set(key: str, value: Any, ttl_seconds: int) -> None:
if _client is None:
return
await _client.set(key, json.dumps(value), ex=ttl_seconds)
async def flush_cache() -> None:
if _client is not None:
await _client.flushdb()

48
api/app/config.py Normal file
View File

@@ -0,0 +1,48 @@
import os
from pathlib import Path
APP_NAME = "HexaWetter"
APP_VERSION = "1.1.0"
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar"
RENDERED_RADAR_DIR = DATA_DIR / "processed" / "radar"
OPEN_METEO_BASE_URL = os.getenv("OPEN_METEO_BASE_URL", "https://api.open-meteo.com/v1").rstrip("/")
BRIGHTSKY_BASE_URL = os.getenv("BRIGHTSKY_BASE_URL", "https://api.brightsky.dev").rstrip("/")
DWD_RADAR_BASE_URL = os.getenv("DWD_RADAR_BASE_URL", "https://opendata.dwd.de/weather/radar/composite").rstrip("/")
DWD_WMS_URL = os.getenv("DWD_WMS_URL", "https://maps.dwd.de/geoserver/dwd/wms").rstrip("/")
DWD_WARNINGS_URL = os.getenv(
"DWD_WARNINGS_URL",
"https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json",
).rstrip("/")
NOMINATIM_BASE_URL = os.getenv("NOMINATIM_BASE_URL", "https://nominatim.openstreetmap.org").rstrip("/")
NOMINATIM_USER_AGENT = os.getenv("NOMINATIM_USER_AGENT", "HexaWetter/1.0 (selfhosted weather)")
DEFAULT_LAT = float(os.getenv("DEFAULT_LAT", "48.6546"))
DEFAULT_LON = float(os.getenv("DEFAULT_LON", "13.6252"))
DEFAULT_PLACE = os.getenv("DEFAULT_PLACE", "Hauzenberg")
RADAR_PRODUCT = os.getenv("RADAR_PRODUCT", "rv")
RADAR_RETENTION_HOURS = int(os.getenv("RADAR_RETENTION_HOURS", "48"))
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://hexawetter:hexawetter@postgres:5432/hexawetter",
)
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
CACHE_TTL_FORECAST = int(os.getenv("CACHE_TTL_FORECAST", "600"))
CACHE_TTL_OBSERVATIONS = int(os.getenv("CACHE_TTL_OBSERVATIONS", "300"))
CACHE_TTL_WARNINGS = int(os.getenv("CACHE_TTL_WARNINGS", "120"))
CACHE_TTL_GEOCODE = int(os.getenv("CACHE_TTL_GEOCODE", "86400"))
CACHE_TTL_WMS_TIMES = int(os.getenv("CACHE_TTL_WMS_TIMES", "120"))
CACHE_TTL_HEALTH_SOURCES = int(os.getenv("CACHE_TTL_HEALTH_SOURCES", "90"))
CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60"))
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120"))
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "")
ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "")
ADMIN_JWT_EXPIRE_HOURS = int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8"))

213
api/app/database.py Normal file
View File

@@ -0,0 +1,213 @@
from __future__ import annotations
import json
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
import asyncpg
from app.config import DATABASE_URL
logger = logging.getLogger(__name__)
_pool: asyncpg.Pool | None = None
async def init_db() -> None:
global _pool
try:
_pool = await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=8, command_timeout=30)
async with _pool.acquire() as conn:
await conn.execute("SELECT 1")
logger.info("PostgreSQL connected")
except Exception as exc:
logger.warning("PostgreSQL unavailable, running without DB cache: %s", exc)
_pool = None
async def close_db() -> None:
global _pool
if _pool is not None:
await _pool.close()
_pool = None
def pool_available() -> bool:
return _pool is not None
@asynccontextmanager
async def acquire():
if _pool is None:
yield None
return
async with _pool.acquire() as conn:
yield conn
async def get_cached_json(table: str, key_cols: dict[str, Any], max_age_seconds: int) -> Any | None:
async with acquire() as conn:
if conn is None:
return None
if table == "forecast_cache":
row = await conn.fetchrow(
"""
SELECT data, fetched_at FROM forecast_cache
WHERE lat = $1 AND lon = $2
AND fetched_at > NOW() - ($3 || ' seconds')::interval
""",
key_cols["lat"],
key_cols["lon"],
str(max_age_seconds),
)
elif table == "observations_cache":
row = await conn.fetchrow(
"""
SELECT data, fetched_at FROM observations_cache
WHERE lat = $1 AND lon = $2
AND fetched_at > NOW() - ($3 || ' seconds')::interval
""",
key_cols["lat"],
key_cols["lon"],
str(max_age_seconds),
)
elif table == "geocode_cache":
row = await conn.fetchrow(
"""
SELECT results, fetched_at FROM geocode_cache
WHERE query_hash = $1
AND fetched_at > NOW() - ($2 || ' seconds')::interval
""",
key_cols["query_hash"],
str(max_age_seconds),
)
else:
return None
if not row:
return None
data = row["data"] if "data" in row else row["results"]
return json.loads(data) if isinstance(data, str) else data
async def set_cached_json(table: str, key_cols: dict[str, Any], payload: Any) -> None:
async with acquire() as conn:
if conn is None:
return
payload_json = json.dumps(payload)
if table == "forecast_cache":
await conn.execute(
"""
INSERT INTO forecast_cache (lat, lon, data, fetched_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (lat, lon) DO UPDATE
SET data = EXCLUDED.data, fetched_at = NOW()
""",
key_cols["lat"],
key_cols["lon"],
payload_json,
)
elif table == "observations_cache":
await conn.execute(
"""
INSERT INTO observations_cache (lat, lon, data, fetched_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (lat, lon) DO UPDATE
SET data = EXCLUDED.data, fetched_at = NOW()
""",
key_cols["lat"],
key_cols["lon"],
payload_json,
)
elif table == "geocode_cache":
await conn.execute(
"""
INSERT INTO geocode_cache (query_hash, query_text, results, fetched_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (query_hash) DO UPDATE
SET results = EXCLUDED.results, fetched_at = NOW(), query_text = EXCLUDED.query_text
""",
key_cols["query_hash"],
key_cols.get("query_text", ""),
payload_json,
)
elif table == "warnings_cache":
await conn.execute(
"INSERT INTO warnings_cache (data, fetched_at) VALUES ($1::jsonb, NOW())",
payload_json,
)
async def upsert_radar_meta(
product: str,
filename: str,
file_size: int | None,
modified_at: datetime | None,
render_status: str = "pending",
render_path: str | None = None,
render_bounds: dict | None = None,
render_error: str | None = None,
) -> None:
async with acquire() as conn:
if conn is None:
return
await conn.execute(
"""
INSERT INTO radar_file_meta (
product, filename, file_size, modified_at, render_status,
render_path, render_bounds, render_error, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, NOW())
ON CONFLICT (filename) DO UPDATE SET
file_size = EXCLUDED.file_size,
modified_at = EXCLUDED.modified_at,
render_status = EXCLUDED.render_status,
render_path = EXCLUDED.render_path,
render_bounds = EXCLUDED.render_bounds,
render_error = EXCLUDED.render_error,
updated_at = NOW()
""",
product,
filename,
file_size,
modified_at,
render_status,
render_path,
json.dumps(render_bounds) if render_bounds else None,
render_error,
)
async def list_rendered_radar(product: str, limit: int = 24) -> list[dict[str, Any]]:
async with acquire() as conn:
if conn is None:
return []
rows = await conn.fetch(
"""
SELECT filename, render_path, render_bounds, modified_at, updated_at
FROM radar_file_meta
WHERE product = $1 AND render_status = 'done' AND render_path IS NOT NULL
ORDER BY modified_at DESC NULLS LAST
LIMIT $2
""",
product,
limit,
)
result = []
for row in rows:
bounds = row["render_bounds"]
if isinstance(bounds, str):
bounds = json.loads(bounds)
result.append(
{
"filename": row["filename"],
"image_url": f"/api/radar/rendered/{product}/{row['filename']}.png",
"bounds": bounds,
"modified_at": row["modified_at"].isoformat() if row["modified_at"] else None,
"rendered_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
)
return result
def utc_now() -> datetime:
return datetime.now(timezone.utc)

41
api/app/http_client.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import httpx
_client: httpx.AsyncClient | None = None
async def init_http_client() -> None:
global _client
_client = httpx.AsyncClient(
timeout=httpx.Timeout(20.0, connect=5.0),
follow_redirects=True,
limits=httpx.Limits(max_connections=40, max_keepalive_connections=20),
)
async def close_http_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
def get_client() -> httpx.AsyncClient:
if _client is None:
raise RuntimeError("HTTP client not initialized")
return _client
async def fetch_json(url: str, params: dict | None = None, timeout: float | None = None) -> dict:
client = get_client()
response = await client.get(url, params=params, timeout=timeout)
response.raise_for_status()
return response.json()
async def fetch_text(url: str, timeout: float | None = None) -> str:
client = get_client()
response = await client.get(url, timeout=timeout)
response.raise_for_status()
return response.text

404
api/app/main.py Normal file
View File

@@ -0,0 +1,404 @@
from __future__ import annotations
import logging
import os
import re
import time
from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
from app.auth import admin_configured
from app.cache import close_redis, init_redis, redis_available
from app.config import (
APP_NAME,
APP_VERSION,
BRIGHTSKY_BASE_URL,
DEFAULT_LAT,
DEFAULT_LON,
DEFAULT_PLACE,
DWD_RADAR_BASE_URL,
DWD_WMS_URL,
OPEN_METEO_BASE_URL,
RADAR_PRODUCT,
RADAR_RETENTION_HOURS,
RATE_LIMIT_PER_MINUTE,
RENDERED_RADAR_DIR,
RAW_RADAR_DIR,
)
from app.database import (
close_db,
init_db,
list_rendered_radar,
pool_available,
upsert_radar_meta,
)
from app.http_client import close_http_client, get_client, init_http_client
from app.services.dashboard import build_dashboard
from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index
from app.services.geocoding import reverse_geocode, search_places
from app.services.health import check_sources
from app.routes.admin import router as admin_router
from app.services.warnings import fetch_warnings_for_location
from app.services.weather import get_forecast, get_observations
from app.services.wms import fetch_wms_time_steps
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger(__name__)
_rate_buckets: dict[str, list[float]] = defaultdict(list)
class Location(BaseModel):
lat: float = DEFAULT_LAT
lon: float = DEFAULT_LON
place: str = DEFAULT_PLACE
class FavoriteLocation(BaseModel):
place: str
lat: float = Field(ge=-90, le=90)
lon: float = Field(ge=-180, le=180)
@asynccontextmanager
async def lifespan(_: FastAPI):
await init_http_client()
await init_db()
await init_redis()
RENDERED_RADAR_DIR.mkdir(parents=True, exist_ok=True)
RAW_RADAR_DIR.mkdir(parents=True, exist_ok=True)
yield
await close_redis()
await close_db()
await close_http_client()
app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan)
app.include_router(admin_router)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(self)"
return response
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
if not request.url.path.startswith("/api/"):
return await call_next(request)
client_ip = request.client.host if request.client else "unknown"
now = time.time()
limit = 20 if request.url.path == "/api/admin/login" else RATE_LIMIT_PER_MINUTE
bucket = _rate_buckets[client_ip]
_rate_buckets[client_ip] = [ts for ts in bucket if now - ts < 60]
if len(_rate_buckets[client_ip]) >= limit:
return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"})
_rate_buckets[client_ip].append(now)
return await call_next(request)
def utc_now() -> datetime:
return datetime.now(timezone.utc)
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {
"status": "ok",
"service": APP_NAME,
"version": APP_VERSION,
"time_utc": utc_now().isoformat(),
"admin_enabled": admin_configured(),
"postgres": pool_available(),
"redis": redis_available(),
}
@app.get("/api/health/sources")
async def health_sources() -> dict[str, Any]:
sources = await check_sources()
degraded = [s for s in sources if s.get("status") not in ("ok", "disabled")]
return {
"status": "degraded" if degraded else "ok",
"time_utc": utc_now().isoformat(),
"sources": sources,
}
@app.get("/api/dashboard")
async def dashboard(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
local_only: bool = Query(True),
) -> dict[str, Any]:
try:
return await build_dashboard(lat, lon, local_only=local_only)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Dashboard unavailable: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Dashboard error: {exc}") from exc
def cleanup_old_radar_files() -> None:
cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS)
if not RAW_RADAR_DIR.exists():
return
for path in RAW_RADAR_DIR.rglob("*"):
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
continue
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
if modified < cutoff:
path.unlink(missing_ok=True)
@app.get("/api/location/default")
async def default_location() -> Location:
return Location()
@app.get("/api/geocode/search")
async def geocode_search(q: str = Query(..., min_length=2), limit: int = Query(8, ge=1, le=15)) -> dict[str, Any]:
try:
results = await search_places(q, limit=limit)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Geocoding unavailable: {exc}") from exc
return {"query": q, "count": len(results), "results": results}
@app.get("/api/geocode/reverse")
async def geocode_reverse(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
) -> dict[str, Any]:
try:
result = await reverse_geocode(lat, lon)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Reverse geocoding unavailable: {exc}") from exc
return {"lat": lat, "lon": lon, "result": result}
@app.get("/api/forecast")
async def forecast(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
) -> dict[str, Any]:
try:
return await get_forecast(lat, lon)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Forecast source unavailable: {exc}") from exc
@app.get("/api/observations")
async def observations(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
) -> dict[str, Any]:
try:
return await get_observations(lat, lon)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Observation source unavailable: {exc}") from exc
@app.get("/api/warnings")
async def warnings(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
local_only: bool = Query(True),
) -> dict[str, Any]:
try:
return await fetch_warnings_for_location(lat, lon, local_only=local_only)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Warnings unavailable: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Warnings parse error: {exc}") from exc
@app.get("/api/radar/latest")
async def radar_latest(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(12, ge=1, le=100),
) -> dict[str, Any]:
try:
return await fetch_radar_index(product=product, limit=limit)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"DWD radar index unavailable: {exc}") from exc
@app.post("/api/radar/sync")
async def radar_sync(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(6, ge=1, le=50),
) -> dict[str, Any]:
latest = await radar_latest(product=product, limit=limit)
target_dir = RAW_RADAR_DIR / "composite" / product
target_dir.mkdir(parents=True, exist_ok=True)
downloaded = []
skipped = []
client = get_client()
for item in latest["files"]:
out = target_dir / item["name"]
if out.exists() and out.stat().st_size > 0:
skipped.append(item["name"])
continue
response = await client.get(item["url"], timeout=90.0)
response.raise_for_status()
out.write_bytes(response.content)
downloaded.append(item["name"])
modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc)
await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending")
cleanup_old_radar_files()
return {"product": product, "target_dir": str(target_dir), "downloaded": downloaded, "skipped": skipped}
@app.get("/api/radar/files")
async def radar_files(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
) -> dict[str, Any]:
target_dir = RAW_RADAR_DIR / "composite" / product
target_dir.mkdir(parents=True, exist_ok=True)
files = []
for path in sorted(target_dir.iterdir()):
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
continue
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
files.append(
{
"name": path.name,
"size_bytes": path.stat().st_size,
"modified_utc": modified.isoformat(),
"download_url": f"/api/radar/files/{product}/{path.name}",
}
)
return {"product": product, "path": str(target_dir), "count": len(files), "files": files}
@app.get("/api/radar/files/{product}/{filename}")
async def radar_file_download(product: str, filename: str):
if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Invalid product")
if not RADAR_FILE_RE.fullmatch(filename):
raise HTTPException(status_code=400, detail="Invalid filename")
path = RAW_RADAR_DIR / "composite" / product / filename
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream"
return FileResponse(path, filename=filename, media_type=media_type)
@app.get("/api/radar/rendered")
async def radar_rendered_list(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(24, ge=1, le=100),
) -> dict[str, Any]:
frames = await list_rendered_radar(product, limit=limit)
return {"product": product, "count": len(frames), "frames": frames}
@app.get("/api/radar/rendered/{product}/{filename}.png")
async def radar_rendered_image(product: str, filename: str):
if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Invalid filename")
path = RENDERED_RADAR_DIR / product / f"{filename}.png"
if not path.exists():
raise HTTPException(status_code=404, detail="Rendered image not found")
return FileResponse(path, media_type="image/png")
@app.get("/api/radar/wms")
async def radar_wms() -> dict[str, Any]:
return {
"service_url": DWD_WMS_URL,
"attribution": "Radar/Warnungen: Deutscher Wetterdienst (DWD)",
"default_layer": "dwd:Niederschlagsradar",
"layers": [
{
"id": "niederschlagsradar",
"title": "Niederschlagsradar",
"layer": "dwd:Niederschlagsradar",
"opacity": 0.75,
"enabled": True,
"animated": True,
},
{
"id": "warnungen_gemeinden",
"title": "DWD Warnungen (Gemeinden)",
"layer": "dwd:Warnungen_Gemeinden_vereinigt",
"opacity": 0.6,
"enabled": True,
"animated": False,
},
{
"id": "radolan_ry",
"title": "RADOLAN RY",
"layer": "dwd:RADOLAN-RY",
"opacity": 0.72,
"enabled": False,
"animated": False,
},
{
"id": "radar_wn",
"title": "Radar WN Reflektivität",
"layer": "dwd:Radar_wn-product_1x1km_ger",
"opacity": 0.68,
"enabled": False,
"animated": True,
},
{
"id": "warnungen_landkreise",
"title": "DWD Warnungen Landkreise",
"layer": "dwd:Warnungen_Landkreise",
"opacity": 0.55,
"enabled": False,
"animated": False,
},
],
}
@app.get("/api/radar/wms/times")
async def radar_wms_times(
layer: str = Query("dwd:Niederschlagsradar"),
minutes: int = Query(120, ge=15, le=180),
) -> dict[str, Any]:
try:
return await fetch_wms_time_steps(layer, minutes=minutes)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc
@app.get("/api/sources")
async def sources() -> dict[str, Any]:
return {
"note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.",
"open_meteo_base_url": OPEN_METEO_BASE_URL,
"brightsky_base_url": BRIGHTSKY_BASE_URL,
"dwd_radar_base_url": DWD_RADAR_BASE_URL,
"dwd_wms_url": DWD_WMS_URL,
"dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.",
"osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.",
}

View File

154
api/app/routes/admin.py Normal file
View File

@@ -0,0 +1,154 @@
from __future__ import annotations
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.auth import (
ADMIN_USERNAME,
admin_configured,
check_login_rate_limit,
create_access_token,
record_failed_login,
require_admin,
verify_password,
)
from app.cache import cache_get, cache_set, redis_available
from app.config import (
APP_NAME,
APP_VERSION,
BRIGHTSKY_BASE_URL,
CACHE_TTL_FORECAST,
CACHE_TTL_GEOCODE,
CACHE_TTL_OBSERVATIONS,
CACHE_TTL_WARNINGS,
CACHE_TTL_WMS_TIMES,
DATA_DIR,
DWD_RADAR_BASE_URL,
DWD_WMS_URL,
OPEN_METEO_BASE_URL,
RADAR_PRODUCT,
RADAR_RETENTION_HOURS,
RATE_LIMIT_PER_MINUTE,
RAW_RADAR_DIR,
RENDERED_RADAR_DIR,
)
from app.database import pool_available
from app.services.health import check_sources
router = APIRouter(prefix="/api/admin", tags=["admin"])
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=8, max_length=128)
class SettingsUpdate(BaseModel):
rate_limit_per_minute: int | None = Field(default=None, ge=10, le=1000)
cache_ttl_forecast: int | None = Field(default=None, ge=60, le=86400)
cache_ttl_observations: int | None = Field(default=None, ge=60, le=86400)
cache_ttl_warnings: int | None = Field(default=None, ge=30, le=3600)
radar_retention_hours: int | None = Field(default=None, ge=6, le=168)
def _dir_stats(path: Path) -> dict[str, Any]:
if not path.exists():
return {"path": str(path), "exists": False, "files": 0, "bytes": 0}
files = [p for p in path.rglob("*") if p.is_file()]
total = sum(p.stat().st_size for p in files)
return {"path": str(path), "exists": True, "files": len(files), "bytes": total}
def _public_config() -> dict[str, Any]:
return {
"app_name": APP_NAME,
"version": APP_VERSION,
"open_meteo_base_url": OPEN_METEO_BASE_URL,
"brightsky_base_url": BRIGHTSKY_BASE_URL,
"dwd_radar_base_url": DWD_RADAR_BASE_URL,
"dwd_wms_url": DWD_WMS_URL,
"radar_product": RADAR_PRODUCT,
"radar_retention_hours": RADAR_RETENTION_HOURS,
"rate_limit_per_minute": RATE_LIMIT_PER_MINUTE,
"cache_ttl": {
"forecast": CACHE_TTL_FORECAST,
"observations": CACHE_TTL_OBSERVATIONS,
"warnings": CACHE_TTL_WARNINGS,
"geocode": CACHE_TTL_GEOCODE,
"wms_times": CACHE_TTL_WMS_TIMES,
},
}
@router.get("/status")
async def admin_status_public() -> dict[str, Any]:
return {"admin_enabled": admin_configured(), "version": APP_VERSION}
@router.post("/login")
async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any]:
if not admin_configured():
raise HTTPException(status_code=503, detail="Admin login is not configured on this instance")
check_login_rate_limit(request)
if payload.username != ADMIN_USERNAME or not verify_password(payload.password, os.getenv("ADMIN_PASSWORD_HASH", "")):
record_failed_login(request)
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(payload.username)
return {
"access_token": token,
"token_type": "bearer",
"expires_in_hours": int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8")),
}
@router.get("/dashboard")
async def admin_dashboard(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
sources = await check_sources()
return {
"time_utc": datetime.now(timezone.utc).isoformat(),
"services": sources,
"infrastructure": {
"postgres": "ok" if pool_available() else "disabled",
"redis": "ok" if redis_available() else "disabled",
},
"storage": {
"data_dir": _dir_stats(DATA_DIR),
"radar_raw": _dir_stats(RAW_RADAR_DIR),
"radar_rendered": _dir_stats(RENDERED_RADAR_DIR),
},
"config": _public_config(),
}
@router.post("/cache/clear")
async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
from app.cache import flush_cache
if not redis_available():
raise HTTPException(status_code=503, detail="Redis cache is not available")
await flush_cache()
return {"status": "ok", "message": "Redis cache cleared"}
@router.get("/settings")
async def admin_get_settings(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
runtime = await cache_get("admin:runtime_settings") or {}
return {"defaults": _public_config(), "runtime_overrides": runtime}
@router.put("/settings")
async def admin_update_settings(payload: SettingsUpdate, _: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
from app.cache import cache_set
current = await cache_get("admin:runtime_settings") or {}
updates = payload.model_dump(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="No settings provided")
current.update(updates)
await cache_set("admin:runtime_settings", current, ttl_seconds=86400 * 30)
return {"status": "ok", "runtime_overrides": current, "note": "Overrides are stored in Redis. Service restart applies full .env changes."}

View File

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from app.cache import cache_get, cache_set
from app.config import CACHE_TTL_DASHBOARD, CACHE_TTL_OBSERVATIONS, CACHE_TTL_WARNINGS
from app.services.dwd_radar import fetch_radar_index
from app.services.warnings import fetch_warnings_for_location
from app.services.weather import get_forecast, get_observations
logger = logging.getLogger(__name__)
async def get_radar_summary() -> dict[str, Any]:
redis_key = "radar:summary"
cached = await cache_get(redis_key)
if cached:
return {**cached, "cached": True}
index = await fetch_radar_index(limit=12)
payload = {
"source": index["source"],
"product": index["product"],
"count": index["count"],
"cached": False,
}
await cache_set(redis_key, payload, 120)
return payload
async def build_dashboard(lat: float, lon: float, local_only: bool = True) -> dict[str, Any]:
redis_key = f"dashboard:{round(lat, 4)}:{round(lon, 4)}:{int(local_only)}"
cached = await cache_get(redis_key)
if cached:
return {**cached, "bundle_cached": True}
results = await asyncio.gather(
get_forecast(lat, lon),
get_observations(lat, lon),
fetch_warnings_for_location(lat, lon, local_only=local_only),
get_radar_summary(),
return_exceptions=True,
)
payload: dict[str, Any] = {
"lat": lat,
"lon": lon,
"bundle_cached": False,
"errors": {},
}
names = ("forecast", "observations", "warnings", "radar")
for name, result in zip(names, results):
if isinstance(result, Exception):
logger.warning("dashboard %s failed: %s", name, result)
payload["errors"][name] = str(result)
payload[name] = None
else:
payload[name] = result
ttl = min(CACHE_TTL_DASHBOARD, CACHE_TTL_OBSERVATIONS, CACHE_TTL_WARNINGS)
if not payload["errors"]:
await cache_set(redis_key, payload, ttl)
return payload

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import re
from typing import Any
from urllib.parse import urljoin
from app.config import DWD_RADAR_BASE_URL, RADAR_PRODUCT
from app.http_client import fetch_text
RADAR_FILE_RE = re.compile(r"^[A-Za-z0-9_.-]+\.(?:tar|tar\.bz2|bz2|gz)$", re.IGNORECASE)
def parse_apache_index(html: str, suffixes: tuple[str, ...] | None = None) -> list[dict[str, Any]]:
files: list[dict[str, Any]] = []
pattern = re.compile(
r'href="(?P<href>[^"]+)"[^>]*>[^<]+</a>\s*'
r'(?P<date>\d{2}-[A-Za-z]{3}-\d{4})\s+(?P<time>\d{2}:\d{2}(?::\d{2})?)\s+(?P<size>[0-9\-]+)?',
re.IGNORECASE,
)
for match in pattern.finditer(html):
href = match.group("href")
if href.startswith("../") or href.endswith("/"):
continue
if suffixes and not href.lower().endswith(tuple(s.lower() for s in suffixes)):
continue
files.append(
{
"name": href,
"modified": f"{match.group('date')} {match.group('time')}",
"size_bytes": None if match.group("size") in (None, "-") else int(match.group("size")),
}
)
return files
async def fetch_radar_index(product: str = RADAR_PRODUCT, limit: int = 12) -> dict[str, Any]:
base_url = f"{DWD_RADAR_BASE_URL}/{product}/"
html = await fetch_text(base_url, timeout=12.0)
files = parse_apache_index(html, suffixes=(".tar", ".tar.bz2", ".bz2", ".gz"))
files = files[-limit:]
for item in files:
item["url"] = urljoin(base_url, item["name"])
return {"source": base_url, "product": product, "count": len(files), "files": files}

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
import hashlib
from typing import Any
from app.config import CACHE_TTL_GEOCODE, NOMINATIM_BASE_URL, NOMINATIM_USER_AGENT
from app.http_client import get_client
from app.database import get_cached_json, set_cached_json
def _query_hash(query: str) -> str:
return hashlib.sha256(query.strip().lower().encode("utf-8")).hexdigest()
async def _nominatim_get(path: str, params: dict[str, Any]) -> Any:
headers = {"User-Agent": NOMINATIM_USER_AGENT, "Accept-Language": "de"}
client = get_client()
response = await client.get(f"{NOMINATIM_BASE_URL}{path}", params=params, headers=headers, timeout=12.0)
response.raise_for_status()
return response.json()
async def search_places(query: str, limit: int = 8) -> list[dict[str, Any]]:
if len(query.strip()) < 2:
return []
qhash = _query_hash(f"search:{query}:{limit}")
cached = await get_cached_json("geocode_cache", {"query_hash": qhash}, CACHE_TTL_GEOCODE)
if cached is not None:
return cached
raw = await _nominatim_get(
"/search",
{
"q": query,
"format": "jsonv2",
"addressdetails": 1,
"limit": limit,
"countrycodes": "de,at,ch",
},
)
results = []
for item in raw:
address = item.get("address") or {}
label = item.get("display_name", "")
place = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or address.get("county")
or label.split(",")[0]
)
results.append(
{
"label": label,
"place": place,
"lat": float(item["lat"]),
"lon": float(item["lon"]),
"type": item.get("type"),
"postcode": address.get("postcode"),
"county": address.get("county"),
"state": address.get("state"),
}
)
await set_cached_json(
"geocode_cache",
{"query_hash": qhash, "query_text": query},
results,
)
return results
async def reverse_geocode(lat: float, lon: float) -> dict[str, Any]:
qhash = _query_hash(f"reverse:{lat:.4f}:{lon:.4f}")
cached = await get_cached_json("geocode_cache", {"query_hash": qhash}, CACHE_TTL_GEOCODE)
if cached is not None:
return cached
raw = await _nominatim_get(
"/reverse",
{"lat": lat, "lon": lon, "format": "jsonv2", "addressdetails": 1, "zoom": 10},
)
address = raw.get("address") or {}
payload = {
"label": raw.get("display_name"),
"place": address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or address.get("county"),
"county": address.get("county"),
"state": address.get("state"),
"postcode": address.get("postcode"),
"admin_names": [
value
for value in [
address.get("city"),
address.get("town"),
address.get("village"),
address.get("municipality"),
address.get("county"),
address.get("state"),
]
if value
],
}
await set_cached_json(
"geocode_cache",
{"query_hash": qhash, "query_text": f"{lat},{lon}"},
payload,
)
return payload

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import asyncio
import time
from typing import Any
from app.cache import cache_get, cache_set, redis_available
from app.config import (
BRIGHTSKY_BASE_URL,
CACHE_TTL_HEALTH_SOURCES,
DATABASE_URL,
DWD_RADAR_BASE_URL,
DWD_WMS_URL,
DWD_WARNINGS_URL,
OPEN_METEO_BASE_URL,
)
from app.database import pool_available
from app.http_client import get_client
async def _probe(name: str, url: str, timeout: float = 5.0) -> dict[str, Any]:
started = time.perf_counter()
try:
client = get_client()
response = await client.get(url, timeout=timeout)
elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
ok = response.status_code < 500
return {
"name": name,
"url": url,
"status": "ok" if ok else "degraded",
"http_status": response.status_code,
"latency_ms": elapsed_ms,
}
except Exception as exc:
elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
return {
"name": name,
"url": url,
"status": "error",
"error": str(exc),
"latency_ms": elapsed_ms,
}
async def check_sources() -> list[dict[str, Any]]:
cache_key = "health:sources"
cached = await cache_get(cache_key)
if cached:
return cached
probes = await asyncio.gather(
_probe("open_meteo", f"{OPEN_METEO_BASE_URL}/forecast?latitude=52.5&longitude=13.4&current=temperature_2m"),
_probe("brightsky", f"{BRIGHTSKY_BASE_URL}/current_weather?lat=52.5&lon=13.4"),
_probe("dwd_radar", f"{DWD_RADAR_BASE_URL}/rv/", timeout=4.0),
_probe("dwd_wms", f"{DWD_WMS_URL}?service=WMS&request=GetCapabilities", timeout=6.0),
_probe("dwd_warnings", DWD_WARNINGS_URL, timeout=4.0),
)
results = list(probes)
results.append(
{
"name": "postgres",
"url": DATABASE_URL.split("@")[-1],
"status": "ok" if pool_available() else "disabled",
}
)
results.append(
{
"name": "redis",
"url": "redis",
"status": "ok" if redis_available() else "disabled",
}
)
await cache_set(cache_key, results, CACHE_TTL_HEALTH_SOURCES)
return results

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
import httpx
from app.cache import cache_get, cache_set
from app.config import CACHE_TTL_GEOCODE, DWD_WMS_URL
from app.http_client import get_client
logger = logging.getLogger(__name__)
WFS_URL = f"{DWD_WMS_URL.rsplit('/wms', 1)[0]}/ows"
# Landkreise reicht fuer warnapp_landkreise/json/warnings.json
WFS_LAYERS = ("dwd:Warngebiete_Kreise",)
async def _wfs_intersects(layer: str, lat: float, lon: float) -> list[dict[str, Any]]:
params = {
"service": "WFS",
"version": "2.0.0",
"request": "GetFeature",
"typeName": layer,
"outputFormat": "application/json",
"count": 5,
"CQL_FILTER": f"INTERSECTS(SHAPE,POINT({lat} {lon}))",
}
client = get_client()
response = await client.get(WFS_URL, params=params, timeout=8.0)
response.raise_for_status()
payload = response.json()
results = []
for feature in payload.get("features", []):
props = feature.get("properties") or {}
cell_id = props.get("WARNCELLID")
if cell_id is None:
continue
results.append(
{
"warncell_id": str(cell_id),
"name": props.get("NAME"),
"short_name": props.get("KURZNAME"),
"layer": layer.split(":")[-1],
"state": props.get("BL"),
}
)
return results
def _related_cell_ids(cell_id: str) -> set[str]:
ids = {cell_id}
if len(cell_id) >= 9:
core = cell_id[1:]
ids.add(f"1{core}")
ids.add(f"9{core}")
if cell_id.startswith("9"):
ids.add(f"1{cell_id[1:]}")
elif cell_id.startswith("1"):
ids.add(f"9{cell_id[1:]}")
return ids
async def resolve_warncells(lat: float, lon: float) -> dict[str, Any]:
cache_key = f"warncell:{lat:.4f}:{lon:.4f}"
cached = await cache_get(cache_key)
if cached:
return cached
layer_results = await asyncio.gather(
*[_wfs_intersects(layer, lat, lon) for layer in WFS_LAYERS],
return_exceptions=True,
)
areas: list[dict[str, Any]] = []
for layer, result in zip(WFS_LAYERS, layer_results):
if isinstance(result, Exception):
logger.warning("WFS warncell lookup failed for %s: %s", layer, result)
continue
areas.extend(result)
cell_ids: set[str] = set()
for area in areas:
cell_ids.update(_related_cell_ids(area["warncell_id"]))
payload = {
"lat": lat,
"lon": lon,
"areas": areas,
"cell_ids": sorted(cell_ids),
}
await cache_set(cache_key, payload, CACHE_TTL_GEOCODE)
return payload

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from typing import Any
from app.cache import cache_get, cache_set
from app.config import CACHE_TTL_WARNINGS, DWD_WARNINGS_URL
from app.database import set_cached_json
from app.http_client import get_client
from app.services.warncells import resolve_warncells
WARN_LEVELS = {
10: ("Vorabinformation", "info"),
20: ("Wetterwarnung", "minor"),
30: ("Markantes Wetter", "moderate"),
40: ("Unwetterwarnung", "severe"),
50: ("Extremes Unwetter", "extreme"),
60: ("Extremes Unwetter", "extreme"),
70: ("Extremes Unwetter", "extreme"),
80: ("Extremes Unwetter", "extreme"),
90: ("Extremes Unwetter", "extreme"),
}
def _flatten_warnings(payload: dict[str, Any]) -> list[dict[str, Any]]:
warnings_root = payload.get("warnings", {})
flattened: list[dict[str, Any]] = []
for cell_id, items in warnings_root.items():
if not isinstance(items, list):
continue
for item in items:
level = int(item.get("level", 0))
level_label, severity = WARN_LEVELS.get(level, (f"Level {level}", "unknown"))
flattened.append(
{
"cell_id": str(cell_id),
"state": item.get("state"),
"region_name": item.get("regionName"),
"event": item.get("event"),
"headline": item.get("headline"),
"description": (item.get("description") or "").strip(),
"instruction": (item.get("instruction") or "").strip(),
"level": level,
"level_label": level_label,
"severity": severity,
"type": item.get("type"),
"start": datetime.fromtimestamp(item["start"] / 1000, tz=timezone.utc).isoformat()
if item.get("start")
else None,
"end": datetime.fromtimestamp(item["end"] / 1000, tz=timezone.utc).isoformat()
if item.get("end")
else None,
}
)
flattened.sort(key=lambda w: (-w["level"], w.get("start") or ""))
return flattened
def _normalize_kreis_name(name: str) -> str:
value = name.lower().strip()
value = re.sub(r"^(landkreis|kreis und stadt|kreis|stadt)\s+", "", value)
value = re.sub(r"[^a-zäöüß0-9]+", " ", value)
return " ".join(value.split())
def _match_by_area_names(warning: dict[str, Any], area_names: list[str]) -> bool:
region = _normalize_kreis_name(warning.get("region_name") or "")
if not region:
return False
for area_name in area_names:
normalized = _normalize_kreis_name(area_name or "")
if not normalized or len(normalized) < 3:
continue
if region == normalized or normalized in region or region in normalized:
return True
return False
async def fetch_all_warnings() -> list[dict[str, Any]]:
cache_key = "warnings:all"
cached = await cache_get(cache_key)
if cached:
return cached
client = get_client()
response = await client.get(DWD_WARNINGS_URL, timeout=10.0)
response.raise_for_status()
text = response.text
if text.startswith("warnWetter.loadWarnings("):
text = text[text.index("(") + 1 : text.rindex(")")]
payload = json.loads(text)
warnings = _flatten_warnings(payload)
await cache_set(cache_key, warnings, CACHE_TTL_WARNINGS)
await set_cached_json("warnings_cache", {}, payload)
return warnings
async def fetch_warnings_for_location(lat: float, lon: float, local_only: bool = True) -> dict[str, Any]:
warnings = await fetch_all_warnings()
warncells = await resolve_warncells(lat, lon)
cell_ids = set(warncells.get("cell_ids", []))
area_names = [area.get("name") for area in warncells.get("areas", []) if area.get("name")]
local = [
warning
for warning in warnings
if warning["cell_id"] in cell_ids or _match_by_area_names(warning, area_names)
]
if local_only:
result_warnings = local
else:
result_warnings = warnings[:100]
return {
"lat": lat,
"lon": lon,
"count_total": len(warnings),
"count_local": len(local),
"warnings": result_warnings,
"warncells": warncells.get("areas", []),
"matched_cell_ids": sorted(cell_ids),
"local_only": local_only,
"all_warnings_truncated": not local_only and len(warnings) > 100,
}

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from typing import Any
from app.cache import cache_get as redis_get, cache_set as redis_set
from app.config import (
BRIGHTSKY_BASE_URL,
CACHE_TTL_FORECAST,
CACHE_TTL_OBSERVATIONS,
OPEN_METEO_BASE_URL,
)
from app.database import get_cached_json, set_cached_json
from app.http_client import fetch_json
FORECAST_PARAMS = {
"timezone": "Europe/Berlin",
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m,wind_gusts_10m",
"hourly": "temperature_2m,relative_humidity_2m,precipitation_probability,precipitation,weather_code,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m,wind_gusts_10m,visibility",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max,wind_gusts_10m_max,sunrise,sunset",
"forecast_days": 7,
}
def _coord_key(lat: float, lon: float) -> tuple[float, float]:
return round(lat, 4), round(lon, 4)
async def get_forecast(lat: float, lon: float) -> dict[str, Any]:
lat_k, lon_k = _coord_key(lat, lon)
redis_key = f"forecast:{lat_k}:{lon_k}"
cached = await redis_get(redis_key)
if cached:
return {"source": OPEN_METEO_BASE_URL, "lat": lat, "lon": lon, "data": cached, "cached": True}
cached = await get_cached_json("forecast_cache", {"lat": lat_k, "lon": lon_k}, CACHE_TTL_FORECAST)
if cached:
await redis_set(redis_key, cached, CACHE_TTL_FORECAST)
return {"source": OPEN_METEO_BASE_URL, "lat": lat, "lon": lon, "data": cached, "cached": True}
data = await fetch_json(
f"{OPEN_METEO_BASE_URL}/forecast",
params={"latitude": lat, "longitude": lon, **FORECAST_PARAMS},
)
await set_cached_json("forecast_cache", {"lat": lat_k, "lon": lon_k}, data)
await redis_set(redis_key, data, CACHE_TTL_FORECAST)
return {"source": OPEN_METEO_BASE_URL, "lat": lat, "lon": lon, "data": data, "cached": False}
async def get_observations(lat: float, lon: float) -> dict[str, Any]:
lat_k, lon_k = _coord_key(lat, lon)
redis_key = f"observations:{lat_k}:{lon_k}"
cached = await redis_get(redis_key)
if cached:
return {"source": BRIGHTSKY_BASE_URL, "lat": lat, "lon": lon, "data": cached, "cached": True}
cached = await get_cached_json("observations_cache", {"lat": lat_k, "lon": lon_k}, CACHE_TTL_OBSERVATIONS)
if cached:
await redis_set(redis_key, cached, CACHE_TTL_OBSERVATIONS)
return {"source": BRIGHTSKY_BASE_URL, "lat": lat, "lon": lon, "data": cached, "cached": True}
data = await fetch_json(f"{BRIGHTSKY_BASE_URL}/current_weather", params={"lat": lat, "lon": lon})
await set_cached_json("observations_cache", {"lat": lat_k, "lon": lon_k}, data)
await redis_set(redis_key, data, CACHE_TTL_OBSERVATIONS)
return {"source": BRIGHTSKY_BASE_URL, "lat": lat, "lon": lon, "data": data, "cached": False}

92
api/app/services/wms.py Normal file
View File

@@ -0,0 +1,92 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree as ET
from app.http_client import get_client
from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL
NS = {"wms": "http://www.opengis.net/wms"}
def _parse_iso_duration_minutes(duration: str) -> int:
match = re.fullmatch(r"PT(\d+)M", duration.strip().upper())
if not match:
return 5
return int(match.group(1))
def _parse_time_dimension(value: str) -> tuple[datetime, datetime, int]:
start_raw, end_raw, step_raw = value.split("/")
start = datetime.fromisoformat(start_raw.replace("Z", "+00:00"))
end = datetime.fromisoformat(end_raw.replace("Z", "+00:00"))
step_minutes = _parse_iso_duration_minutes(step_raw)
return start, end, step_minutes
def _generate_time_steps(start: datetime, end: datetime, step_minutes: int) -> list[str]:
steps: list[str] = []
current = start
delta = timedelta(minutes=step_minutes)
while current <= end:
steps.append(current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"))
current += delta
return steps
def _find_layer_dimension(xml_text: str, layer_name: str) -> str | None:
root = ET.fromstring(xml_text)
target = layer_name.split(":")[-1]
for layer in root.iter():
if not layer.tag.endswith("Layer"):
continue
name_el = layer.find("wms:Name", NS) or layer.find("Name")
if name_el is None or name_el.text != target:
continue
for dim in layer:
if dim.tag.endswith("Dimension") and (dim.attrib.get("name") or "").lower() == "time":
return (dim.text or "").strip()
return None
async def fetch_wms_time_steps(layer: str, minutes: int = 120) -> dict:
cache_key = f"wms:times:{layer}:{minutes}"
cached = await cache_get(cache_key)
if cached:
return cached
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
client = get_client()
response = await client.get(DWD_WMS_URL, params=params, timeout=15.0)
response.raise_for_status()
dimension = _find_layer_dimension(response.text, layer)
if not dimension:
raise ValueError(f"No TIME dimension found for layer {layer}")
if "/" in dimension:
start, end, step_minutes = _parse_time_dimension(dimension)
all_steps = _generate_time_steps(start, end, step_minutes)
else:
all_steps = [item.strip() for item in dimension.split(",") if item.strip()]
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
filtered = []
for step in all_steps:
try:
ts = datetime.fromisoformat(step.replace("Z", "+00:00"))
except ValueError:
continue
if ts >= cutoff:
filtered.append(step)
payload = {
"layer": layer,
"minutes": minutes,
"step_minutes": _parse_iso_duration_minutes("PT5M") if "/" in dimension else 5,
"count": len(filtered),
"times": filtered,
"latest": filtered[-1] if filtered else None,
}
await cache_set(cache_key, payload, CACHE_TTL_WMS_TIMES)
return payload