Security & Dependency Updates / Dependency Security Scan (push) Successful in 1m8s
Security & Dependency Updates / License Compliance (push) Successful in 25s
Security & Dependency Updates / Check for Dependency Updates (push) Successful in 20s
Security & Dependency Updates / Code Quality Metrics (push) Successful in 17s
Security & Dependency Updates / Security Summary (push) Successful in 9s
Add src/ml/ package predicting, per station and per 6/12/24 h horizon, the probability of exceeding warning (3.0 m) and danger (4.5 m) levels plus expected peak level, trained on the 592k-row PostgreSQL history: - features.py: hourly grid with coverage gating and no future leakage; upstream stations enter at empirically measured travel-time lags (P.20 +17h ... P.103 +1h vs P.1); hour-of-day deliberately excluded (it encodes the scrape schedule, not hydrology) - train.py: HistGradientBoosting regression + warn/danger classifier heads per station x horizon, >=30-positives gate with calibrated sigmoid-on-regression fallback, strict temporal splits, per-event lead-time evaluation; guards against sklearn 1.9.0 crash on degenerate feature columns - predict.py: bundle loading with feature-name checks, heuristic fallback tier, get_latest_forecasts() for the API; raises when no models are trained so the endpoint 503s instead of serving persistence output as forecasts - data.py: Postgres-first loader (FLOOD_ML_DB_URL override), HTTP API fallback (flagged: that path backfills synthetic discharge), csv.gz cache - /forecast endpoint (15-min TTL cache) + dashboard flood-risk panel (hidden until models exist) - docs/FLOOD_FORECASTING.md: full system doc with measured deployment numbers (~335 MB RSS, CPU negligible, ~6 min full retrain) and retraining policy Validation: out-of-sample backtest of the record 2024 flood season (train <= Aug 2024) alerted 24-48 h ahead of the Oct 5 peak; 2025-26 test split: P.1 6h PR-AUC 0.974, recall 98.3% at 1% false-alarm rate. Also: fix P.81 station coordinates (was Ban Pong/Ratchaburi, 493 km out of basin; now 18.6936 N 99.0819 E per RID station page), pin scikit-learn==1.9.0 and numpy<2, gitignore model artifacts (~100 MB, train on the server via scripts/train_flood_model.py).
665 lines
24 KiB
Python
665 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
FastAPI web interface for water monitoring system
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timedelta
|
|
from threading import Lock
|
|
from typing import Any, Dict, List
|
|
|
|
import requests
|
|
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import HTMLResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from .config import Config
|
|
from .health_check import APIHealthCheck, DatabaseHealthCheck, HealthCheckManager, MemoryHealthCheck
|
|
from .logging_config import get_logger, setup_logging
|
|
from .metrics import get_metrics_collector, increment_counter, set_gauge
|
|
from .postgres_history import PostgresHistory
|
|
from .schemas import (
|
|
HealthResponse,
|
|
MeasurementResponse,
|
|
MetricsResponse,
|
|
ScrapingStatusResponse,
|
|
StationCreateModel,
|
|
StationResponse,
|
|
StationUpdateModel,
|
|
)
|
|
from .thaiwater import ThaiWaterClient
|
|
from .water_scraper_v3 import EnhancedWaterMonitorScraper
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Simple thread-safe TTL cache for PostgreSQL history queries
|
|
HISTORY_CACHE: Dict[str, tuple] = {}
|
|
HISTORY_CACHE_LOCK = Lock()
|
|
HISTORY_TTL = 300 # 5 minutes
|
|
|
|
FORECAST_CACHE: Dict[str, tuple] = {}
|
|
FORECAST_CACHE_LOCK = Lock()
|
|
FORECAST_TTL = 900 # 15 minutes
|
|
|
|
# Dashboard HTML is loaded once at import from src/static/dashboard.html.
|
|
_DASHBOARD_HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "dashboard.html")
|
|
try:
|
|
with open(_DASHBOARD_HTML_PATH, encoding="utf-8") as _dashboard_file:
|
|
DASHBOARD_HTML = _dashboard_file.read()
|
|
except OSError as _dashboard_error: # pragma: no cover - defensive fallback
|
|
logger.error(f"Could not load dashboard HTML: {_dashboard_error}")
|
|
DASHBOARD_HTML = "<h1>Northern Thailand Ping River Monitor API</h1><p>See <code>/docs</code>.</p>"
|
|
|
|
|
|
# Global application state
|
|
app_state = {
|
|
"scraper": None,
|
|
"health_manager": None,
|
|
"scraping_task": None,
|
|
"is_scraping": False,
|
|
"scraping_stats": {
|
|
"total_runs": 0,
|
|
"successful_runs": 0,
|
|
"failed_runs": 0,
|
|
"last_run": None,
|
|
"next_run": None,
|
|
},
|
|
}
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Application lifespan manager"""
|
|
# Startup
|
|
logger.info("Starting Water Monitor API...")
|
|
|
|
# Initialize configuration
|
|
try:
|
|
Config.validate_config()
|
|
logger.info("Configuration validated successfully")
|
|
except Exception as e:
|
|
logger.error(f"Configuration validation failed: {e}")
|
|
raise
|
|
|
|
# Initialize scraper
|
|
db_config = Config.get_database_config()
|
|
app_state["scraper"] = EnhancedWaterMonitorScraper(db_config)
|
|
|
|
# Initialize health checks
|
|
health_manager = HealthCheckManager()
|
|
health_manager.add_check(DatabaseHealthCheck(app_state["scraper"].db_adapter))
|
|
health_manager.add_check(APIHealthCheck(Config.API_URL, app_state["scraper"].session))
|
|
health_manager.add_check(MemoryHealthCheck(max_memory_mb=1000))
|
|
app_state["health_manager"] = health_manager
|
|
|
|
# Start background scraping task
|
|
app_state["scraping_task"] = asyncio.create_task(background_scraping_task())
|
|
|
|
logger.info("Water Monitor API started successfully")
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
logger.info("Shutting down Water Monitor API...")
|
|
|
|
if app_state["scraping_task"]:
|
|
app_state["scraping_task"].cancel()
|
|
try:
|
|
await app_state["scraping_task"]
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
logger.info("Water Monitor API shutdown complete")
|
|
|
|
|
|
# Create FastAPI app
|
|
app = FastAPI(
|
|
title="Northern Thailand Ping River Monitor API",
|
|
description="Real-time water level monitoring system for Northern Thailand's Ping River Basin stations",
|
|
version="3.1.3",
|
|
lifespan=lifespan,
|
|
)
|
|
app.mount("/static", StaticFiles(directory=os.path.dirname(_DASHBOARD_HTML_PATH)), name="static")
|
|
|
|
# Add CORS middleware.
|
|
# Origins come from CORS_ALLOW_ORIGINS (comma-separated). When none are configured
|
|
# we fall back to a wildcard WITHOUT credentials (a safe, spec-valid combination);
|
|
# credentials are only enabled when explicit origins are provided.
|
|
_cors_origins = Config.CORS_ALLOW_ORIGINS or ["*"]
|
|
_cors_allow_credentials = bool(Config.CORS_ALLOW_ORIGINS)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_cors_origins,
|
|
allow_credentials=_cors_allow_credentials,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
async def background_scraping_task():
|
|
"""Background task for periodic data scraping"""
|
|
while True:
|
|
try:
|
|
if not app_state["is_scraping"]:
|
|
app_state["is_scraping"] = True
|
|
|
|
# Run scraping cycle
|
|
scraper = app_state["scraper"]
|
|
if scraper:
|
|
logger.info("Starting background scraping cycle")
|
|
start_time = datetime.now()
|
|
|
|
try:
|
|
# run_scraping_cycle() does blocking network/DB I/O and time.sleep
|
|
# retries; run it in a thread so it doesn't freeze the event loop.
|
|
result = await asyncio.get_event_loop().run_in_executor(None, scraper.run_scraping_cycle)
|
|
|
|
# Update stats
|
|
app_state["scraping_stats"]["total_runs"] += 1
|
|
app_state["scraping_stats"]["last_run"] = start_time
|
|
|
|
if result:
|
|
app_state["scraping_stats"]["successful_runs"] += 1
|
|
increment_counter("scraping_cycles_successful")
|
|
logger.info("Background scraping cycle completed successfully")
|
|
else:
|
|
app_state["scraping_stats"]["failed_runs"] += 1
|
|
increment_counter("scraping_cycles_failed")
|
|
logger.warning("Background scraping cycle completed with no new data")
|
|
|
|
# Update metrics
|
|
set_gauge("last_scraping_timestamp", start_time.timestamp())
|
|
|
|
except Exception as e:
|
|
app_state["scraping_stats"]["failed_runs"] += 1
|
|
increment_counter("scraping_cycles_failed")
|
|
logger.error(f"Background scraping cycle failed: {e}")
|
|
|
|
app_state["is_scraping"] = False
|
|
|
|
# Calculate next run time
|
|
interval_seconds = Config.SCRAPING_INTERVAL_HOURS * 3600
|
|
app_state["scraping_stats"]["next_run"] = datetime.now() + timedelta(seconds=interval_seconds)
|
|
|
|
# Wait for next cycle
|
|
await asyncio.sleep(interval_seconds)
|
|
|
|
except asyncio.CancelledError:
|
|
logger.info("Background scraping task cancelled")
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error in background scraping task: {e}")
|
|
await asyncio.sleep(60) # Wait a minute before retrying
|
|
|
|
|
|
# API Routes
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def root():
|
|
"""Root endpoint with basic dashboard"""
|
|
return HTMLResponse(content=DASHBOARD_HTML)
|
|
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
async def get_health():
|
|
"""Get system health status"""
|
|
increment_counter("api_requests", labels={"endpoint": "health"})
|
|
|
|
health_manager = app_state["health_manager"]
|
|
if not health_manager:
|
|
raise HTTPException(status_code=503, detail="Health manager not initialized")
|
|
|
|
# Run health checks (populates state read by get_health_summary)
|
|
health_manager.run_all_checks()
|
|
summary = health_manager.get_health_summary()
|
|
|
|
return HealthResponse(**summary)
|
|
|
|
|
|
@app.get("/metrics", response_model=MetricsResponse)
|
|
async def get_metrics():
|
|
"""Get application metrics"""
|
|
increment_counter("api_requests", labels={"endpoint": "metrics"})
|
|
|
|
metrics_collector = get_metrics_collector()
|
|
metrics = metrics_collector.get_all_metrics()
|
|
|
|
return MetricsResponse(**metrics)
|
|
|
|
|
|
@app.get("/stations", response_model=List[StationResponse])
|
|
async def get_stations():
|
|
"""Get list of all monitoring stations"""
|
|
increment_counter("api_requests", labels={"endpoint": "stations"})
|
|
|
|
scraper = app_state["scraper"]
|
|
if not scraper:
|
|
raise HTTPException(status_code=503, detail="Scraper not initialized")
|
|
|
|
stations = []
|
|
for station_id, station_info in scraper.station_mapping.items():
|
|
stations.append(
|
|
StationResponse(
|
|
station_id=int(station_id),
|
|
station_code=station_info["code"],
|
|
thai_name=station_info["thai_name"],
|
|
english_name=station_info["english_name"],
|
|
latitude=station_info.get("latitude"),
|
|
longitude=station_info.get("longitude"),
|
|
status="active",
|
|
)
|
|
)
|
|
|
|
return stations
|
|
|
|
|
|
@app.post("/stations", response_model=StationResponse)
|
|
async def create_station(station: StationCreateModel):
|
|
"""Create a new monitoring station"""
|
|
increment_counter("api_requests", labels={"endpoint": "create_station"})
|
|
|
|
scraper = app_state["scraper"]
|
|
if not scraper:
|
|
raise HTTPException(status_code=503, detail="Scraper not initialized")
|
|
|
|
try:
|
|
# Find next available station ID
|
|
existing_ids = [int(sid) for sid in scraper.station_mapping.keys()]
|
|
new_station_id = max(existing_ids) + 1 if existing_ids else 1
|
|
|
|
# Add to station mapping and persist
|
|
new_key = str(new_station_id)
|
|
scraper.station_mapping[new_key] = {
|
|
"code": station.station_code,
|
|
"thai_name": station.thai_name,
|
|
"english_name": station.english_name,
|
|
"latitude": station.latitude,
|
|
"longitude": station.longitude,
|
|
"geohash": station.geohash,
|
|
}
|
|
if not scraper.save_stations():
|
|
scraper.station_mapping.pop(new_key, None)
|
|
raise HTTPException(status_code=500, detail="Failed to persist new station")
|
|
|
|
logger.info(f"Created new station: {station.station_code} ({station.english_name})")
|
|
|
|
return StationResponse(
|
|
station_id=new_station_id,
|
|
station_code=station.station_code,
|
|
thai_name=station.thai_name,
|
|
english_name=station.english_name,
|
|
latitude=station.latitude,
|
|
longitude=station.longitude,
|
|
geohash=station.geohash,
|
|
status=station.status,
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error creating station: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.put("/stations/{station_id}", response_model=StationResponse)
|
|
async def update_station(station_id: int, updates: StationUpdateModel):
|
|
"""Update an existing monitoring station"""
|
|
increment_counter("api_requests", labels={"endpoint": "update_station"})
|
|
|
|
scraper = app_state["scraper"]
|
|
if not scraper:
|
|
raise HTTPException(status_code=503, detail="Scraper not initialized")
|
|
|
|
station_key = str(station_id)
|
|
if station_key not in scraper.station_mapping:
|
|
raise HTTPException(status_code=404, detail="Station not found")
|
|
|
|
try:
|
|
station_info = scraper.station_mapping[station_key]
|
|
original = dict(station_info) # snapshot for rollback if persistence fails
|
|
|
|
# Update fields if provided
|
|
if updates.thai_name is not None:
|
|
station_info["thai_name"] = updates.thai_name
|
|
if updates.english_name is not None:
|
|
station_info["english_name"] = updates.english_name
|
|
if updates.latitude is not None:
|
|
station_info["latitude"] = updates.latitude
|
|
if updates.longitude is not None:
|
|
station_info["longitude"] = updates.longitude
|
|
if updates.geohash is not None:
|
|
station_info["geohash"] = updates.geohash
|
|
|
|
if not scraper.save_stations():
|
|
scraper.station_mapping[station_key] = original
|
|
raise HTTPException(status_code=500, detail="Failed to persist station update")
|
|
|
|
logger.info(f"Updated station {station_id}: {station_info['code']}")
|
|
|
|
return StationResponse(
|
|
station_id=station_id,
|
|
station_code=station_info["code"],
|
|
thai_name=station_info["thai_name"],
|
|
english_name=station_info["english_name"],
|
|
latitude=station_info.get("latitude"),
|
|
longitude=station_info.get("longitude"),
|
|
geohash=station_info.get("geohash"),
|
|
status=updates.status or "active",
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error updating station {station_id}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.delete("/stations/{station_id}")
|
|
async def delete_station(station_id: int):
|
|
"""Delete a monitoring station"""
|
|
increment_counter("api_requests", labels={"endpoint": "delete_station"})
|
|
|
|
scraper = app_state["scraper"]
|
|
if not scraper:
|
|
raise HTTPException(status_code=503, detail="Scraper not initialized")
|
|
|
|
station_key = str(station_id)
|
|
if station_key not in scraper.station_mapping:
|
|
raise HTTPException(status_code=404, detail="Station not found")
|
|
|
|
try:
|
|
station_info = scraper.station_mapping.pop(station_key)
|
|
|
|
if not scraper.save_stations():
|
|
scraper.station_mapping[station_key] = station_info # restore
|
|
raise HTTPException(status_code=500, detail="Failed to persist station deletion")
|
|
|
|
logger.info(f"Deleted station {station_id}: {station_info['code']}")
|
|
|
|
return {"message": f"Station {station_info['code']} deleted successfully"}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error deleting station {station_id}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/stations/{station_id}", response_model=StationResponse)
|
|
async def get_station(station_id: int):
|
|
"""Get details of a specific monitoring station"""
|
|
increment_counter("api_requests", labels={"endpoint": "get_station"})
|
|
|
|
scraper = app_state["scraper"]
|
|
if not scraper:
|
|
raise HTTPException(status_code=503, detail="Scraper not initialized")
|
|
|
|
station_key = str(station_id)
|
|
if station_key not in scraper.station_mapping:
|
|
raise HTTPException(status_code=404, detail="Station not found")
|
|
|
|
station_info = scraper.station_mapping[station_key]
|
|
|
|
return StationResponse(
|
|
station_id=station_id,
|
|
station_code=station_info["code"],
|
|
thai_name=station_info["thai_name"],
|
|
english_name=station_info["english_name"],
|
|
latitude=station_info.get("latitude"),
|
|
longitude=station_info.get("longitude"),
|
|
geohash=station_info.get("geohash"),
|
|
status="active",
|
|
)
|
|
|
|
|
|
def _to_measurement_response(measurement: Dict[str, Any]) -> MeasurementResponse:
|
|
"""Map a raw measurement dict from a DB adapter to the API response model.
|
|
|
|
``discharge`` is optional in the data (some stations report only level), so
|
|
it is read with ``.get`` rather than assumed present.
|
|
"""
|
|
return MeasurementResponse(
|
|
timestamp=measurement["timestamp"],
|
|
station_code=measurement["station_code"],
|
|
station_name_en=measurement["station_name_en"],
|
|
station_name_th=measurement["station_name_th"],
|
|
water_level=measurement["water_level"],
|
|
discharge=measurement.get("discharge"),
|
|
discharge_percent=measurement.get("discharge_percent"),
|
|
status=measurement.get("status", "active"),
|
|
)
|
|
|
|
|
|
@app.get("/sensors/thaiwater")
|
|
async def get_thaiwater_sensors():
|
|
"""Get current ThaiWater water-level sensors in the Ping basin."""
|
|
increment_counter("api_requests", labels={"endpoint": "thaiwater_sensors"})
|
|
try:
|
|
client = ThaiWaterClient(
|
|
api_key=Config.THAIWATER_API_KEY,
|
|
timeout=Config.REQUEST_TIMEOUT,
|
|
)
|
|
return await asyncio.to_thread(client.fetch_ping_sensors)
|
|
except RuntimeError as error:
|
|
raise HTTPException(status_code=503, detail=str(error))
|
|
except requests.RequestException as error:
|
|
logger.error(f"Error fetching ThaiWater sensors: {error}")
|
|
raise HTTPException(status_code=502, detail="ThaiWater API unavailable")
|
|
|
|
|
|
@app.get("/measurements/history/{station_code}")
|
|
async def get_postgres_history(
|
|
station_code: str,
|
|
hours: int = Query(168, ge=1),
|
|
limit: int = Query(50000, ge=1, le=100000),
|
|
):
|
|
"""Get historical measurements for a station from the configured database."""
|
|
cache_key = f"{station_code}:{hours}:{limit}"
|
|
now = time.monotonic()
|
|
with HISTORY_CACHE_LOCK:
|
|
cached = HISTORY_CACHE.get(cache_key)
|
|
if cached and now - cached[0] < HISTORY_TTL:
|
|
return cached[1]
|
|
try:
|
|
db_config = Config.get_database_config()
|
|
end_time = datetime.now()
|
|
if db_config["type"] == "postgresql":
|
|
history = PostgresHistory(db_config["connection_string"])
|
|
data = await asyncio.to_thread(
|
|
history.station_history,
|
|
station_code,
|
|
end_time - timedelta(hours=hours),
|
|
end_time,
|
|
limit,
|
|
)
|
|
else:
|
|
scraper = app_state["scraper"]
|
|
if not scraper or not scraper.db_adapter:
|
|
raise RuntimeError("Database not available")
|
|
rows = await asyncio.to_thread(
|
|
scraper.db_adapter.get_measurements_by_timerange,
|
|
end_time - timedelta(hours=hours),
|
|
end_time,
|
|
[station_code],
|
|
)
|
|
# adapter returns newest-first; keep the newest `limit` rows, chart wants ascending
|
|
data = list(reversed(rows[:limit]))
|
|
with HISTORY_CACHE_LOCK:
|
|
HISTORY_CACHE[cache_key] = (now, data)
|
|
return data
|
|
except RuntimeError as error:
|
|
raise HTTPException(status_code=503, detail=str(error))
|
|
except Exception as error:
|
|
logger.error(f"Error fetching measurement history: {error}")
|
|
raise HTTPException(status_code=502, detail="Measurement history unavailable")
|
|
|
|
|
|
@app.get("/forecast")
|
|
async def get_flood_forecasts():
|
|
"""Flood-risk forecasts per station for the 6/12/24 h horizons."""
|
|
increment_counter("api_requests", labels={"endpoint": "forecast"})
|
|
now = time.monotonic()
|
|
with FORECAST_CACHE_LOCK:
|
|
cached = FORECAST_CACHE.get("all")
|
|
if cached and now - cached[0] < FORECAST_TTL:
|
|
return cached[1]
|
|
try:
|
|
from .ml.predict import get_latest_forecasts
|
|
except ImportError as error:
|
|
raise HTTPException(status_code=503, detail=f"Forecasting unavailable: {error}")
|
|
try:
|
|
data = await asyncio.to_thread(get_latest_forecasts)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=503, detail="No trained flood models found")
|
|
except RuntimeError as error:
|
|
raise HTTPException(status_code=503, detail=str(error))
|
|
except Exception as error:
|
|
logger.error(f"Error computing flood forecasts: {error}")
|
|
raise HTTPException(status_code=502, detail="Flood forecast unavailable")
|
|
with FORECAST_CACHE_LOCK:
|
|
FORECAST_CACHE["all"] = (now, data)
|
|
return data
|
|
|
|
|
|
@app.get("/measurements/latest", response_model=List[MeasurementResponse])
|
|
async def get_latest_measurements(limit: int = 100):
|
|
"""Get latest measurements from all stations"""
|
|
increment_counter("api_requests", labels={"endpoint": "measurements_latest"})
|
|
|
|
scraper = app_state["scraper"]
|
|
if not scraper or not scraper.db_adapter:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
try:
|
|
measurements = scraper.get_latest_data(limit=limit)
|
|
|
|
return [_to_measurement_response(m) for m in measurements]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching latest measurements: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/measurements/station/{station_code}", response_model=List[MeasurementResponse])
|
|
async def get_station_measurements(station_code: str, hours: int = 24, limit: int = 1000):
|
|
"""Get measurements for a specific station"""
|
|
increment_counter("api_requests", labels={"endpoint": "measurements_station"})
|
|
|
|
scraper = app_state["scraper"]
|
|
if not scraper or not scraper.db_adapter:
|
|
raise HTTPException(status_code=503, detail="Database not available")
|
|
|
|
try:
|
|
# Get measurements for the specified time range
|
|
end_time = datetime.now()
|
|
start_time = end_time - timedelta(hours=hours)
|
|
|
|
measurements = scraper.db_adapter.get_measurements_by_timerange(
|
|
start_time, end_time, station_codes=[station_code]
|
|
)
|
|
|
|
# Limit results
|
|
measurements = measurements[:limit]
|
|
|
|
return [_to_measurement_response(m) for m in measurements]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching station measurements: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/scrape/trigger")
|
|
async def trigger_scraping(background_tasks: BackgroundTasks):
|
|
"""Trigger manual data scraping"""
|
|
increment_counter("api_requests", labels={"endpoint": "scrape_trigger"})
|
|
|
|
if app_state["is_scraping"]:
|
|
raise HTTPException(status_code=409, detail="Scraping already in progress")
|
|
|
|
scraper = app_state["scraper"]
|
|
if not scraper:
|
|
raise HTTPException(status_code=503, detail="Scraper not initialized")
|
|
|
|
def run_scraping():
|
|
"""Background task to run scraping"""
|
|
try:
|
|
app_state["is_scraping"] = True
|
|
logger.info("Manual scraping triggered via API")
|
|
|
|
result = scraper.run_scraping_cycle()
|
|
|
|
# Update stats
|
|
app_state["scraping_stats"]["total_runs"] += 1
|
|
app_state["scraping_stats"]["last_run"] = datetime.now()
|
|
|
|
if result:
|
|
app_state["scraping_stats"]["successful_runs"] += 1
|
|
increment_counter("manual_scraping_successful")
|
|
else:
|
|
app_state["scraping_stats"]["failed_runs"] += 1
|
|
increment_counter("manual_scraping_failed")
|
|
|
|
except Exception as e:
|
|
app_state["scraping_stats"]["failed_runs"] += 1
|
|
increment_counter("manual_scraping_failed")
|
|
logger.error(f"Manual scraping failed: {e}")
|
|
finally:
|
|
app_state["is_scraping"] = False
|
|
|
|
background_tasks.add_task(run_scraping)
|
|
|
|
return {"message": "Scraping triggered", "status": "started"}
|
|
|
|
|
|
@app.get("/scraping/status", response_model=ScrapingStatusResponse)
|
|
async def get_scraping_status():
|
|
"""Get current scraping status"""
|
|
increment_counter("api_requests", labels={"endpoint": "scraping_status"})
|
|
|
|
stats = app_state["scraping_stats"]
|
|
|
|
return ScrapingStatusResponse(
|
|
is_running=app_state["is_scraping"],
|
|
last_run=stats["last_run"],
|
|
next_run=stats["next_run"],
|
|
total_runs=stats["total_runs"],
|
|
successful_runs=stats["successful_runs"],
|
|
failed_runs=stats["failed_runs"],
|
|
)
|
|
|
|
|
|
@app.get("/config")
|
|
async def get_config():
|
|
"""Get current configuration (sensitive data masked)"""
|
|
increment_counter("api_requests", labels={"endpoint": "config"})
|
|
|
|
config = Config.get_all_settings()
|
|
|
|
# Mask sensitive information
|
|
for key in config:
|
|
if "password" in key.lower() or "secret" in key.lower():
|
|
if config[key]:
|
|
config[key] = "*" * 8
|
|
|
|
return config
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
# Setup logging
|
|
setup_logging(
|
|
log_level=Config.LOG_LEVEL,
|
|
log_file=Config.LOG_FILE,
|
|
enable_console=True,
|
|
enable_colors=True,
|
|
)
|
|
|
|
# Run the API server
|
|
uvicorn.run("web_api:app", host="0.0.0.0", port=8000, reload=False, log_config=None) # Use our custom logging
|