Spaces:
Runtime error
Runtime error
File size: 4,731 Bytes
df71cfc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | """
Archivo: data.py
Fecha de modificación: 04/06/2026
Autor: Equipo AgroVisión
Descripción:
Router del **Explorador de Datos** (Fase 11). Permite consultar las tablas de la BD
Supabase directamente desde la UI. Expone dos endpoints:
- `GET /api/data/{table}`: lista los primeros 100 registros de una tabla permitida.
- `POST /api/data/query`: ejecuta una consulta SQL personalizada (solo SELECT).
Seguridad:
- Solo tablas explícitamente permitidas (`ALLOWED_TABLES`).
- Solo consultas `SELECT` (bloquea INSERT/UPDATE/DELETE/ALTER/DROP).
- Límite de 1000 filas por consulta.
- Timeout de 10 segundos.
Entradas / Dependencias:
- `backend.api.deps.get_db`, `backend.api.deps.get_user_keys`.
Ejemplo de Integración:
from backend.api.data import router
"""
from __future__ import annotations
import re
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.deps import get_db
router = APIRouter(prefix="/api/data", tags=["explorador de datos"])
# Tablas permitidas para consulta directa
ALLOWED_TABLES: frozenset[str] = frozenset({
"fields",
"ndvi_timeseries",
"chat_messages",
"events",
"plant_counts",
})
# Límite máximo de filas por consulta
MAX_ROWS = 1000
def _validate_query(sql: str) -> str:
"""
Valida que la consulta sea solo SELECT (lectura).
Args:
sql: Consulta SQL propuesta.
Returns:
La consulta limpia (stripped).
Raises:
HTTPException: Si la consulta no es SELECT o intenta modificar datos.
"""
sql = sql.strip().rstrip(";")
upper = sql.upper()
# Bloquear cualquier cosa que no sea SELECT
if not upper.startswith("SELECT"):
raise HTTPException(status_code=400, detail="Solo se permiten consultas SELECT.")
# Bloquear palabras clave peligrosas
dangerous = [
"INSERT", "UPDATE", "DELETE", "ALTER", "DROP",
"CREATE", "TRUNCATE", "GRANT", "REVOKE",
]
for word in dangerous:
if re.search(r"\b" + word + r"\b", upper):
raise HTTPException(status_code=400, detail=f"Consulta bloqueada: contiene '{word}'.")
return sql
@router.get("/{table}")
async def get_table(
table: str,
session: AsyncSession = Depends(get_db),
) -> dict:
"""
Lista los primeros 100 registros de una tabla permitida.
Args:
table: Nombre de la tabla (debe estar en ALLOWED_TABLES).
session: Sesión de BD.
Returns:
Dict con `rows` (lista de dicts) y `count`.
Raises:
HTTPException: Si la tabla no está permitida o no existe.
"""
if table not in ALLOWED_TABLES:
raise HTTPException(
status_code=400,
detail=f"Tabla no permitida. Permitidas: {sorted(ALLOWED_TABLES)}",
)
try:
query = text(f"SELECT * FROM {table} LIMIT 100")
result = await session.execute(query)
rows = [dict(row._mapping) for row in result.fetchall()]
# Convertir tipos no serializables
for row in rows:
for k, v in row.items():
if hasattr(v, "isoformat"):
row[k] = v.isoformat()
elif hasattr(v, "__geo_interface__"):
row[k] = dict(v.__geo_interface__)
return {"table": table, "rows": rows, "count": len(rows)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@router.post("/query")
async def run_query(
body: dict,
session: AsyncSession = Depends(get_db),
) -> dict:
"""
Ejecuta una consulta SQL personalizada (solo SELECT).
Args:
body: {"sql": "SELECT ..."}
session: Sesión de BD.
Returns:
Dict con `rows` y `count`.
Raises:
HTTPException: Si la consulta es inválida o falla.
"""
sql = body.get("sql", "").strip()
if not sql:
raise HTTPException(status_code=400, detail="Falta la consulta SQL.")
sql = _validate_query(sql)
# Añadir LIMIT si no existe
if "LIMIT" not in sql.upper():
sql = f"{sql} LIMIT {MAX_ROWS}"
try:
result = await session.execute(text(sql))
rows = [dict(row._mapping) for row in result.fetchall()]
# Convertir tipos no serializables
for row in rows:
for k, v in row.items():
if hasattr(v, "isoformat"):
row[k] = v.isoformat()
elif hasattr(v, "__geo_interface__"):
row[k] = dict(v.__geo_interface__)
return {"rows": rows, "count": len(rows)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
|