Files
2026-09-03 16:29:29 -05:00

391 lines
14 KiB
Python

from flask import Flask, request, jsonify, send_from_directory
from flask_cors import CORS
import requests
import sqlite3
import threading
import time
import os
from datetime import datetime
# Servir archivos estáticos desde la carpeta fronted (sin static_url_path para controlar rutas manualmente)
app = Flask(__name__, static_folder="../fronted")
CORS(app)
# URL por defecto (se puede sobreescribir vía /config o por el body en /test_gs)
API_URL = "https://script.google.com/macros/s/AKfycbwSLQWgSxHy51_vVopRyKss0UlrqCuKpO8LhxaEBztzsk-Idc-_LnvjsT5dUEwTlb9r/exec"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_DIR = os.path.join(BASE_DIR, "..", "db")
INTERVALO_SYNC = 600 # 10 minutos en segundos
# ============ CONFIGURACIÓN DE MÓDULOS ============
# Cada módulo tiene su PROPIA base de datos SQLite y su hoja de Google Sheets
MODULOS = {
"cajaMenor": {
"db": os.path.join(DB_DIR, "cajaMenor.db"),
"tabla": "movimientos",
"hoja_sheets": "CajaMenor"
},
"libroDiario": {
"db": os.path.join(DB_DIR, "libroDiario.db"),
"tabla": "movimientos",
"hoja_sheets": "libroDiario"
}
}
# ============ BASE DE DATOS ============
def get_db(modulo):
"""Abre la base de datos del módulo indicado"""
os.makedirs(DB_DIR, exist_ok=True)
conn = sqlite3.connect(modulo["db"])
conn.row_factory = sqlite3.Row
return conn
def inicializar_db():
for modulo in MODULOS.values():
conn = get_db(modulo)
conn.execute(f"""
CREATE TABLE IF NOT EXISTS {modulo["tabla"]} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fecha TEXT NOT NULL,
tipo TEXT NOT NULL,
descripcion TEXT NOT NULL,
monto INTEGER NOT NULL,
sincronizado INTEGER DEFAULT 0,
borrado INTEGER DEFAULT 0,
creado_en TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
# Migración para DBs existentes: agregar columna borrado si no existe
for modulo in MODULOS.values():
conn = get_db(modulo)
columnas = [c[1] for c in conn.execute(f"PRAGMA table_info({modulo['tabla']})").fetchall()]
if "borrado" not in columnas:
conn.execute(f"ALTER TABLE {modulo['tabla']} ADD COLUMN borrado INTEGER DEFAULT 0")
conn.commit()
conn.close()
# ============ SERVIR EL FRONTEND ============
import os.path
@app.route("/")
def index():
return send_from_directory(os.path.join(app.static_folder, "html"), "index.html")
@app.route("/<path:archivo>")
def archivos_estaticos(archivo):
base_dir = app.static_folder
# Extraer solo el nombre del archivo (sin carpetas previas)
nombre_archivo = os.path.basename(archivo)
# Determinar en qué carpeta buscar según la extensión
if nombre_archivo.endswith(".html"):
carpeta = "html"
elif nombre_archivo.endswith(".css"):
carpeta = "css"
elif nombre_archivo.endswith(".js"):
carpeta = "js"
else:
carpeta = ""
ruta_completa = os.path.join(base_dir, carpeta, nombre_archivo) if carpeta else os.path.join(base_dir, nombre_archivo)
# Debug: log en consola
print(f"[STATIC] Recibido: {archivo} | Buscando: {ruta_completa}")
if os.path.exists(ruta_completa):
directorio = os.path.dirname(ruta_completa)
nombre_archivo_final = os.path.basename(ruta_completa)
return send_from_directory(directorio, nombre_archivo_final)
else:
return f"Archivo no encontrado: {ruta_completa}", 404
# ============ LEER MOVIMIENTOS (desde SQLite - instantáneo) ============
@app.route("/leer", methods=["GET"])
def leer():
nombre = request.args.get("hoja", "cajaMenor")
modulo = MODULOS.get(nombre)
if not modulo:
return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400
conn = get_db(modulo)
filas = conn.execute(
f"SELECT * FROM {modulo['tabla']} WHERE borrado = 0 ORDER BY fecha DESC, id DESC"
).fetchall()
conn.close()
datos = [
{
"id": fila["id"],
"Fecha": fila["fecha"],
"Tipo": fila["tipo"],
"Descripción": fila["descripcion"],
"Monto": fila["monto"],
"sincronizado": fila["sincronizado"]
}
for fila in filas
]
return jsonify({"datos": datos})
# ============ GUARDAR MOVIMIENTO (SQLite - instantáneo) ============
@app.route("/guardar", methods=["POST"])
def guardar():
body = request.json
nombre = body.get("hoja", "cajaMenor")
modulo = MODULOS.get(nombre)
if not modulo:
return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400
datos = body.get("datos", {})
conn = get_db(modulo)
cursor = conn.execute(
f"INSERT INTO {modulo['tabla']} (fecha, tipo, descripcion, monto) VALUES (?, ?, ?, ?)",
(
datos.get("Fecha", ""),
datos.get("Tipo", ""),
datos.get("Descripción", ""),
int(float(datos.get("Monto", 0)))
)
)
conn.commit()
nuevo_id = cursor.lastrowid
conn.close()
return jsonify({"ok": True, "id": nuevo_id, "mensaje": "Guardado localmente, se sincronizará con Google Sheets"})
# ============ ACTUALIZAR MOVIMIENTO ============
@app.route("/actualizar", methods=["PUT"])
def actualizar():
body = request.json
nombre = body.get("hoja")
modulo = MODULOS.get(nombre)
if not modulo:
return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400
datos = body.get("datos", {})
id_reg = body.get("id")
conn = get_db(modulo)
fila = conn.execute(f"SELECT sincronizado FROM {modulo['tabla']} WHERE id = ?", (id_reg,)).fetchone()
if not fila:
conn.close()
return jsonify({"error": "Registro no encontrado"}), 404
conn.execute(
f"UPDATE {modulo['tabla']} SET fecha=?, tipo=?, descripcion=?, monto=? WHERE id=?",
(
datos.get("Fecha", ""),
datos.get("Tipo", ""),
datos.get("Descripción", ""),
int(float(datos.get("Monto", 0))),
id_reg
)
)
# Si ya había subido a Sheets, marcar pendiente para que el sync lo actualice allá
if fila["sincronizado"] == 1:
conn.execute(f"UPDATE {modulo['tabla']} SET sincronizado = 0 WHERE id = ?", (id_reg,))
conn.commit()
conn.close()
return jsonify({"ok": True})
# ============ BORRAR MOVIMIENTO ============
@app.route("/borrar", methods=["DELETE"])
def borrar():
body = request.json
nombre = body.get("hoja")
modulo = MODULOS.get(nombre)
if not modulo:
return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400
id_reg = body.get("id")
conn = get_db(modulo)
fila = conn.execute(f"SELECT sincronizado FROM {modulo['tabla']} WHERE id = ?", (id_reg,)).fetchone()
if not fila:
conn.close()
return jsonify({"error": "Registro no encontrado"}), 404
if fila["sincronizado"] == 1:
# Ya está en Sheets: soft delete, el sync lo borrará allá
conn.execute(f"UPDATE {modulo['tabla']} SET borrado = 1, sincronizado = 0 WHERE id = ?", (id_reg,))
else:
# Nunca subió a Sheets: borrado definitivo
conn.execute(f"DELETE FROM {modulo['tabla']} WHERE id = ?", (id_reg,))
conn.commit()
conn.close()
return jsonify({"ok": True})
# ============ SINCRONIZACIÓN CON GOOGLE SHEETS (background) ============
def sincronizar_tabla(nombre_modulo, modulo):
"""Sube los pendientes de un módulo a su hoja de Google Sheets"""
conn = get_db(modulo)
pendientes = conn.execute(
f"SELECT * FROM {modulo['tabla']} WHERE sincronizado = 0"
).fetchall()
if not pendientes:
conn.close()
return 0
ids_ok = []
for fila in pendientes:
# Determinar la acción: borrar / actualizar / nuevo
if fila["borrado"] == 1:
accion = "borrar"
datos = None
else:
# Si la última columna Sheets ya la tiene, es actualizar; si no, nuevo
# (el Apps Script busca por id; si no existe, agrega fila nueva)
accion = "actualizar"
datos = {
"Fecha": fila["fecha"],
"Tipo": fila["tipo"],
"Descripción": fila["descripcion"],
"Monto": str(fila["monto"])
}
payload = {
"hoja": modulo["hoja_sheets"],
"accion": accion,
"id": fila["id"],
"datos": datos
}
try:
respuesta = requests.post(API_URL, json=payload, allow_redirects=True, timeout=30)
print(f"[SYNC] POST {nombre_modulo} id={fila['id']} status={respuesta.status_code} content-type={respuesta.headers.get('content-type','?')}")
if respuesta.status_code != 200 or "html" in respuesta.headers.get("content-type", "").lower():
print(f"[SYNC] Respuesta inesperada (HTML?): {respuesta.text[:300]}")
continue
resultado = respuesta.json()
if resultado.get("ok"):
if fila["borrado"] == 1:
# Borrado exitoso en Sheets → eliminar definitivamente de SQLite
conn.execute(f"DELETE FROM {modulo['tabla']} WHERE id = ?", (fila["id"],))
conn.commit()
print(f"[SYNC] {nombre_modulo}: id {fila['id']} borrado de Google Sheets ({resultado.get('mensaje', '')})")
else:
ids_ok.append(fila["id"])
else:
print(f"[SYNC] Apps Script respondió error: {resultado}")
except Exception as e:
print(f"[SYNC] Error subiendo {nombre_modulo} id {fila['id']}: {e}")
if ids_ok:
placeholders = ",".join("?" * len(ids_ok))
conn.execute(f"UPDATE {modulo['tabla']} SET sincronizado = 1 WHERE id IN ({placeholders})", ids_ok)
conn.commit()
print(f"[SYNC {datetime.now().strftime('%H:%M:%S')}] {nombre_modulo}: {len(ids_ok)} registros subidos a Google Sheets")
conn.close()
return len(pendientes) - len(ids_ok) # cuántos quedaron pendientes
def sincronizar_pendientes():
"""Sincroniza todos los módulos con Google Sheets"""
total_pendientes = 0
for nombre, modulo in MODULOS.items():
total_pendientes += sincronizar_tabla(nombre, modulo)
if total_pendientes == 0:
print(f"[SYNC {datetime.now().strftime('%H:%M:%S')}] Todo sincronizado")
return total_pendientes
def ciclo_sincronizacion():
"""Hilo que corre la sincronización cada INTERVALO_SYNC segundos"""
while True:
time.sleep(INTERVALO_SYNC)
sincronizar_pendientes()
# ============ FORZAR SINCRONIZACIÓN MANUAL ============
@app.route("/sincronizar", methods=["POST"])
def sincronizar_ahora():
pendientes = sincronizar_pendientes()
return jsonify({"ok": True, "pendientes": pendientes})
# ============ MARCAR TODO COMO PENDIENTE (re-subir a Sheets) ============
@app.route("/reset_sync", methods=["POST"])
def reset_sync():
"""Marca todos los registros activos como no sincronizados.
Útil después de limpiar la hoja de Google para re-subir todo desde cero."""
body = request.json or {}
nombre = body.get("hoja")
if nombre:
modulo = MODULOS.get(nombre)
if not modulo:
return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400
modulos = {nombre: modulo}
else:
modulos = MODULOS
total = 0
for modulo in modulos.values():
conn = get_db(modulo)
cursor = conn.execute(f"UPDATE {modulo['tabla']} SET sincronizado = 0 WHERE borrado = 0")
total += cursor.rowcount
conn.commit()
conn.close()
return jsonify({"ok": True, "marcados": total, "mensaje": "Ahora llama a /sincronizar para re-subir todo"})
# ============ CONFIGURAR URL DE GOOGLE SHEETS ============
@app.route("/config", methods=["POST"])
def configurar_url():
"""Permite cambiar la URL de Apps Script en runtime."""
global API_URL
body = request.json or {}
url = body.get("api_url")
if not url:
return jsonify({"error": "Falta api_url en el body"}), 400
API_URL = url
return jsonify({"ok": True, "api_url": API_URL})
# ============ PROBAR CONEXIÓN DIRECTA A GOOGLE SHEETS ============
@app.route("/test_gs", methods=["POST"])
def test_gs():
"""Endpoint para probar manualmente una URL de Apps Script.
Si envías api_url en el body, se usa esa URL; si no, la global.
"""
body = request.json or {}
url = body.get("api_url", API_URL)
payload = {
"hoja": body.get("hoja", "CajaMenor"),
"accion": body.get("accion", "nuevo"),
"id": body.get("id", "777"),
"datos": body.get("datos", {
"Fecha": "2026-09-03",
"Tipo": "Ingreso",
"Descripción": "Prueba backend",
"Monto": "1"
})
}
try:
respuesta = requests.post(url, json=payload, allow_redirects=True, timeout=30)
return jsonify({
"url_usada": url,
"status": respuesta.status_code,
"content_type": respuesta.headers.get("content-type"),
"primeros_300_chars": respuesta.text[:300],
"json_parseable": respuesta.headers.get("content-type", "").lower().startswith("application/json")
})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ============ INICIO ============
if __name__ == "__main__":
inicializar_db()
# Lanzar el hilo de sincronización
hilo = threading.Thread(target=ciclo_sincronizacion, daemon=True)
hilo.start()
print(f"🚀 Servidor iniciado. Sincronización con Google Sheets cada {INTERVALO_SYNC // 60} minutos")
print(f"📦 Módulos activos: {', '.join(MODULOS.keys())}")
# Primera sincronización al arrancar (por si quedó algo pendiente de antes)
sincronizar_pendientes()
app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False)