Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| 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 | |
| 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 | |