initial commit
This commit is contained in:
18
api/Dockerfile
Normal file
18
api/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
0
api/app/__init__.py
Normal file
0
api/app/__init__.py
Normal file
87
api/app/auth.py
Normal file
87
api/app/auth.py
Normal 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
57
api/app/cache.py
Normal 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
48
api/app/config.py
Normal 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
213
api/app/database.py
Normal 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
41
api/app/http_client.py
Normal 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
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.",
|
||||
}
|
||||
0
api/app/routes/__init__.py
Normal file
0
api/app/routes/__init__.py
Normal file
154
api/app/routes/admin.py
Normal file
154
api/app/routes/admin.py
Normal 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."}
|
||||
0
api/app/services/__init__.py
Normal file
0
api/app/services/__init__.py
Normal file
67
api/app/services/dashboard.py
Normal file
67
api/app/services/dashboard.py
Normal 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
|
||||
43
api/app/services/dwd_radar.py
Normal file
43
api/app/services/dwd_radar.py
Normal 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}
|
||||
114
api/app/services/geocoding.py
Normal file
114
api/app/services/geocoding.py
Normal 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
|
||||
75
api/app/services/health.py
Normal file
75
api/app/services/health.py
Normal 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¤t=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
|
||||
93
api/app/services/warncells.py
Normal file
93
api/app/services/warncells.py
Normal 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
|
||||
128
api/app/services/warnings.py
Normal file
128
api/app/services/warnings.py
Normal 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,
|
||||
}
|
||||
66
api/app/services/weather.py
Normal file
66
api/app/services/weather.py
Normal 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
92
api/app/services/wms.py
Normal 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
|
||||
8
api/requirements.txt
Normal file
8
api/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.10.3
|
||||
asyncpg==0.30.0
|
||||
redis==5.2.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.2.1
|
||||
Reference in New Issue
Block a user