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