This commit is contained in:
Lizandro Guarnizo
2026-06-18 12:25:44 -05:00
parent 65ff7995e8
commit 408ea50d21
12 changed files with 1042 additions and 1100 deletions
+9 -16
View File
@@ -1,10 +1,13 @@
import os
import bcrypt import bcrypt
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
SECRET_KEY = "rips-manager-secret-key-change-in-production" # ponytail: fallback inseguro — set RIPS_SECRET_KEY en producción
SECRET_KEY = os.environ.get("RIPS_SECRET_KEY", "rips-manager-secret-key-change-in-production")
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 12 ACCESS_TOKEN_EXPIRE_HOURS = 12
@@ -23,12 +26,11 @@ def create_token(user_id: int, username: str) -> str:
payload = { payload = {
"user_id": user_id, "user_id": user_id,
"username": username, "username": username,
"exp": datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS),
} }
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
from typing import Optional
def decode_token(token: str) -> Optional[dict]: def decode_token(token: str) -> Optional[dict]:
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
@@ -37,23 +39,14 @@ def decode_token(token: str) -> Optional[dict]:
return None return None
from fastapi import Request
def get_current_user(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)): def get_current_user(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)):
if hasattr(request.state, "user") and request.state.user: if hasattr(request.state, "user") and request.state.user:
return request.state.user return request.state.user
if credentials is None: if credentials is None:
raise HTTPException( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
payload = decode_token(credentials.credentials) payload = decode_token(credentials.credentials)
if payload is None: if payload is None:
raise HTTPException( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
return payload return payload
-13
View File
@@ -39,19 +39,6 @@ def init_db():
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
step TEXT,
status TEXT NOT NULL CHECK(status IN ('success','error')),
payload TEXT,
response TEXT,
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS envios ( CREATE TABLE IF NOT EXISTS envios (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, user_id INTEGER,
-18
View File
@@ -13,26 +13,8 @@ class UserLogin(BaseModel):
password: str password: str
class ConfigUpdate(BaseModel):
key: str
value: str
class QueryCreate(BaseModel): class QueryCreate(BaseModel):
name: str name: str
query_type: str query_type: str
query_text: str query_text: str
description: Optional[str] = None description: Optional[str] = None
class QueryUpdate(BaseModel):
name: Optional[str] = None
query_text: Optional[str] = None
description: Optional[str] = None
class SendRequest(BaseModel):
tipo: str
fecha_inicio: str
fecha_fin: str
factura: Optional[str] = None
+77 -87
View File
@@ -1,9 +1,12 @@
import json as json_lib
import httpx
from datetime import datetime
from fastapi import APIRouter, Request, Form, Depends from fastapi import APIRouter, Request, Form, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.database import get_connection from app.database import get_connection
from app.auth import get_current_user from app.auth import get_current_user
from app.services.firebird_service import FirebirdService from app.services.firebird_service import get_firebird_from_config
from app.services.json_generator import generar_terceros, generar_transaccion from app.services.json_generator import generar_terceros, generar_transaccion, agrupar_por_factura
router = APIRouter(prefix="/automation", tags=["automation"]) router = APIRouter(prefix="/automation", tags=["automation"])
@@ -31,11 +34,6 @@ async def run_automation(
fecha_fin: str = Form(...), fecha_fin: str = Form(...),
factura: str = Form(""), factura: str = Form(""),
): ):
import json as json_lib
import httpx
from datetime import datetime
from collections import defaultdict
conn = get_connection() conn = get_connection()
q_terceros = conn.execute("SELECT * FROM queries WHERE id = ?", (query_terceros_id,)).fetchone() q_terceros = conn.execute("SELECT * FROM queries WHERE id = ?", (query_terceros_id,)).fetchone()
q_trans = conn.execute("SELECT * FROM queries WHERE id = ?", (query_transaccion_id,)).fetchone() q_trans = conn.execute("SELECT * FROM queries WHERE id = ?", (query_transaccion_id,)).fetchone()
@@ -45,15 +43,8 @@ async def run_automation(
if not q_terceros or not q_trans: if not q_terceros or not q_trans:
return JSONResponse({"success": False, "message": "Consultas no encontradas"}) return JSONResponse({"success": False, "message": "Consultas no encontradas"})
fb = FirebirdService() fb, fb_ok, fb_msg = get_firebird_from_config(configs)
fb_success, fb_msg = fb.connect( if not fb_ok:
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"}) return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
api_url = configs.get("api_url", "") api_url = configs.get("api_url", "")
@@ -74,7 +65,10 @@ async def run_automation(
if ":doc_num" in q_terceros["query_text"]: if ":doc_num" in q_terceros["query_text"]:
params["doc_num"] = "" params["doc_num"] = ""
success, error, rows = fb.execute_query(q_terceros["query_text"], params if ":fecha_ini" in q_terceros["query_text"] else None) success, error, rows = fb.execute_query(
q_terceros["query_text"],
params if ":fecha_ini" in q_terceros["query_text"] else None
)
if not success: if not success:
resultado["paso1_terceros"] = {"status": "error", "message": error} resultado["paso1_terceros"] = {"status": "error", "message": error}
@@ -85,40 +79,44 @@ async def run_automation(
terceros_errores = 0 terceros_errores = 0
pacientes_enviados = [] pacientes_enviados = []
for row in rows: async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
tercero_json = generar_terceros(row) for row in rows:
doc_id = tercero_json["numDocumentoIdentificacion"] tercero_json = generar_terceros(row)
if doc_id in pacientes_enviados: doc_id = tercero_json["numDocumentoIdentificacion"]
continue if doc_id in pacientes_enviados:
pacientes_enviados.append(doc_id) continue
pacientes_enviados.append(doc_id)
try: resp_ok = False
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client: resp_text = ""
try:
if api_method == "POST": if api_method == "POST":
resp = await client.post(api_url + "/terceros", json=tercero_json, headers=headers) resp = await client.post(api_url + "/terceros", json=tercero_json, headers=headers)
else: else:
resp = await client.put(api_url + "/terceros", json=tercero_json, headers=headers) resp = await client.put(api_url + "/terceros", json=tercero_json, headers=headers)
resp_ok = resp.is_success
if resp.is_success: resp_text = resp.text[:1000]
terceros_enviados += 1 if resp_ok:
else: terceros_enviados += 1
else:
terceros_errores += 1
except Exception as e:
terceros_errores += 1 terceros_errores += 1
except Exception as e: resp_text = str(e)
terceros_errores += 1
conn = get_connection() conn = get_connection()
conn.execute(""" conn.execute("""
INSERT INTO envios (user_id, tipo, factura, status, json_enviado, respuesta_api, created_at) INSERT INTO envios (user_id, tipo, factura, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", ( """, (
user["user_id"], "terceros", factura or "AUTO", user["user_id"], "terceros", factura or "AUTO",
"success" if resp.is_success else "error", "success" if resp_ok else "error",
json_lib.dumps(tercero_json, indent=2, ensure_ascii=False), json_lib.dumps(tercero_json, indent=2, ensure_ascii=False),
resp.text[:1000] if resp.is_success else str(e), resp_text,
datetime.now().isoformat(), datetime.now().isoformat(),
)) ))
conn.commit() conn.commit()
conn.close() conn.close()
resultado["paso1_terceros"] = { resultado["paso1_terceros"] = {
"status": "success" if terceros_errores == 0 else "partial", "status": "success" if terceros_errores == 0 else "partial",
@@ -140,59 +138,51 @@ async def run_automation(
elif not rows: elif not rows:
resultado["paso2_transaccion"] = {"status": "error", "message": "No hay servicios para enviar"} resultado["paso2_transaccion"] = {"status": "error", "message": "No hay servicios para enviar"}
else: else:
grupos = defaultdict(lambda: {"factura": "", "procedimientos": [], "paciente": {}}) grupos = agrupar_por_factura(rows, factura)
for row in rows:
doc_key = (row.get("tipo_doc_paciente", "CC"), row.get("num_doc_paciente", ""))
fact = row.get("num_factura", factura)
grupos[(fact, doc_key)]["factura"] = fact
grupos[(fact, doc_key)]["procedimientos"].append(dict(row))
trans_enviados = 0 trans_enviados = 0
trans_errores = 0 trans_errores = 0
for (fact, doc_key), grupo in grupos.items(): async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]} for (fact, doc_key), grupo in grupos.items():
trans_json = generar_transaccion( paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]}
fact, trans_json = generar_transaccion(
configs.get("num_documento_obligado", ""), fact, configs.get("num_documento_obligado", ""),
paciente_data, paciente_data, grupo["procedimientos"],
grupo["procedimientos"], )
)
try: status_ok = False
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client: resp_text = ""
try:
if api_method == "POST": if api_method == "POST":
resp = await client.post(api_url + "/transaccion", json=trans_json, headers=headers) resp = await client.post(api_url + "/transaccion", json=trans_json, headers=headers)
else: else:
resp = await client.put(api_url + "/transaccion", json=trans_json, headers=headers) resp = await client.put(api_url + "/transaccion", json=trans_json, headers=headers)
status_ok = resp.is_success
resp_text = resp.text[:1000]
except Exception as e:
resp_text = str(e)
status_ok = resp.is_success if status_ok:
resp_text = resp.text[:1000] trans_enviados += 1
except Exception as e: else:
status_ok = False trans_errores += 1
resp_text = str(e)
if status_ok: conn = get_connection()
trans_enviados += 1 conn.execute("""
else: INSERT INTO envios (user_id, tipo, factura, fecha_inicio, fecha_fin,
trans_errores += 1 pacientes_count, servicios_count, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
conn = get_connection() """, (
conn.execute(""" user["user_id"], "transaccion", fact,
INSERT INTO envios (user_id, tipo, factura, fecha_inicio, fecha_fin, fecha_inicio, fecha_fin,
pacientes_count, servicios_count, status, json_enviado, respuesta_api, created_at) 1, len(grupo["procedimientos"]),
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "success" if status_ok else "error",
""", ( json_lib.dumps(trans_json, indent=2, ensure_ascii=False)[:5000],
user["user_id"], "transaccion", fact, resp_text,
fecha_inicio, fecha_fin, datetime.now().isoformat(),
1, len(grupo["procedimientos"]), ))
"success" if status_ok else "error", conn.commit()
json_lib.dumps(trans_json, indent=2, ensure_ascii=False)[:5000], conn.close()
resp_text,
datetime.now().isoformat(),
))
conn.commit()
conn.close()
resultado["paso2_transaccion"] = { resultado["paso2_transaccion"] = {
"status": "success" if trans_errores == 0 else "partial", "status": "success" if trans_errores == 0 else "partial",
-1
View File
@@ -32,7 +32,6 @@ def ensure_defaults():
@router.get("") @router.get("")
async def config_page(request: Request, user: dict = Depends(get_current_user)): async def config_page(request: Request, user: dict = Depends(get_current_user)):
ensure_defaults()
conn = get_connection() conn = get_connection()
configs = conn.execute("SELECT * FROM config ORDER BY key").fetchall() configs = conn.execute("SELECT * FROM config ORDER BY key").fetchall()
conn.close() conn.close()
+7 -8
View File
@@ -96,7 +96,6 @@ def ensure_defaults():
@router.get("") @router.get("")
async def queries_page(request: Request, user: dict = Depends(get_current_user)): async def queries_page(request: Request, user: dict = Depends(get_current_user)):
ensure_defaults()
conn = get_connection() conn = get_connection()
queries = conn.execute("SELECT * FROM queries ORDER BY query_type, name").fetchall() queries = conn.execute("SELECT * FROM queries ORDER BY query_type, name").fetchall()
conn.close() conn.close()
@@ -149,7 +148,7 @@ async def run_sql(
sql: str = Form(...), sql: str = Form(...),
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
from app.services.firebird_service import FirebirdService from app.services.firebird_service import FirebirdService, get_firebird_from_config
conn = get_connection() conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()} configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close() conn.close()
@@ -190,7 +189,7 @@ async def query_delete(query_id: int, user: dict = Depends(get_current_user)):
@router.post("/esquema") @router.post("/esquema")
async def obtener_esquema(user: dict = Depends(get_current_user)): async def obtener_esquema(user: dict = Depends(get_current_user)):
from app.services.firebird_service import FirebirdService from app.services.firebird_service import FirebirdService, get_firebird_from_config
conn = get_connection() conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()} configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close() conn.close()
@@ -221,16 +220,16 @@ async def obtener_esquema(user: dict = Depends(get_current_user)):
resultado = [] resultado = []
for t in tablas: for t in tablas:
nombre = t["NOMBRE"].strip() nombre = t["NOMBRE"].strip()
ok, err, cols = fb.execute_query(f"""␍␍ ok, err, cols = fb.execute_query("""
SELECT SELECT
rf.RDB$FIELD_NAME as COLUMN_NAME, rf.RDB$FIELD_NAME as COLUMN_NAME,
f.RDB$FIELD_TYPE as FIELD_TYPE, f.RDB$FIELD_TYPE as FIELD_TYPE,
f.RDB$FIELD_LENGTH as FIELD_LENGTH f.RDB$FIELD_LENGTH as FIELD_LENGTH
FROM RDB$RELATION_FIELDS rf FROM RDB$RELATION_FIELDS rf
JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME
WHERE rf.RDB$RELATION_NAME = '{nombre}'␍␍ WHERE rf.RDB$RELATION_NAME = :nombre
ORDER BY rf.RDB$FIELD_POSITION ORDER BY rf.RDB$FIELD_POSITION
""") """, {"nombre": nombre})
columnas = [] columnas = []
if ok: if ok:
for c in cols: for c in cols:
@@ -251,7 +250,7 @@ async def obtener_esquema(user: dict = Depends(get_current_user)):
@router.post("/tablas") @router.post("/tablas")
async def listar_tablas(user: dict = Depends(get_current_user)): async def listar_tablas(user: dict = Depends(get_current_user)):
from app.services.firebird_service import FirebirdService from app.services.firebird_service import FirebirdService, get_firebird_from_config
conn = get_connection() conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()} configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close() conn.close()
@@ -290,7 +289,7 @@ async def query_test(
query_id: int = Form(...), query_id: int = Form(...),
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
from app.services.firebird_service import FirebirdService from app.services.firebird_service import FirebirdService, get_firebird_from_config
conn = get_connection() conn = get_connection()
q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone() q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
+33 -43
View File
@@ -1,13 +1,23 @@
import json
import httpx
from datetime import datetime
from fastapi import APIRouter, Request, Form, Depends from fastapi import APIRouter, Request, Form, Depends
from fastapi.responses import RedirectResponse, JSONResponse from fastapi.responses import JSONResponse
from app.database import get_connection from app.database import get_connection
from app.auth import get_current_user from app.auth import get_current_user
from app.services.firebird_service import FirebirdService from app.services.firebird_service import get_firebird_from_config
from app.services.json_generator import generar_terceros from app.services.json_generator import generar_terceros
router = APIRouter(prefix="/terceros", tags=["terceros"]) router = APIRouter(prefix="/terceros", tags=["terceros"])
def _load_configs() -> dict:
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
return configs
@router.get("") @router.get("")
async def terceros_page(request: Request, user: dict = Depends(get_current_user)): async def terceros_page(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection() conn = get_connection()
@@ -41,6 +51,7 @@ async def test_connection(
fb_user: str = Form(...), fb_user: str = Form(...),
fb_password: str = Form(...), fb_password: str = Form(...),
): ):
from app.services.firebird_service import FirebirdService
fb = FirebirdService() fb = FirebirdService()
success, msg = fb.connect(host, port, database, fb_user, fb_password) success, msg = fb.connect(host, port, database, fb_user, fb_password)
if success: if success:
@@ -63,16 +74,9 @@ async def preview_query(
if not q: if not q:
return JSONResponse({"success": False, "message": "Consulta no encontrada"}) return JSONResponse({"success": False, "message": "Consulta no encontrada"})
fb = FirebirdService() fb, ok, msg = get_firebird_from_config(configs)
fb_success, fb_msg = fb.connect( if not ok:
configs.get("firebird_host", "localhost"), return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
params = {} params = {}
if ":doc_num" in q["query_text"] and doc_num: if ":doc_num" in q["query_text"] and doc_num:
@@ -89,9 +93,7 @@ async def preview_query(
if not success: if not success:
return JSONResponse({"success": False, "message": error}) return JSONResponse({"success": False, "message": error})
json_result = None json_result = generar_terceros(rows[0]) if rows else None
if rows:
json_result = generar_terceros(rows[0])
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
@@ -109,10 +111,6 @@ async def send_terceros(
query_id: int = Form(...), query_id: int = Form(...),
doc_num: str = Form(""), doc_num: str = Form(""),
): ):
import json
import httpx
from datetime import datetime
conn = get_connection() conn = get_connection()
q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone() q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()} configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
@@ -121,16 +119,9 @@ async def send_terceros(
if not q: if not q:
return JSONResponse({"success": False, "message": "Consulta no encontrada"}) return JSONResponse({"success": False, "message": "Consulta no encontrada"})
fb = FirebirdService() fb, ok, msg = get_firebird_from_config(configs)
fb_success, fb_msg = fb.connect( if not ok:
configs.get("firebird_host", "localhost"), return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
params = {} params = {}
if ":doc_num" in q["query_text"]: if ":doc_num" in q["query_text"]:
@@ -141,51 +132,50 @@ async def send_terceros(
if not success: if not success:
return JSONResponse({"success": False, "message": error}) return JSONResponse({"success": False, "message": error})
if not rows: if not rows:
return JSONResponse({"success": False, "message": "No se encontraron datos"}) return JSONResponse({"success": False, "message": "No se encontraron datos"})
# Generar JSON terceros
tercero_json = generar_terceros(rows[0]) tercero_json = generar_terceros(rows[0])
# Enviar a API
api_url = configs.get("api_url", "") api_url = configs.get("api_url", "")
api_key = configs.get("api_key", "") api_key = configs.get("api_key", "")
api_method = configs.get("api_method", "POST") api_method = configs.get("api_method", "POST")
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if api_key: if api_key:
headers["Authorization"] = f"Bearer {api_key}" headers["Authorization"] = f"Bearer {api_key}"
resp_ok = False
resp_text = ""
resp_code = 0
try: try:
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client: async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
if api_method == "POST": if api_method == "POST":
resp = await client.post(api_url + "/terceros", json=tercero_json, headers=headers) resp = await client.post(api_url + "/terceros", json=tercero_json, headers=headers)
else: else:
resp = await client.put(api_url + "/terceros", json=tercero_json, headers=headers) resp = await client.put(api_url + "/terceros", json=tercero_json, headers=headers)
resp_ok = resp.is_success
result = resp.status_code, resp.is_success, resp.text resp_text = resp.text
resp_code = resp.status_code
except Exception as e: except Exception as e:
result = (0, False, str(e)) resp_text = str(e)
# Guardar log
conn = get_connection() conn = get_connection()
conn.execute(""" conn.execute("""
INSERT INTO envios (user_id, tipo, status, json_enviado, respuesta_api, created_at) INSERT INTO envios (user_id, tipo, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", ( """, (
user["user_id"], "terceros", user["user_id"], "terceros",
"success" if result[1] else "error", "success" if resp_ok else "error",
json.dumps(tercero_json, indent=2, ensure_ascii=False), json.dumps(tercero_json, indent=2, ensure_ascii=False),
str(result[2])[:1000], resp_text[:1000],
datetime.now().isoformat(), datetime.now().isoformat(),
)) ))
conn.commit() conn.commit()
conn.close() conn.close()
return JSONResponse({ return JSONResponse({
"success": result[1], "success": resp_ok,
"status_code": result[0], "status_code": resp_code,
"message": "Envío exitoso" if result[1] else f"Error: {result[2]}", "message": "Envío exitoso" if resp_ok else f"Error: {resp_text}",
"cuv": result[2][:200] if result[1] else None, "cuv": resp_text[:200] if resp_ok else None,
}) })
+52 -90
View File
@@ -1,9 +1,12 @@
import json as json_lib
import httpx
from datetime import datetime
from fastapi import APIRouter, Request, Form, Depends from fastapi import APIRouter, Request, Form, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.database import get_connection from app.database import get_connection
from app.auth import get_current_user from app.auth import get_current_user
from app.services.firebird_service import FirebirdService from app.services.firebird_service import get_firebird_from_config
from app.services.json_generator import generar_terceros, generar_transaccion from app.services.json_generator import generar_transaccion, agrupar_por_factura
router = APIRouter(prefix="/transaccion", tags=["transaccion"]) router = APIRouter(prefix="/transaccion", tags=["transaccion"])
@@ -45,16 +48,9 @@ async def preview_transaccion(
if not q: if not q:
return JSONResponse({"success": False, "message": "Consulta no encontrada"}) return JSONResponse({"success": False, "message": "Consulta no encontrada"})
fb = FirebirdService() fb, ok, msg = get_firebird_from_config(configs)
fb_success, fb_msg = fb.connect( if not ok:
configs.get("firebird_host", "localhost"), return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin} params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin}
if ":factura" in q["query_text"] and factura: if ":factura" in q["query_text"] and factura:
@@ -66,26 +62,14 @@ async def preview_transaccion(
if not success: if not success:
return JSONResponse({"success": False, "message": error}) return JSONResponse({"success": False, "message": error})
# Agrupar por paciente y factura grupos = agrupar_por_factura(rows, factura)
from collections import defaultdict
grupos = defaultdict(lambda: {"factura": "", "procedimientos": [], "paciente": {}})
for row in rows:
doc_key = (row.get("tipo_doc_paciente", "CC"), row.get("num_doc_paciente", ""))
fact = row.get("num_factura", factura)
grupos[(fact, doc_key)]["factura"] = fact
grupos[(fact, doc_key)]["procedimientos"].append(dict(row))
json_result = [] json_result = []
for (fact, doc_key), grupo in grupos.items(): for (fact, doc_key), grupo in grupos.items():
paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]} paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]}
trans = generar_transaccion( json_result.append(generar_transaccion(
fact, fact, configs.get("num_documento_obligado", ""),
configs.get("num_documento_obligado", ""), paciente_data, grupo["procedimientos"],
paciente_data, ))
grupo["procedimientos"],
)
json_result.append(trans)
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
@@ -107,11 +91,6 @@ async def send_transaccion(
fecha_inicio: str = Form(...), fecha_inicio: str = Form(...),
fecha_fin: str = Form(...), fecha_fin: str = Form(...),
): ):
import json as json_lib
import httpx
from datetime import datetime
from collections import defaultdict
conn = get_connection() conn = get_connection()
q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone() q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()} configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
@@ -120,16 +99,9 @@ async def send_transaccion(
if not q: if not q:
return JSONResponse({"success": False, "message": "Consulta no encontrada"}) return JSONResponse({"success": False, "message": "Consulta no encontrada"})
fb = FirebirdService() fb, ok, msg = get_firebird_from_config(configs)
fb_success, fb_msg = fb.connect( if not ok:
configs.get("firebird_host", "localhost"), return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin} params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin}
if ":factura" in q["query_text"] and factura: if ":factura" in q["query_text"] and factura:
@@ -143,14 +115,7 @@ async def send_transaccion(
if not rows: if not rows:
return JSONResponse({"success": False, "message": "No se encontraron datos"}) return JSONResponse({"success": False, "message": "No se encontraron datos"})
# Agrupar grupos = agrupar_por_factura(rows, factura)
grupos = defaultdict(lambda: {"factura": "", "procedimientos": [], "paciente": {}})
for row in rows:
doc_key = (row.get("tipo_doc_paciente", "CC"), row.get("num_doc_paciente", ""))
fact = row.get("num_factura", factura)
grupos[(fact, doc_key)]["factura"] = fact
grupos[(fact, doc_key)]["procedimientos"].append(dict(row))
api_url = configs.get("api_url", "") api_url = configs.get("api_url", "")
api_key = configs.get("api_key", "") api_key = configs.get("api_key", "")
api_method = configs.get("api_method", "POST") api_method = configs.get("api_method", "POST")
@@ -162,52 +127,49 @@ async def send_transaccion(
total_errores = 0 total_errores = 0
resultados = [] resultados = []
for (fact, doc_key), grupo in grupos.items(): async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]} for (fact, doc_key), grupo in grupos.items():
trans_json = generar_transaccion( paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]}
fact, trans_json = generar_transaccion(
configs.get("num_documento_obligado", ""), fact, configs.get("num_documento_obligado", ""),
paciente_data, paciente_data, grupo["procedimientos"],
grupo["procedimientos"], )
)
try: status_ok = False
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client: response_text = ""
try:
if api_method == "POST": if api_method == "POST":
resp = await client.post(api_url + "/transaccion", json=trans_json, headers=headers) resp = await client.post(api_url + "/transaccion", json=trans_json, headers=headers)
else: else:
resp = await client.put(api_url + "/transaccion", json=trans_json, headers=headers) resp = await client.put(api_url + "/transaccion", json=trans_json, headers=headers)
status_ok = resp.is_success
response_text = resp.text[:1000]
except Exception as e:
response_text = str(e)
status_ok = resp.is_success if status_ok:
response_text = resp.text[:1000] total_enviados += 1
except Exception as e: else:
status_ok = False total_errores += 1
response_text = str(e)
if status_ok: resultados.append({"factura": fact, "success": status_ok})
total_enviados += 1
else:
total_errores += 1
resultados.append({"factura": fact, "success": status_ok}) conn = get_connection()
conn.execute("""
# Guardar log INSERT INTO envios (user_id, tipo, factura, fecha_inicio, fecha_fin,
conn = get_connection() pacientes_count, servicios_count, status, json_enviado, respuesta_api, created_at)
conn.execute(""" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO envios (user_id, tipo, factura, fecha_inicio, fecha_fin, """, (
pacientes_count, servicios_count, status, json_enviado, respuesta_api, created_at) user["user_id"], "transaccion", fact,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) fecha_inicio, fecha_fin,
""", ( 1, len(grupo["procedimientos"]),
user["user_id"], "transaccion", fact, "success" if status_ok else "error",
fecha_inicio, fecha_fin, json_lib.dumps(trans_json, indent=2, ensure_ascii=False)[:5000],
1, len(grupo["procedimientos"]), response_text,
"success" if status_ok else "error", datetime.now().isoformat(),
json_lib.dumps(trans_json, indent=2, ensure_ascii=False)[:5000], ))
response_text, conn.commit()
datetime.now().isoformat(), conn.close()
))
conn.commit()
conn.close()
return JSONResponse({ return JSONResponse({
"success": total_errores == 0, "success": total_errores == 0,
+13 -1
View File
@@ -44,7 +44,7 @@ class FirebirdService:
return False, "No hay conexión activa", [] return False, "No hay conexión activa", []
try: try:
cur = self.conn.cursor() cur = self.conn.cursor()
if params: if params is not None:
cur.execute(query, params) cur.execute(query, params)
else: else:
cur.execute(query) cur.execute(query)
@@ -53,3 +53,15 @@ class FirebirdService:
return True, "", [dict(zip(columns, row)) for row in rows] return True, "", [dict(zip(columns, row)) for row in rows]
except Exception as e: except Exception as e:
return False, str(e), [] return False, str(e), []
def get_firebird_from_config(configs: dict) -> tuple:
fb = FirebirdService()
ok, msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
return fb, ok, msg
+11
View File
@@ -1,3 +1,4 @@
from collections import defaultdict
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@@ -49,6 +50,16 @@ def generar_procedimiento(row: dict, consecutivo: int) -> dict:
} }
def agrupar_por_factura(rows: list, factura_default: str = "") -> dict:
grupos = defaultdict(lambda: {"factura": "", "procedimientos": []})
for row in rows:
doc_key = (row.get("tipo_doc_paciente", "CC"), row.get("num_doc_paciente", ""))
fact = row.get("num_factura", factura_default)
grupos[(fact, doc_key)]["factura"] = fact
grupos[(fact, doc_key)]["procedimientos"].append(dict(row))
return grupos
def generar_transaccion( def generar_transaccion(
factura: str, factura: str,
num_doc_obligado: str, num_doc_obligado: str,
+371 -340
View File
@@ -1,403 +1,434 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Consultas SQL{% endblock %} {% block title %}SQL Workbench{% endblock %}
{% block header %}Consultas SQL{% endblock %} {% block header %}SQL Workbench{% endblock %}
{% block content %} {% block content %}
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6"> <style>
<div class="lg:col-span-3"> .CodeMirror { height: 180px; font-size: 13px; }
<div class="flex space-x-1 border-b border-gray-200 mb-4"> .cm-s-default .cm-keyword { color: #0000ff; font-weight: bold; }
<button onclick="cambiarTab('probador')" class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-blue-600 text-blue-600" id="tab-probador"><i class="fas fa-play mr-1"></i> Probador</button> .schema-col:hover { background: #eff6ff; }
<button onclick="cambiarTab('constructor')" class="tab-btn px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 border-b-2 border-transparent" id="tab-constructor"><i class="fas fa-wrench mr-1"></i> Constructor</button> .schema-table-row:hover { background: #f3f4f6; }
<button onclick="cambiarTab('editor')" class="tab-btn px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 border-b-2 border-transparent" id="tab-editor"><i class="fas fa-code mr-1"></i> Editor</button> #result-table table { border-collapse: collapse; width: 100%; }
</div> #result-table th { position: sticky; top: 0; background: #f9fafb; z-index: 1; }
#result-table td, #result-table th { padding: 4px 10px; border-bottom: 1px solid #e5e7eb; white-space: nowrap; font-size: 12px; text-align: left; }
#result-table tr:hover td { background: #eff6ff; }
#result-table tr:nth-child(even) td { background: #fafafa; }
#result-table tr:nth-child(even):hover td { background: #eff6ff; }
</style>
<div id="panel-probador"> <div class="flex gap-3" style="height: calc(100vh - 148px);">
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="p-4 border-b border-gray-200 flex items-center justify-between"> <!-- ── LEFT PANEL ─────────────────────────────────────────────── -->
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-terminal mr-2 text-green-500"></i>Escribí tu SQL y ejecutalo</h3> <div class="flex flex-col gap-3" style="width: 240px; flex-shrink: 0;">
<div class="flex space-x-2">
<button onclick="ejecutarProbador()" class="px-4 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs rounded-lg"><i class="fas fa-play mr-1"></i> Ejecutar</button> <!-- Schema Browser -->
<button onclick="limpiarProbador()" class="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 text-xs rounded-lg"><i class="fas fa-eraser mr-1"></i> Limpiar</button> <div class="bg-white rounded-xl border border-gray-200 flex flex-col" style="flex: 1; min-height: 0;">
</div> <div class="px-3 py-2 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
</div> <span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">Esquema</span>
<div> <button id="btn-conectar" onclick="cargarEsquema()"
<textarea id="probador-sql" rows="6" class="w-full px-4 py-3 text-sm font-mono border-0 focus:ring-0" placeholder="SELECT * FROM ..."></textarea> class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
</div> <i class="fas fa-plug"></i> Conectar
<div id="probador-resultados" class="border-t border-gray-200 overflow-x-auto max-h-96 overflow-y-auto"></div> </button>
<div id="probador-info" class="px-4 py-2 text-xs text-gray-500 border-t border-gray-200"></div> </div>
<div id="schema-search-wrap" class="hidden px-2 pt-2 flex-shrink-0">
<input id="schema-search" type="text" placeholder="Buscar tabla..."
oninput="filtrarTablas(this.value)"
class="w-full text-xs border border-gray-200 rounded px-2 py-1 focus:outline-none focus:border-blue-400">
</div>
<div id="schema-tree" class="flex-1 overflow-y-auto p-2 text-xs">
<p class="text-gray-400 italic text-center mt-4">Presioná Conectar para<br>cargar el esquema Firebird</p>
</div> </div>
</div> </div>
<div id="panel-constructor" class="hidden"> <!-- Saved Queries -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200"> <div class="bg-white rounded-xl border border-gray-200 flex flex-col" style="max-height: 38%; min-height: 120px;">
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between"> <div class="px-3 py-2 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-wrench mr-2 text-blue-500"></i>Constructor Visual</h3> <span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">Consultas</span>
<button onclick="cargarEsquema()" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded-lg"><i class="fas fa-sync mr-1"></i> Cargar esquema</button> <button onclick="abrirModalNueva()"
</div> class="text-xs text-blue-600 hover:text-blue-800"><i class="fas fa-plus"></i></button>
<div class="p-4"> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div class="overflow-y-auto flex-1">
<div> {% for q in queries %}
<h4 class="text-xs font-semibold text-gray-700 mb-2">Tablas</h4> <div class="group flex items-center px-3 py-1.5 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-0"
<div id="builder-tablas" class="space-y-1 max-h-64 overflow-y-auto border rounded p-2 bg-gray-50 text-xs"> onclick="cargarConsulta({{ q.query_text | tojson }}, {{ q.name | tojson }}, {{ q.query_type | tojson }})">
<p class="text-gray-400 italic">Presiona "Cargar esquema"</p> <div class="flex-1 min-w-0">
</div> <div class="flex items-center gap-1.5">
</div> <span class="text-xs px-1 rounded {% if q.query_type == 'terceros' %}bg-blue-100 text-blue-600{% else %}bg-purple-100 text-purple-600{% endif %}">
<div> {{ q.query_type[:4] }}
<h4 class="text-xs font-semibold text-gray-700 mb-2">Columnas</h4> </span>
<div id="builder-columnas" class="border rounded p-2 bg-gray-50 min-h-[100px] text-xs"> <span class="text-xs text-gray-800 truncate">{{ q.name }}</span>
<p class="text-gray-400 italic">Seleccioná una tabla</p>
</div>
<h4 class="text-xs font-semibold text-gray-700 mt-3 mb-2">Filtros</h4>
<div id="builder-where" class="space-y-1 text-xs"></div>
<button onclick="agregarFiltro()" class="mt-1 text-xs text-blue-600 hover:text-blue-800"><i class="fas fa-plus mr-1"></i> Agregar filtro</button>
</div>
<div>
<h4 class="text-xs font-semibold text-gray-700 mb-2">SQL</h4>
<textarea id="builder-sql" rows="4" readonly class="w-full px-2 py-1 border rounded text-xs font-mono bg-gray-100"></textarea>
<p id="builder-params" class="text-xs text-blue-600 mt-1"></p>
<button onclick="ejecutarBuilder()" class="mt-2 px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded-lg"><i class="fas fa-play mr-1"></i> Ejecutar</button>
<button onclick="usarSqlEditor()" class="mt-2 px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded-lg ml-1"><i class="fas fa-arrow-right mr-1"></i> Enviar al Editor</button>
</div> </div>
</div> </div>
<div id="builder-resultados" class="mt-3 border-t border-gray-200 pt-3 overflow-x-auto max-h-60 overflow-y-auto"></div> <div class="hidden group-hover:flex items-center gap-1 ml-1 flex-shrink-0">
</div> <button onclick="event.stopPropagation();editarConsulta({{ q.id }}, {{ q.name | tojson }}, {{ q.query_type | tojson }}, {{ q.query_text | tojson }}, {{ (q.description or '') | tojson }})"
</div> class="text-blue-400 hover:text-blue-600 p-0.5"><i class="fas fa-edit text-xs"></i></button>
</div> <form method="POST" action="/queries/delete/{{ q.id }}" onsubmit="return confirm('¿Eliminar consulta?')" class="inline">
<button type="submit" class="text-red-400 hover:text-red-600 p-0.5"><i class="fas fa-trash text-xs"></i></button>
<div id="panel-editor" class="hidden"> </form>
<div class="bg-white rounded-xl shadow-sm border border-gray-200"> </div>
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-save mr-2 text-blue-500"></i>Guardar consulta</h3>
</div>
<div class="p-4">
<form method="POST" action="/queries/create" class="space-y-3">
<input type="hidden" name="query_id" id="query_id" value="">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Nombre</label>
<input type="text" name="name" id="q_name" required class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm" placeholder="Nombre">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Tipo</label>
<select name="query_type" id="q_type" class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
<option value="terceros">Terceros</option>
<option value="transaccion">Transacción</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">SQL</label>
<textarea name="query_text" id="q_text" rows="8" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono" placeholder="PEGA ACA LA SQL YA PROBADA"></textarea>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Descripción</label>
<input type="text" name="description" id="q_desc" class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm" placeholder="Descripción">
</div>
<div class="flex space-x-2">
<button type="submit" class="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm"><i class="fas fa-save mr-1"></i> Guardar</button>
<button type="button" onclick="cancelEdit()" class="px-4 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 hidden" id="btn-cancel"><i class="fas fa-times mr-1"></i> Cancelar</button>
</div>
</form>
</div> </div>
{% else %}
<p class="text-xs text-gray-400 italic p-3 text-center">Sin consultas guardadas</p>
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
<div class="lg:col-span-1"> <!-- ── RIGHT PANEL ────────────────────────────────────────────── -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200"> <div class="flex-1 flex flex-col gap-3 min-w-0">
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800"><i class="fas fa-list mr-2 text-blue-500"></i>Consultas</h3> <!-- Toolbar -->
<div class="bg-white rounded-xl border border-gray-200 px-4 py-2.5 flex items-center gap-2 flex-shrink-0">
<button onclick="ejecutar()" id="btn-run"
class="flex items-center gap-2 px-4 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg font-medium">
<i class="fas fa-play text-xs"></i> Ejecutar
</button>
<span class="text-gray-200 select-none">|</span>
<input type="text" id="q-name" placeholder="Nombre de la consulta"
class="text-sm border border-gray-200 rounded-lg px-3 py-1.5 w-44 focus:outline-none focus:border-blue-400">
<select id="q-type" class="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-400">
<option value="terceros">Terceros</option>
<option value="transaccion">Transacción</option>
</select>
<button onclick="guardar()"
class="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg">
<i class="fas fa-save text-xs"></i> Guardar
</button>
<button id="btn-cancel" onclick="cancelarEdicion()"
class="hidden items-center gap-1 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm rounded-lg">
<i class="fas fa-times text-xs"></i> Cancelar
</button>
<span class="text-xs text-gray-400 ml-1 hidden" id="editing-label"></span>
<div class="ml-auto text-xs" id="status-bar"></div>
</div>
<!-- Editor -->
<div class="bg-white rounded-xl border border-gray-200 flex-shrink-0">
<div class="flex items-center justify-between px-3 py-1.5 border-b border-gray-100">
<span class="text-xs text-gray-500 font-mono" id="editor-label">SQL</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">Ctrl+Enter para ejecutar · Ctrl+Space autocomplete</span>
<button onclick="limpiar()" class="text-xs text-gray-400 hover:text-gray-600"><i class="fas fa-eraser"></i></button>
</div>
</div> </div>
<div class="p-3 space-y-2 max-h-[calc(100vh-200px)] overflow-y-auto"> <textarea id="sql-editor" class="hidden"></textarea>
{% for q in queries %} </div>
<div class="p-2 rounded-lg border border-gray-200 text-xs">
<div onclick="cargarConsulta({{ q.id }}, '{{ q.name }}', '{{ q.query_type }}', `{{ q.query_text|e }}`)" class="cursor-pointer"> <!-- Results -->
<div class="flex items-center justify-between"> <div class="bg-white rounded-xl border border-gray-200 flex-1 flex flex-col min-h-0">
<span class="font-medium text-gray-800 truncate">{{ q.name }}</span> <div class="px-4 py-2 border-b border-gray-100 flex items-center justify-between flex-shrink-0">
<span class="px-1.5 py-0.5 rounded text-xs {% if q.query_type == 'terceros' %}bg-blue-100 text-blue-700{% else %}bg-purple-100 text-purple-700{% endif %}">{{ q.query_type[:4] }}</span> <span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">Resultados</span>
</div> <span id="result-info" class="text-xs text-gray-400"></span>
<p class="text-gray-500 mt-0.5 truncate">{{ q.description or 'Sin descripción' }}</p> </div>
</div> <div id="result-table" class="flex-1 overflow-auto">
<div class="flex space-x-2 mt-1"> <div class="flex flex-col items-center justify-center h-full text-gray-300 select-none">
<button onclick="event.stopPropagation();ejecutarConsultaId({{ q.id }})" class="text-green-600 hover:text-green-800"><i class="fas fa-play"></i></button> <i class="fas fa-table text-4xl mb-3"></i>
<button onclick="event.stopPropagation();cargarConsulta({{ q.id }}, '{{ q.name }}', '{{ q.query_type }}', `{{ q.query_text|e }}`)" class="text-blue-600 hover:text-blue-800"><i class="fas fa-edit"></i></button> <p class="text-sm">Ejecutá una consulta para ver resultados</p>
<form method="POST" action="/queries/delete/{{ q.id }}" class="inline" onsubmit="return confirm('Eliminar?')">
<button type="submit" class="text-red-500 hover:text-red-700"><i class="fas fa-trash"></i></button>
</form>
</div>
</div> </div>
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script> <!-- ── MODAL GUARDAR ──────────────────────────────────────────────── -->
let sqlEditor = null; <div id="modal-save" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
let esquema = []; <div class="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 p-6">
let tablasSeleccionadas = {}; <h3 class="font-semibold text-gray-800 mb-4 text-base" id="modal-title">Guardar consulta</h3>
let filtros = []; <form id="form-save" method="POST" action="/queries/create" class="space-y-3">
let aliasCounter = 0; <input type="text" name="name" id="form-name" required placeholder="Nombre *"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-400">
<div class="grid grid-cols-2 gap-3">
<select name="query_type" id="form-type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-400">
<option value="terceros">Terceros</option>
<option value="transaccion">Transacción</option>
</select>
<input type="text" name="description" id="form-desc" placeholder="Descripción"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-400">
</div>
<textarea name="query_text" id="form-sql" rows="5"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:outline-none focus:border-blue-400"></textarea>
<div class="flex justify-end gap-2 pt-1">
<button type="button" onclick="cerrarModal()"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200">Cancelar</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">Guardar</button>
</div>
</form>
</div>
</div>
function cambiarTab(tab) { <script>
['probador','constructor','editor'].forEach(t => { let editor, esquema = [], editandoId = null;
document.getElementById('panel-'+t).classList.toggle('hidden', t !== tab);
const btn = document.getElementById('tab-'+t); // ── INIT CODEMIRROR ──────────────────────────────────────────────────
if (t === tab) { document.addEventListener('DOMContentLoaded', () => {
btn.className = 'tab-btn px-4 py-2 text-sm font-medium border-b-2 border-blue-600 text-blue-600'; editor = CodeMirror.fromTextArea(document.getElementById('sql-editor'), {
} else { mode: 'text/x-sql',
btn.className = 'tab-btn px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 border-b-2 border-transparent'; lineNumbers: true,
autofocus: true,
tabSize: 4,
indentWithTabs: false,
lineWrapping: true,
extraKeys: {
'Ctrl-Space': 'autocomplete',
'Cmd-Space': 'autocomplete',
},
hintOptions: { tables: {} }
});
editor.setSize(null, 180);
editor.on('keydown', (cm, e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
ejecutar();
} }
}); });
if (tab === 'constructor' && !esquema.length) cargarEsquema(); });
// ── SCHEMA BROWSER ───────────────────────────────────────────────────
async function cargarEsquema() {
const btn = document.getElementById('btn-conectar');
const tree = document.getElementById('schema-tree');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
btn.disabled = true;
tree.innerHTML = '<p class="text-gray-400 italic text-center mt-4">Conectando...</p>';
try {
const resp = await fetch('/queries/esquema', { method: 'POST' });
const data = await resp.json();
if (data.error) {
tree.innerHTML = `<div class="p-2 text-red-500 text-xs">${data.error}</div>`;
showToast(data.error, 'error');
return;
}
esquema = data.tablas;
// feed schema into CodeMirror autocomplete
const tablesHint = {};
esquema.forEach(t => { tablesHint[t.tabla] = t.columnas.map(c => c.nombre); });
editor.setOption('hintOptions', { tables: tablesHint });
document.getElementById('schema-search-wrap').classList.remove('hidden');
renderSchemaTree(esquema);
btn.innerHTML = '<i class="fas fa-sync mr-1"></i>Refrescar';
showToast(`${esquema.length} tablas cargadas`);
} catch(e) {
tree.innerHTML = `<div class="p-2 text-red-500 text-xs">Error: ${e.message}</div>`;
showToast('Error: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
} }
// ───── PROBADOR ───── function filtrarTablas(q) {
function ejecutarProbador() { const filtradas = q.trim()
const sql = document.getElementById('probador-sql').value.trim(); ? esquema.filter(t => t.tabla.toLowerCase().includes(q.toLowerCase()))
: esquema;
renderSchemaTree(filtradas);
}
function renderSchemaTree(tablas) {
const tree = document.getElementById('schema-tree');
if (!tablas.length) {
tree.innerHTML = '<p class="text-gray-400 italic text-center mt-4 text-xs">Sin resultados</p>';
return;
}
tree.innerHTML = '';
tablas.forEach(t => {
const key = t.tabla.replace(/\W/g, '_');
const wrap = document.createElement('div');
wrap.className = 'mb-0.5';
wrap.innerHTML = `
<div class="schema-table-row flex items-center gap-1 px-2 py-1 rounded cursor-pointer group select-none"
onclick="toggleCols('${key}')">
<i id="chev-${key}" class="fas fa-chevron-right text-gray-300 text-xs transition-transform duration-150" style="width:10px"></i>
<i class="fas fa-table text-blue-400 text-xs"></i>
<span class="font-semibold text-gray-700 flex-1 text-xs">${t.tabla}</span>
<span class="text-gray-300 text-xs">${t.columnas.length}</span>
<button onclick="event.stopPropagation();selectAll('${t.tabla}',${JSON.stringify(t.columnas.map(c=>c.nombre))})"
title="SELECT *" class="hidden group-hover:inline text-blue-400 hover:text-blue-600 text-xs ml-0.5">
<i class="fas fa-code"></i>
</button>
</div>
<div id="cols-${key}" class="hidden ml-4 border-l border-gray-200 pl-2">
${t.columnas.map(c => `
<div class="schema-col flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs"
onclick="insertarTexto('${c.nombre}')" title="Insertar ${c.nombre}">
<i class="fas fa-columns text-gray-300" style="font-size:9px;width:10px"></i>
<span class="text-gray-700 flex-1">${c.nombre}</span>
<span class="text-gray-400" style="font-size:10px">${c.tipo}</span>
</div>`).join('')}
</div>`;
tree.appendChild(wrap);
});
}
function toggleCols(key) {
const div = document.getElementById('cols-' + key);
const chev = document.getElementById('chev-' + key);
const open = !div.classList.contains('hidden');
div.classList.toggle('hidden', open);
chev.style.transform = open ? '' : 'rotate(90deg)';
}
function insertarTexto(texto) {
if (!editor) return;
editor.replaceSelection(texto);
editor.focus();
}
function selectAll(tabla, columnas) {
const cols = columnas.map(c => ` ${c}`).join(',\n');
const sql = `SELECT\n${cols}\nFROM ${tabla}\nROWS 50`;
editor.setValue(sql);
editor.focus();
}
// ── EJECUTAR ─────────────────────────────────────────────────────────
async function ejecutar() {
const sql = editor.getValue().trim();
if (!sql) { showToast('Escribí una consulta SQL', 'warning'); return; } if (!sql) { showToast('Escribí una consulta SQL', 'warning'); return; }
ejecutarSql(sql, 'probador-resultados', 'probador-info');
}
function limpiarProbador() { const resultDiv = document.getElementById('result-table');
document.getElementById('probador-sql').value = ''; const infoDiv = document.getElementById('result-info');
document.getElementById('probador-resultados').innerHTML = ''; const statusBar = document.getElementById('status-bar');
document.getElementById('probador-info').innerHTML = ''; const btnRun = document.getElementById('btn-run');
}
async function ejecutarSql(sql, outputId, infoId) { btnRun.disabled = true;
const outputDiv = document.getElementById(outputId); btnRun.innerHTML = '<i class="fas fa-spinner fa-spin text-xs mr-2"></i>Ejecutando...';
const infoDiv = document.getElementById(infoId); resultDiv.innerHTML = '<div class="flex items-center justify-center h-full text-gray-400 text-sm gap-2"><i class="fas fa-spinner fa-spin"></i> Ejecutando...</div>';
outputDiv.innerHTML = '<div class="p-4 text-center"><i class="fas fa-spinner fa-spin mr-2"></i>Ejecutando...</div>'; infoDiv.textContent = '';
if (infoDiv) infoDiv.innerHTML = ''; statusBar.innerHTML = '';
const t0 = performance.now();
try { try {
const form = new FormData(); const form = new FormData();
form.append('sql', sql); form.append('sql', sql);
const resp = await fetch('/queries/run-sql', { method: 'POST', body: form }); const resp = await fetch('/queries/run-sql', { method: 'POST', body: form });
const data = await resp.json(); const data = await resp.json();
const ms = performance.now() - t0;
const elapsed = ms < 1000 ? `${Math.round(ms)}ms` : `${(ms/1000).toFixed(2)}s`;
if (data.error) { if (data.error) {
outputDiv.innerHTML = `<div class="p-4 text-red-600 text-sm"><i class="fas fa-exclamation-circle mr-1"></i> ${data.error}</div>`; resultDiv.innerHTML = `<div class="p-4 text-red-600 text-sm flex items-start gap-2"><i class="fas fa-exclamation-circle mt-0.5"></i><pre class="whitespace-pre-wrap font-mono text-xs">${escHtml(data.error)}</pre></div>`;
statusBar.innerHTML = `<span class="text-red-500"><i class="fas fa-times-circle mr-1"></i>Error · ${elapsed}</span>`;
return; return;
} }
let html = '<table class="w-full text-xs border-collapse"><thead><tr class="bg-gray-100">';
data.columns.forEach(c => { html += '<th class="p-2 border text-left font-semibold whitespace-nowrap">' + c + '</th>'; }); if (!data.rows || !data.rows.length) {
resultDiv.innerHTML = '<div class="flex items-center justify-center h-full text-gray-400 text-sm">Sin resultados</div>';
infoDiv.textContent = '0 filas';
statusBar.innerHTML = `<span class="text-gray-500">0 filas · ${elapsed}</span>`;
return;
}
let html = '<table><thead><tr>';
data.columns.forEach(c => {
html += `<th class="text-gray-700 font-semibold">${escHtml(c)}</th>`;
});
html += '</tr></thead><tbody>'; html += '</tr></thead><tbody>';
data.rows.slice(0, 200).forEach(r => { data.rows.forEach(r => {
html += '<tr class="hover:bg-gray-50">'; html += '<tr>';
data.columns.forEach(c => { html += '<td class="p-2 border whitespace-nowrap">' + (r[c] ?? '') + '</td>'; }); data.columns.forEach(c => {
const v = r[c] ?? '';
const vs = String(v);
html += `<td title="${escAttr(vs)}">${escHtml(vs)}</td>`;
});
html += '</tr>'; html += '</tr>';
}); });
html += '</tbody></table>'; html += '</tbody></table>';
outputDiv.innerHTML = html; resultDiv.innerHTML = html;
if (infoDiv) infoDiv.innerHTML = `${data.total} registros (mostrando ${Math.min(data.total, 200)})`;
if (data.total > 200) infoDiv.innerHTML += ' <span class="text-yellow-600">- Resultado truncado a 200 filas</span>'; const shown = Math.min(data.total, 200);
const trunc = data.total > 200 ? ` <span class="text-yellow-600 font-medium">(truncado a 200)</span>` : '';
infoDiv.innerHTML = `${data.total} fila${data.total !== 1 ? 's' : ''}${trunc}`;
statusBar.innerHTML = `<span class="text-green-600 font-medium"><i class="fas fa-check-circle mr-1"></i>${shown} fila${shown !== 1 ? 's' : ''} · ${elapsed}</span>`;
} catch(e) { } catch(e) {
outputDiv.innerHTML = `<div class="p-4 text-red-600 text-sm">Error: ${e.message}</div>`; resultDiv.innerHTML = `<div class="p-4 text-red-600 text-sm">Error de red: ${escHtml(e.message)}</div>`;
} finally {
btnRun.disabled = false;
btnRun.innerHTML = '<i class="fas fa-play text-xs"></i> Ejecutar';
} }
} }
// ───── CONSTRUCTOR ───── function limpiar() {
async function cargarEsquema() { if (editor) editor.setValue('');
const btn = document.querySelector('#panel-constructor .bg-green-600'); document.getElementById('result-table').innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-300 select-none"><i class="fas fa-table text-4xl mb-3"></i><p class="text-sm">Ejecutá una consulta para ver resultados</p></div>';
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Cargando...'; document.getElementById('result-info').textContent = '';
try { document.getElementById('status-bar').innerHTML = '';
const resp = await fetch('/queries/esquema', { method: 'POST' });
const data = await resp.json();
if (data.error) { showToast(data.error, 'error'); return; }
esquema = data.tablas;
renderizarTablas();
} catch(e) { showToast('Error: ' + e.message, 'error'); }
finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-sync mr-1"></i> Cargar esquema'; }
} }
function renderizarTablas() { // ── GUARDAR / EDITAR ──────────────────────────────────────────────────
const div = document.getElementById('builder-tablas'); function abrirModalNueva() {
div.innerHTML = ''; editandoId = null;
esquema.forEach(t => { document.getElementById('form-save').action = '/queries/create';
const id = 'tab-' + t.tabla.replace(/[^a-zA-Z0-9]/g, '_'); document.getElementById('modal-title').textContent = 'Nueva consulta';
const checked = tablasSeleccionadas[t.tabla] ? 'checked' : ''; document.getElementById('form-name').value = document.getElementById('q-name').value;
const card = document.createElement('div'); document.getElementById('form-type').value = document.getElementById('q-type').value;
card.className = 'border rounded p-1.5 ' + (checked ? 'border-blue-300 bg-blue-50' : 'border-gray-200 bg-white'); document.getElementById('form-sql').value = editor ? editor.getValue() : '';
card.innerHTML = ` document.getElementById('form-desc').value = '';
<label class="flex items-center space-x-1 cursor-pointer"> document.getElementById('modal-save').classList.remove('hidden');
<input type="checkbox" ${checked} onchange="toggleTabla('${t.tabla}', this.checked)" class="rounded"> setTimeout(() => document.getElementById('form-name').focus(), 50);
<span class="font-medium text-xs">${t.tabla}</span>
<span class="text-gray-400 text-xs">(${t.columnas.length})</span>
</label>
<div id="${id}" class="ml-4 ${checked ? '' : 'hidden'}">${renderizarColumnas(t.tabla, t.columnas)}</div>`;
div.appendChild(card);
});
actualizarBuilder();
} }
function renderizarColumnas(tabla, columnas) { function guardar() {
let html = ''; const sql = editor ? editor.getValue().trim() : '';
columnas.forEach(c => { if (!sql) { showToast('No hay SQL para guardar', 'warning'); return; }
const sel = tablasSeleccionadas[tabla]?.columnas?.[c.nombre] ? 'checked' : ''; document.getElementById('form-sql').value = sql;
html += `<label class="flex items-center space-x-1 py-0.5 cursor-pointer hover:bg-blue-100 px-1 rounded text-xs"> document.getElementById('form-name').value = document.getElementById('q-name').value;
<input type="checkbox" ${sel} onchange="toggleColumna('${tabla}','${c.nombre}',this.checked)" class="rounded"> document.getElementById('form-type').value = document.getElementById('q-type').value;
<span>${c.nombre} <span class="text-gray-400">(${c.tipo}${c.longitud ? ','+c.longitud : ''})</span></span> if (editandoId) {
</label>`; document.getElementById('form-save').action = '/queries/update/' + editandoId;
}); document.getElementById('modal-title').textContent = 'Actualizar consulta';
return html; } else {
document.getElementById('form-save').action = '/queries/create';
document.getElementById('modal-title').textContent = 'Guardar consulta';
}
document.getElementById('modal-save').classList.remove('hidden');
setTimeout(() => document.getElementById('form-name').focus(), 50);
} }
function toggleTabla(tabla, checked) { function cerrarModal() {
if (checked) tablasSeleccionadas[tabla] = { alias: 't' + (++aliasCounter), columnas: {} }; document.getElementById('modal-save').classList.add('hidden');
else delete tablasSeleccionadas[tabla];
renderizarTablas(); actualizarBuilder();
} }
function toggleColumna(tabla, columna, checked) { function cargarConsulta(sql, name, type) {
if (!tablasSeleccionadas[tabla]) return; if (editor) editor.setValue(sql);
if (checked) tablasSeleccionadas[tabla].columnas[columna] = true; document.getElementById('q-name').value = name;
else delete tablasSeleccionadas[tabla].columnas[columna]; document.getElementById('q-type').value = type;
renderizarTablas(); actualizarBuilder(); editor && editor.focus();
} }
function agregarFiltro() { function editarConsulta(id, name, type, sql, desc) {
filtros.push({ campo: '', operador: '=', parametro: '', condicion: 'AND' }); editandoId = id;
renderizarFiltros(); actualizarBuilder(); cargarConsulta(sql, name, type);
document.getElementById('form-desc').value = desc;
document.getElementById('btn-cancel').classList.replace('hidden', 'flex');
document.getElementById('editing-label').textContent = `Editando: ${name}`;
document.getElementById('editing-label').classList.remove('hidden');
} }
function renderizarFiltros() { function cancelarEdicion() {
const div = document.getElementById('builder-where'); editandoId = null;
div.innerHTML = ''; limpiar();
if (!filtros.length) { div.innerHTML = '<p class="text-gray-400 italic">Sin filtros</p>'; return; } document.getElementById('q-name').value = '';
filtros.forEach((f, i) => { document.getElementById('btn-cancel').classList.replace('flex', 'hidden');
const opts = []; document.getElementById('editing-label').classList.add('hidden');
Object.entries(tablasSeleccionadas).forEach(([t, info]) => {
const tInfo = esquema.find(e => e.tabla === t);
if (tInfo) tInfo.columnas.forEach(c => {
opts.push({ value: `${t}.${c.nombre}`, label: `${info.alias}.${c.nombre}` });
});
});
const row = document.createElement('div');
row.className = 'flex items-center space-x-1 text-xs';
row.innerHTML = `
<select onchange="actualizarFiltro(${i},'campo',this.value)" class="border rounded px-1 py-0.5 text-xs max-w-[100px]">
<option value="">--campo--</option>
${opts.map(o => `<option value="${o.value}" ${f.campo===o.value?'selected':''}>${o.label}</option>`).join('')}
</select>
<select onchange="actualizarFiltro(${i},'operador',this.value)" class="border rounded px-1 py-0.5 text-xs w-14">
${['=','>','<','>=','<=','<>','LIKE','BETWEEN'].map(o => `<option ${f.operador===o?'selected':''}>${o}</option>`).join('')}
</select>
<input type="text" value="${f.parametro}" onchange="actualizarFiltro(${i},'parametro',this.value)" placeholder=":param" class="border rounded px-1 py-0.5 text-xs font-mono w-20">
<button onclick="filtros.splice(${i},1);renderizarFiltros();actualizarBuilder();" class="text-red-500 hover:text-red-700"><i class="fas fa-times"></i></button>`;
div.appendChild(row);
if (i < filtros.length - 1) {
const sep = document.createElement('div');
sep.className = 'ml-1';
sep.innerHTML = `<select onchange="actualizarFiltro(${i+1},'condicion',this.value)" class="border rounded px-1 py-0.5 text-xs w-14">
<option value="AND" ${filtros[i+1]?.condicion==='AND'?'selected':''}>AND</option>
<option value="OR" ${filtros[i+1]?.condicion==='OR'?'selected':''}>OR</option>
</select>`;
div.appendChild(sep);
}
});
} }
function actualizarFiltro(i, prop, val) { // ── UTILS ─────────────────────────────────────────────────────────────
if (!filtros[i]) filtros[i] = { campo: '', operador: '=', parametro: '', condicion: 'AND' }; function escHtml(s) {
filtros[i][prop] = val; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
actualizarBuilder(); }
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
} }
function generarSql() { // close modal on backdrop click
const tabs = Object.entries(tablasSeleccionadas); document.getElementById('modal-save').addEventListener('click', e => {
if (!tabs.length) return { sql: '-- Seleccioná al menos una tabla', params: [] }; if (e.target === e.currentTarget) cerrarModal();
const selects = [], froms = [], allParams = new Set(); });
tabs.forEach(([tabla, info]) => {
froms.push({ tabla, alias: info.alias });
Object.keys(info.columnas).forEach(c => selects.push(`${info.alias}.${c}`));
});
if (!selects.length) return { sql: '-- Seleccioná al menos una columna', params: [] };
let sql = 'SELECT\n ' + selects.join(',\n ') + '\nFROM ';
sql += froms.map((f, i) => i === 0 ? `${f.tabla} ${f.alias}` : `JOIN ${f.tabla} ${f.alias} ON 1=1`).join('\n');
const wheres = [];
filtros.forEach(f => {
if (f.campo && f.parametro) {
const [t, c] = f.campo.split('.');
const info = tablasSeleccionadas[t];
if (info) { wheres.push(`${info.alias}.${c} ${f.operador} ${f.parametro}`); if (f.parametro.startsWith(':')) allParams.add(f.parametro); }
}
});
if (wheres.length) sql += '\nWHERE ' + wheres.join(' ');
sql += '\nROWS 200';
return { sql, params: Array.from(allParams) };
}
function actualizarBuilder() {
const { sql, params } = generarSql();
document.getElementById('builder-sql').value = sql;
document.getElementById('builder-params').textContent = params.length ? 'Params: ' + params.join(', ') : '';
const tabs = Object.entries(tablasSeleccionadas);
const div = document.getElementById('builder-columnas');
if (!tabs.length) { div.innerHTML = '<p class="text-gray-400 italic">Seleccioná una tabla</p>'; return; }
let html = '';
tabs.forEach(([tabla, info]) => {
const cols = Object.keys(info.columnas);
if (cols.length) html += `<div class="mb-0.5 text-xs"><span class="font-medium">${info.alias}.</span> ${cols.map(c => `<code class="bg-blue-100 px-1 rounded">${c}</code>`).join(' ')}</div>`;
});
div.innerHTML = html || '<p class="text-gray-400 italic">Seleccioná columnas</p>';
}
function ejecutarBuilder() {
const sql = document.getElementById('builder-sql').value;
if (!sql || sql.startsWith('--')) { showToast('No hay SQL generado', 'warning'); return; }
ejecutarSql(sql, 'builder-resultados', null);
}
function usarSqlEditor() {
const sql = document.getElementById('builder-sql').value;
if (!sql || sql.startsWith('--')) { showToast('No hay SQL generado', 'warning'); return; }
document.getElementById('q_text').value = sql;
document.getElementById('probador-sql').value = sql;
cambiarTab('probador');
showToast('SQL enviado al Probador');
}
// ───── CONSULTAS GUARDADAS ─────
function cargarConsulta(id, name, type, text) {
document.getElementById('query_id').value = id;
document.getElementById('q_name').value = name;
document.getElementById('q_type').value = type;
document.getElementById('q_text').value = text;
document.getElementById('q_desc').value = '';
document.getElementById('probador-sql').value = text;
document.getElementById('btn-cancel').classList.remove('hidden');
document.querySelector('#panel-editor form').action = '/queries/update/' + id;
cambiarTab('probador');
}
function cancelEdit() {
document.getElementById('query_id').value = '';
document.getElementById('q_name').value = '';
document.getElementById('q_text').value = '';
document.getElementById('q_desc').value = '';
document.getElementById('btn-cancel').classList.add('hidden');
document.querySelector('#panel-editor form').action = '/queries/create';
}
async function ejecutarConsultaId(id) {
const form = new FormData(); form.append('sql', '');
const resp = await fetch('/queries/test', { method: 'POST', body: (()=>{const f=new FormData();f.append('query_id',id);return f;})() });
const data = await resp.json();
if (data.error) { showToast(data.error, 'error'); return; }
let html = '<table class="w-full text-xs border-collapse"><thead><tr class="bg-gray-100">';
data.columns.forEach(c => { html += '<th class="p-2 border text-left font-semibold">' + c + '</th>'; });
html += '</tr></thead><tbody>';
data.rows.slice(0,200).forEach(r => {
html += '<tr class="hover:bg-gray-50">';
data.columns.forEach(c => { html += '<td class="p-2 border whitespace-nowrap">' + (r[c] ?? '') + '</td>'; });
html += '</tr>';
});
html += '</tbody></table>';
const modal = document.createElement('div');
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50';
modal.innerHTML = '<div class="bg-white rounded-xl shadow-xl max-w-5xl w-full mx-4 max-h-[80vh] overflow-auto p-4"><div class="flex justify-between items-center mb-3"><h3 class="font-semibold text-sm">Resultado</h3><button onclick="this.closest(\'.fixed\').remove()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-lg"></i></button></div>' + html + '<p class="text-xs text-gray-500 mt-2">Total: ' + data.total + ' registros (mostrando ' + Math.min(data.total, 200) + ')</p></div>';
document.body.appendChild(modal);
}
</script> </script>
{% endblock %} {% endblock %}
+9 -23
View File
@@ -4,11 +4,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from fastapi import FastAPI, Request, Depends from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from app.database import init_db from app.database import init_db
@@ -16,14 +14,6 @@ from app.auth import decode_token
app = FastAPI(title="RIPS Manager", version="1.0.0") app = FastAPI(title="RIPS Manager", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
templates = Jinja2Templates( templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(__file__), "app", "templates") directory=os.path.join(os.path.dirname(__file__), "app", "templates")
) )
@@ -31,8 +21,7 @@ app.state.templates = templates
def get_user_from_request(request: Request): def get_user_from_request(request: Request):
cookies = dict(request.cookies) token = request.cookies.get("token")
token = cookies.get("token")
if not token: if not token:
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "): if auth.startswith("Bearer "):
@@ -47,21 +36,15 @@ def get_user_from_request(request: Request):
@app.middleware("http") @app.middleware("http")
async def auth_middleware(request: Request, call_next): async def auth_middleware(request: Request, call_next):
public_paths = ["/auth/login", "/auth/register", "/auth/api/login"] if request.url.path.startswith("/auth") or request.url.path.startswith("/static"):
if request.url.path in public_paths or request.url.path.startswith("/static"):
return await call_next(request)
if request.url.path.startswith("/auth"):
return await call_next(request) return await call_next(request)
user = get_user_from_request(request) user = get_user_from_request(request)
print(f" [AUTH] path={request.url.path} user={user['username'] if user else None} cookies={dict(request.cookies)}", flush=True) print(f" [AUTH] path={request.url.path} user={user['username'] if user else None}", flush=True)
if not user: if not user:
if request.url.path.startswith("/api/"): if request.url.path.startswith("/api/"):
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
print(f" [AUTH] -> 401 JSON for /api/ path", flush=True)
return JSONResponse({"detail": "Not authenticated"}, status_code=401) return JSONResponse({"detail": "Not authenticated"}, status_code=401)
print(f" [AUTH] -> 307 redirect to /auth/login", flush=True)
return RedirectResponse(url="/auth/login") return RedirectResponse(url="/auth/login")
request.state.user = user request.state.user = user
@@ -71,6 +54,10 @@ async def auth_middleware(request: Request, call_next):
@app.on_event("startup") @app.on_event("startup")
async def startup(): async def startup():
init_db() init_db()
from app.routes.config import ensure_defaults as config_defaults
from app.routes.queries import ensure_defaults as query_defaults
config_defaults()
query_defaults()
@app.get("/") @app.get("/")
@@ -78,7 +65,6 @@ async def root():
return RedirectResponse(url="/dashboard") return RedirectResponse(url="/dashboard")
# Register routes
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation
app.include_router(auth.router) app.include_router(auth.router)
@@ -96,7 +82,7 @@ if __name__ == "__main__":
hostname = socket.gethostname() hostname = socket.gethostname()
try: try:
lan_ip = socket.gethostbyname(hostname) lan_ip = socket.gethostbyname(hostname)
except: except Exception:
lan_ip = "0.0.0.0" lan_ip = "0.0.0.0"
print(f" Local: http://localhost:8080") print(f" Local: http://localhost:8080")
print(f" Red: http://{lan_ip}:8080") print(f" Red: http://{lan_ip}:8080")