initial commit
This commit is contained in:
404
api/app/main.py
Normal file
404
api/app/main.py
Normal 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.",
|
||||
}
|
||||
Reference in New Issue
Block a user