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)