From 1c9f4131f9cc44998a6bce1c70b784e3bf0ed190 Mon Sep 17 00:00:00 2001 From: Olufemi Taiwo Date: Mon, 16 Mar 2026 20:05:28 +0000 Subject: [PATCH 1/4] feat(api): add bbox and date range validation to PredictRequest - Validate bbox has exactly 4 values [west, south, east, north] - Enforce longitude bounds (-180 to 180) and latitude bounds (-90 to 90) - Ensure west < east and south < north - Validate date strings follow YYYY-MM-DD format - Ensure start_date is earlier than end_date --- src/climatevision/api/main.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/climatevision/api/main.py b/src/climatevision/api/main.py index bd1d2df..7d75369 100644 --- a/src/climatevision/api/main.py +++ b/src/climatevision/api/main.py @@ -17,11 +17,13 @@ from pathlib import Path from typing import Any, Optional, Literal +from pydantic import field_validator + from fastapi import FastAPI, File, Form, HTTPException, UploadFile, Header, Query, Depends from fastapi.responses import RedirectResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel, Field, EmailStr +from pydantic import BaseModel, Field, EmailStr, model_validator from climatevision.db import ( get_connection, @@ -106,6 +108,36 @@ class PredictRequest(BaseModel): start_date: Optional[str] = None end_date: Optional[str] = None + @field_validator("bbox") + @classmethod + def validate_bbox(cls, v: Optional[list[float]]) -> Optional[list[float]]: + if v is None: + return v + if len(v) != 4: + raise ValueError("bbox must have exactly 4 values: [west, south, east, north]") + west, south, east, north = v + if not (-180 <= west <= 180 and -180 <= east <= 180): + raise ValueError("bbox longitude values must be between -180 and 180") + if not (-90 <= south <= 90 and -90 <= north <= 90): + raise ValueError("bbox latitude values must be between -90 and 90") + if west >= east: + raise ValueError("bbox west longitude must be less than east longitude") + if south >= north: + raise ValueError("bbox south latitude must be less than north latitude") + return v + + @model_validator(mode="after") + def validate_date_range(self) -> "PredictRequest": + if self.start_date and self.end_date: + try: + start = datetime.strptime(self.start_date, "%Y-%m-%d") + end = datetime.strptime(self.end_date, "%Y-%m-%d") + except ValueError: + raise ValueError("start_date and end_date must be in YYYY-MM-DD format") + if start >= end: + raise ValueError("start_date must be earlier than end_date") + return self + class RunRow(BaseModel): id: int From 169d2b9179fef73ec92913cacb06b8c69476872f Mon Sep 17 00:00:00 2001 From: Olufemi Taiwo Date: Mon, 16 Mar 2026 20:05:49 +0000 Subject: [PATCH 2/4] feat(api): add pagination and total count to /api/runs endpoint - Add offset query parameter for cursor-based pagination - Return total record count alongside results for frontend page controls - Restructure response to {total, limit, offset, runs} envelope - Refactor WHERE clause building to avoid SQL injection via safe parameterisation --- src/climatevision/api/main.py | 37 +++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/climatevision/api/main.py b/src/climatevision/api/main.py index 7d75369..46465cf 100644 --- a/src/climatevision/api/main.py +++ b/src/climatevision/api/main.py @@ -386,27 +386,38 @@ def get_analysis_type(analysis_type: str) -> dict[str, Any]: @app.get("/api/runs") def list_runs( limit: int = Query(default=50, le=200), + offset: int = Query(default=0, ge=0), status: Optional[str] = None, analysis_type: Optional[str] = None, - ) -> list[RunRow]: - """List analysis runs with optional filtering.""" - query = "SELECT * FROM runs WHERE 1=1" + ) -> dict[str, Any]: + """List analysis runs with optional filtering and pagination metadata.""" + where_clauses = ["1=1"] params: list = [] - + if status: - query += " AND status = ?" + where_clauses.append("status = ?") params.append(status) if analysis_type: - query += " AND analysis_type = ?" + where_clauses.append("analysis_type = ?") params.append(analysis_type) - - query += " ORDER BY id DESC LIMIT ?" - params.append(int(limit)) - + + where = " AND ".join(where_clauses) + with get_connection() as conn: - rows = conn.execute(query, params).fetchall() - - return [RunRow(**dict(r)) for r in rows] + total: int = conn.execute( + f"SELECT COUNT(*) FROM runs WHERE {where}", params + ).fetchone()[0] + rows = conn.execute( + f"SELECT * FROM runs WHERE {where} ORDER BY id DESC LIMIT ? OFFSET ?", + params + [int(limit), int(offset)], + ).fetchall() + + return { + "total": total, + "limit": limit, + "offset": offset, + "runs": [RunRow(**dict(r)) for r in rows], + } @app.get("/api/runs/{run_id}") def get_run(run_id: int) -> dict[str, Any]: From 8b70690dc2fe2c0063aa58883dccfb7836d3d4a5 Mon Sep 17 00:00:00 2001 From: Olufemi Taiwo Date: Mon, 16 Mar 2026 20:06:10 +0000 Subject: [PATCH 3/4] feat(api): add /api/runs/stats endpoint for dashboard KPI cards - Returns total run count, completed runs in last 7 days - Breakdown by status (pending, running, completed, failed) - Breakdown by analysis type (deforestation, ice_melting, flooding) - Alert summary: total alerts and unacknowledged count - Feeds directly into the frontend Dashboard KPI summary cards --- src/climatevision/api/main.py | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/climatevision/api/main.py b/src/climatevision/api/main.py index 46465cf..3327ef5 100644 --- a/src/climatevision/api/main.py +++ b/src/climatevision/api/main.py @@ -419,6 +419,47 @@ def list_runs( "runs": [RunRow(**dict(r)) for r in rows], } + @app.get("/api/runs/stats") + def get_run_stats() -> dict[str, Any]: + """Return aggregated run statistics for dashboard KPI cards.""" + with get_connection() as conn: + total = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0] + + by_status = { + row["status"]: row["count"] + for row in conn.execute( + "SELECT status, COUNT(*) as count FROM runs GROUP BY status" + ).fetchall() + } + + by_analysis_type = { + row["analysis_type"]: row["count"] + for row in conn.execute( + "SELECT analysis_type, COUNT(*) as count FROM runs GROUP BY analysis_type" + ).fetchall() + } + + recent_completed = conn.execute( + "SELECT COUNT(*) FROM runs WHERE status = 'completed' " + "AND created_at >= datetime('now', '-7 days')" + ).fetchone()[0] + + alerts_total = conn.execute("SELECT COUNT(*) FROM alerts").fetchone()[0] + alerts_unacknowledged = conn.execute( + "SELECT COUNT(*) FROM alerts WHERE acknowledged = 0" + ).fetchone()[0] + + return { + "total_runs": total, + "completed_last_7_days": recent_completed, + "by_status": by_status, + "by_analysis_type": by_analysis_type, + "alerts": { + "total": alerts_total, + "unacknowledged": alerts_unacknowledged, + }, + } + @app.get("/api/runs/{run_id}") def get_run(run_id: int) -> dict[str, Any]: """Get details for a specific run including results.""" From ff5452c3414de4325a4b3eb006ecade5eaadbcb0 Mon Sep 17 00:00:00 2001 From: Olufemi Taiwo Date: Mon, 16 Mar 2026 20:07:40 +0000 Subject: [PATCH 4/4] feat(api): add request audit logging middleware - Log every request: method, path, status code, duration_ms, client IP - Attach X-Response-Time-Ms header to all responses for frontend monitoring - Uses Starlette BaseHTTPMiddleware for non-blocking request interception - Helps trace slow endpoints and detect unusual access patterns in production --- src/climatevision/api/main.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/climatevision/api/main.py b/src/climatevision/api/main.py index 3327ef5..a155ed4 100644 --- a/src/climatevision/api/main.py +++ b/src/climatevision/api/main.py @@ -13,16 +13,19 @@ import json import logging +import time from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional, Literal from pydantic import field_validator -from fastapi import FastAPI, File, Form, HTTPException, UploadFile, Header, Query, Depends +from fastapi import FastAPI, File, Form, HTTPException, UploadFile, Header, Query, Depends, Request from fastapi.responses import RedirectResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response from pydantic import BaseModel, Field, EmailStr, model_validator from climatevision.db import ( @@ -315,6 +318,28 @@ async def get_current_organization( return None +# ===== Audit Logging Middleware ===== + +class AuditLogMiddleware(BaseHTTPMiddleware): + """Log every API request with method, path, status code, and duration.""" + + async def dispatch(self, request: Request, call_next: Any) -> Response: + start = time.perf_counter() + response: Response = await call_next(request) + duration_ms = round((time.perf_counter() - start) * 1000, 2) + + logger.info( + "API request | method=%s path=%s status=%s duration_ms=%s ip=%s", + request.method, + request.url.path, + response.status_code, + duration_ms, + request.client.host if request.client else "unknown", + ) + response.headers["X-Response-Time-Ms"] = str(duration_ms) + return response + + # ===== Application Factory ===== def create_app() -> FastAPI: @@ -337,6 +362,7 @@ def create_app() -> FastAPI: openapi_url="/openapi.json", ) + app.add_middleware(AuditLogMiddleware) app.add_middleware( CORSMiddleware, allow_origins=[