Files
gkachele-saas/demo/app_backup_hash_20260117.py.bak
2026-01-17 11:40:17 +01:00

1701 lines
63 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
GKACHELE™ SaaS PageBuilder - Sistema Profesional
© 2025 GKACHELE™. Todos los derechos reservados.
Desarrollado desde noviembre 2025 por GKACHELE
Código propiedad de GKACHELE © 2025 - Prohibida su reproducción sin autorización
"""
import os
import json
import sqlite3
import secrets
from datetime import datetime
from flask import Flask, render_template, request, jsonify, session, redirect, url_for, send_from_directory
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__, template_folder='templates', static_folder='static')
app.secret_key = 'demo-secret-key-2025'
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# Manejador de errores global - siempre devolver JSON
@app.errorhandler(500)
def handle_500(e):
print(f"❌ Error 500: {e}")
import traceback
traceback.print_exc()
# Asegurar que siempre devuelva JSON
response = jsonify({'success': False, 'error': 'Error interno del servidor'})
response.status_code = 500
return response
@app.errorhandler(404)
def handle_404(e):
response = jsonify({'success': False, 'error': 'No encontrado'})
response.status_code = 404
return response
@app.errorhandler(403)
def handle_403(e):
response = jsonify({'success': False, 'error': 'No autorizado'})
response.status_code = 403
return response
# Manejador para excepciones no capturadas - FORZAR JSON
@app.errorhandler(Exception)
def handle_exception(e):
print(f"❌❌❌ EXCEPCIÓN NO CAPTURADA: {e}")
import traceback
traceback.print_exc()
# SIEMPRE devolver JSON, nunca HTML
response = jsonify({'success': False, 'error': f'Error: {str(e)}'})
response.status_code = 500
response.headers['Content-Type'] = 'application/json'
return response
# Forzar JSON en TODAS las rutas /register, /login, /api/*
@app.before_request
def before_request():
# Si es POST a /register o /login, forzar JSON
if request.method == 'POST' and (request.path.startswith('/register') or
request.path.startswith('/login') or
request.path.startswith('/api/')):
try:
request.get_json(force=True)
except:
pass
@app.after_request
def add_header(response):
"""Permitir iframes para el customizer"""
# Eliminar X-Frame-Options para permitir iframe
response.headers.pop('X-Frame-Options', None)
response.headers['Content-Security-Policy'] = "frame-ancestors *;"
return response
from functools import wraps
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
if request.is_json:
return jsonify({'success': False, 'error': 'No autorizado'}), 401
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
# Directorios
BASE_DIR = os.path.dirname(__file__)
DATABASE_DIR = os.path.join(BASE_DIR, 'database')
SITES_DIR = os.path.join(BASE_DIR, 'sites')
THEMES_DIR = os.path.join(BASE_DIR, 'themes')
STATIC_DIR = os.path.join(BASE_DIR, 'static')
UPLOADS_DIR = os.path.join(BASE_DIR, 'uploads')
# ============================================================================
# SISTEMA DE TEMPLATES (Estilo WordPress) - GKACHELE™
# ============================================================================
def scan_available_themes():
"""Escanear todos los templates disponibles y cargar sus configuraciones"""
themes = {}
if not os.path.exists(THEMES_DIR):
return themes
for theme_dir in os.listdir(THEMES_DIR):
theme_path = os.path.join(THEMES_DIR, theme_dir)
if not os.path.isdir(theme_path) or theme_dir.startswith('_'):
continue # Ignorar archivos y carpetas que empiezan con _
config_path = os.path.join(theme_path, 'config.json')
template_path = os.path.join(theme_path, 'template.html')
# Verificar que tenga config.json y template.html
if not os.path.exists(config_path) or not os.path.exists(template_path):
continue
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
themes[theme_dir] = {
'id': theme_dir,
'name': config.get('name', theme_dir),
'description': config.get('description', ''),
'rubro': config.get('rubro', 'general'),
'sections': config.get('sections', []),
'colors': config.get('colors', {}),
'typography': config.get('typography', {}),
'features': config.get('features', {}),
'preview': f'/themes/{theme_dir}/preview.jpg' if os.path.exists(os.path.join(theme_path, 'preview.jpg')) else None
}
except Exception as e:
print(f"⚠️ Error cargando template {theme_dir}: {e}")
continue
return themes
def get_theme_config(theme_id):
"""Obtener configuración de un template específico"""
config_path = os.path.join(THEMES_DIR, theme_id, 'config.json')
if not os.path.exists(config_path):
return None
try:
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"⚠️ Error cargando config de {theme_id}: {e}")
return None
def get_themes_by_rubro(rubro):
"""Obtener templates filtrados por rubro"""
all_themes = scan_available_themes()
return {k: v for k, v in all_themes.items() if v.get('rubro') == rubro or v.get('rubro') == 'general'}
# Crear directorios
# NOTA: Ya no se crean bases de datos separadas (cliente-X.db)
# TODO está en main.db (sistema multi-tenant)
for d in [DATABASE_DIR, SITES_DIR, THEMES_DIR, UPLOADS_DIR]:
os.makedirs(d, exist_ok=True)
# DB principal
MAIN_DB = os.path.join(DATABASE_DIR, 'main.db')
def init_db():
"""Inicializar base de datos principal - TODO EN UNA SOLA DB (sistema multi-tenant)"""
try:
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Usuarios (todos los clientes en una sola tabla) - Sistema GKACHELE™
c.execute('''CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT DEFAULT 'subscriber',
plan TEXT DEFAULT 'base',
rubro TEXT DEFAULT 'gimnasio',
status TEXT DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
# Añadir columna role si no existe (migración)
try:
c.execute('ALTER TABLE users ADD COLUMN role TEXT DEFAULT "subscriber"')
except:
pass # Columna ya existe
# Añadir columna status si no existe (migración)
try:
c.execute('ALTER TABLE users ADD COLUMN status TEXT DEFAULT "active"')
except:
pass # Columna ya existe
# Crear primer admin si no existe (user_id = 1)
c.execute('SELECT COUNT(*) FROM users WHERE id = 1')
if c.fetchone()[0] == 0:
from werkzeug.security import generate_password_hash
admin_password = generate_password_hash('admin123') # Cambiar después
c.execute('''INSERT INTO users (id, email, password, role, plan, rubro, status)
VALUES (1, 'admin@gkachele.com', ?, 'administrator', 'premium', 'admin', 'active')''',
(admin_password,))
print("✅ Usuario administrador creado: admin@gkachele.com / admin123")
# Sitios (todos los sitios, filtrados por user_id)
c.execute('''CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
slug TEXT UNIQUE NOT NULL,
theme TEXT DEFAULT 'default',
status TEXT DEFAULT 'draft',
content_json TEXT,
repo_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)''')
# Migración: Añadir repo_url si no existe
try:
c.execute('ALTER TABLE sites ADD COLUMN repo_url TEXT')
except:
pass # Ya existe
# Solicitudes
c.execute('''CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (site_id) REFERENCES sites(id),
FOREIGN KEY (user_id) REFERENCES users(id)
)''')
# Media (fotos, imágenes del cliente)
c.execute('''CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
site_id INTEGER NOT NULL,
filename TEXT NOT NULL,
filepath TEXT NOT NULL,
file_type TEXT DEFAULT 'image',
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (site_id) REFERENCES sites(id)
)''')
# Content (contenido por sección, filtrado por user_id y site_id)
c.execute('''CREATE TABLE IF NOT EXISTS content (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
site_id INTEGER NOT NULL,
section TEXT NOT NULL,
data_json TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (site_id) REFERENCES sites(id)
)''')
# Settings (configuraciones por usuario/sitio)
c.execute('''CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
site_id INTEGER,
key TEXT NOT NULL,
value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (site_id) REFERENCES sites(id),
UNIQUE(user_id, site_id, key)
)''')
# Menus (sistema de menús dinámicos)
c.execute('''CREATE TABLE IF NOT EXISTS menus (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
site_id INTEGER NOT NULL,
location TEXT NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
order_index INTEGER DEFAULT 0,
parent_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (site_id) REFERENCES sites(id),
FOREIGN KEY (parent_id) REFERENCES menus(id)
)''')
# Widgets (áreas de widgets dinámicos)
c.execute('''CREATE TABLE IF NOT EXISTS widgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
site_id INTEGER NOT NULL,
area TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT,
content TEXT,
order_index INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (site_id) REFERENCES sites(id)
)''')
conn.commit()
conn.close()
print("✅ Base de datos inicializada (multi-tenant - todos los clientes en main.db)")
except Exception as e:
print(f"❌ Error inicializando DB: {e}")
import traceback
traceback.print_exc()
init_db()
# ============================================================================
# FUNCIONES HELPER - GKACHELE™ Template System
# ============================================================================
def get_site_menus(site_id, user_id):
"""Obtener menús del sitio organizados por ubicación"""
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('''SELECT location, title, url, order_index, parent_id
FROM menus
WHERE site_id = ? AND user_id = ?
ORDER BY location, order_index''', (site_id, user_id))
rows = c.fetchall()
conn.close()
menus = {'header': [], 'footer': [], 'sidebar': []}
for row in rows:
location, title, url, order_index, parent_id = row
menu_item = {
'title': title,
'url': url,
'order': order_index,
'parent_id': parent_id
}
if location in menus:
menus[location].append(menu_item)
return menus
def get_site_widgets(site_id, user_id, area='sidebar'):
"""Obtener widgets del sitio por área"""
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('''SELECT type, title, content, order_index
FROM widgets
WHERE site_id = ? AND user_id = ? AND area = ?
ORDER BY order_index''', (site_id, user_id, area))
rows = c.fetchall()
conn.close()
widgets = []
for row in rows:
widget_type, title, content, order_index = row
widgets.append({
'type': widget_type,
'title': title,
'content': content,
'order': order_index
})
return widgets
def render_gkachele_template(theme, content, site_id=None, user_id=None):
"""Renderizar template usando estructura GKACHELE (header.php, footer.php, sidebar.php)"""
# Obtener menús y widgets si hay site_id
menus = {'header': [], 'footer': [], 'sidebar': []}
widgets = []
if site_id and user_id:
try:
menus = get_site_menus(site_id, user_id)
widgets = get_site_widgets(site_id, user_id)
except Exception as e:
print(f"⚠️ Error obteniendo menús/widgets: {e}")
# Cargar template del tema PRIMERO (es el contenido principal)
theme_template = ''
theme_path = os.path.join(THEMES_DIR, theme, 'template.html')
if os.path.exists(theme_path):
with open(theme_path, 'r', encoding='utf-8') as f:
theme_template = f.read()
else:
# Si no existe template del tema, usar template básico
theme_template = '''
<div class="container" style="max-width: 1200px; margin: 0 auto; padding: 40px 20px;">
<h1 style="color: var(--primary);">{{ hero_title or 'Bienvenido' }}</h1>
<p style="color: var(--text);">{{ hero_description or 'Descripción del sitio' }}</p>
</div>
'''
# Cargar header, footer, sidebar
header = ''
footer = ''
sidebar = ''
header_path = os.path.join(THEMES_DIR, '_gkachele', 'header.php')
footer_path = os.path.join(THEMES_DIR, '_gkachele', 'footer.php')
sidebar_path = os.path.join(THEMES_DIR, '_gkachele', 'sidebar.php')
if os.path.exists(header_path):
with open(header_path, 'r', encoding='utf-8') as f:
header = f.read()
else:
# Header básico si no existe
header = '''<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_name or 'GKACHELE Site' }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: {{ colors.primary or '#c94d4d' }};
--secondary: {{ colors.secondary or '#d97757' }};
--text: {{ colors.text or '#2c2c2c' }};
}
body { font-family: '{{ typography.font_family or 'Roboto' }}', sans-serif; }
</style>
</head>
<body>
<header style="background: #fff; padding: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
<nav style="max-width: 1200px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center;">
<a href="/" style="font-size: 24px; color: var(--primary); text-decoration: none; font-weight: bold;">{{ site_name or 'GKACHELE Site' }}</a>
{% if menus.header %}
<ul style="display: flex; list-style: none; gap: 20px;">
{% for item in menus.header %}
<li><a href="{{ item.url }}" style="color: var(--text); text-decoration: none;">{{ item.title }}</a></li>
{% endfor %}
</ul>
{% endif %}
</nav>
</header>
<main style="min-height: calc(100vh - 200px);">'''
if os.path.exists(footer_path):
with open(footer_path, 'r', encoding='utf-8') as f:
footer = f.read()
else:
footer = '''
</main>
<footer style="background: var(--text); color: #fff; text-align: center; padding: 40px 20px; margin-top: 60px;">
<p>© 2025 GKACHELE™. Todos los derechos reservados.<br>
Desarrollado desde noviembre 2025 por GKACHELE<br>
Código propiedad de GKACHELE © 2025 - Prohibida su reproducción sin autorización</p>
</footer>
</body>
</html>'''
if os.path.exists(sidebar_path):
with open(sidebar_path, 'r', encoding='utf-8') as f:
sidebar = f.read()
# Preparar contexto completo - Asegurar que todas las variables esperadas existan
template_data = {
'site_name': content.get('site_name', 'GKACHELE Site'),
'hero_title': content.get('hero_title', 'Bienvenido'),
'hero_description': content.get('hero_description', ''),
'colors': content.get('colors', {'primary': '#c94d4d', 'secondary': '#d97757', 'accent': '#f4a261', 'text': '#2c2c2c'}),
'typography': content.get('typography', {'font_family': 'Roboto'}),
'horarios': content.get('horarios', {}), # Asegurar que horarios siempre existe
'redes_sociales': content.get('redes_sociales', {}), # Asegurar que redes_sociales siempre existe
'especialidad_culinaria': content.get('especialidad_culinaria', {}), # Asegurar que especialidad_culinaria siempre existe
'direccion': content.get('direccion', ''),
'telefono': content.get('telefono', ''),
'email': content.get('email', ''),
'capacidad': content.get('capacidad', ''),
'mapa_url': content.get('mapa_url', ''),
'menu_url': content.get('menu_url', ''),
'menu_items': content.get('menu_items', {}), # Platos del menú
'blocks': content.get('blocks', []), # Bloques añadibles
'menus': menus,
'widgets': widgets,
**content # Incluir todo el contenido adicional (esto sobrescribirá los valores por defecto si existen)
}
# Renderizar usando Jinja2
from jinja2 import Template
# DETECCIÓN DE TEMPLATE COMPLETO (Full Page)
# Forzar restaurante-moderno a ser siempre full page
is_full_page = False
if theme == 'restaurante-moderno':
is_full_page = True
elif '<!DOCTYPE html>' in theme_template or '<html' in theme_template.lower():
is_full_page = True
print(f"🔍 [RENDER] Theme: {theme}, Is Full Page: {is_full_page}")
if is_full_page:
print(f" Template '{theme}' renderizado como página completa (sin wrapper).")
template = Template(theme_template)
return template.render(**template_data)
# Si es un fragmento, construir template completo: header + contenido del tema + sidebar + footer
print(f" Template '{theme}' renderizado con wrapper GKACHELE.")
full_template = header + theme_template + sidebar + footer
template = Template(full_template)
return template.render(**template_data)
# ============================================================================
# RUTAS PÚBLICAS
# ============================================================================
@app.route('/')
def landing():
"""Landing page"""
return render_template('landing_real.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
"""Registro - Sistema Simple y Profesional"""
if request.method == 'POST':
try:
# Datos JSON
data = request.get_json() if request.is_json else (request.form.to_dict() if request.form else {})
if not data:
return jsonify({'success': False, 'error': 'Sin datos'}), 400
email = str(data.get('email', '')).strip()
password = str(data.get('password', '')).strip()
plan = str(data.get('plan', 'base'))
rubro = str(data.get('rubro', 'gimnasio'))
# Validar
if not email or '@' not in email:
return jsonify({'success': False, 'error': 'Email inválido'}), 400
if not password:
return jsonify({'success': False, 'error': 'Contraseña requerida'}), 400
# DB
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Usuario
try:
c.execute('INSERT INTO users (email, password, plan, rubro) VALUES (?, ?, ?, ?)',
(email, generate_password_hash(password), plan, rubro))
user_id = c.lastrowid
except sqlite3.IntegrityError:
conn.close()
return jsonify({'success': False, 'error': 'Email ya existe'}), 400
# Tema - Seleccionar automáticamente según rubro (estilo WordPress)
import random
theme = 'default'
themes_by_rubro = get_themes_by_rubro(rubro)
if themes_by_rubro:
# Si hay templates para este rubro, elegir uno aleatorio
theme = random.choice(list(themes_by_rubro.keys()))
elif rubro == 'restaurante':
# Fallback: templates específicos de restaurante
available = ['restaurante-moderno', 'restaurante-elegante']
theme = random.choice([t for t in available if os.path.exists(os.path.join(THEMES_DIR, t, 'config.json'))])
elif rubro in ['gimnasio', 'gimnasios']:
theme = 'gimnasio-claro' if os.path.exists(os.path.join(THEMES_DIR, 'gimnasio-claro', 'config.json')) else 'default'
# Cargar configuración del template seleccionado
theme_config = get_theme_config(theme)
default_colors = {'primary': '#c94d4d', 'secondary': '#d97757', 'accent': '#f4a261', 'text': '#2c2c2c'}
default_typography = {'font_family': 'Roboto'}
if theme_config:
default_colors = theme_config.get('colors', default_colors)
default_typography = theme_config.get('typography', default_typography)
# Sitio
site_name = email.split('@')[0].title()
content = json.dumps({
'site_name': site_name + ' Site',
'hero_title': 'Bienvenido',
'hero_description': '',
'colors': default_colors,
'typography': default_typography
})
slug = f'site-{secrets.token_hex(4)}'
# --- INTEGRACIÓN GITEA AUTOMATION (DESHABILITADO POR AHORA) ---
repo_url = None
# try:
# import gitea_connector
# # Usar el ID del usuario como nombre de organización para agrupar sus sitios
# org_name = f"cliente-{user_id}"
# repo_name = slug
#
# # 1. Crear Organización (si no existe)
# gitea_connector.create_org(org_name)
#
# # 2. Crear Repositorio
# repo_data = gitea_connector.create_repo(org_name, repo_name, description=f"Sitio {site_name} ({rubro})")
#
# if repo_data:
# repo_url = repo_data.get('clone_url')
# print(f"✅ [FACTORY] Repo creado: {repo_url}")
# except Exception as e:
# print(f"❌ [FACTORY] Error creando repo en Gitea: {e}")
# import traceback
# traceback.print_exc()
# -------------------------------------
c.execute('INSERT INTO sites (user_id, slug, theme, content_json, repo_url) VALUES (?, ?, ?, ?, ?)',
(user_id, slug, theme, content, repo_url))
site_id = c.lastrowid
# Crear menús por defecto (header, footer)
default_menus = [
('header', 'Inicio', '#inicio', 0, None),
('header', 'Menú', '#menu', 1, None),
('header', 'Horarios', '#horarios', 2, None),
('header', 'Reservas', '#reservas', 3, None),
('header', 'Contacto', '#contacto', 4, None),
('footer', 'Inicio', '#inicio', 0, None),
('footer', 'Contacto', '#contacto', 1, None),
]
for location, title, url, order_idx, parent_id in default_menus:
c.execute('''INSERT INTO menus (user_id, site_id, location, title, url, order_index, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?)''',
(user_id, site_id, location, title, url, order_idx, parent_id))
conn.commit()
conn.close()
# ✅ TODO está en main.db - no se necesitan bases separadas
# El cliente accede a /dashboard y solo ve SUS datos filtrados por user_id
# NO crear sesión automáticamente - el usuario debe hacer login
# ÉXITO - Redirigir al login para que inicie sesión
print(f"✅ Usuario registrado: user_id={user_id}, site_id={site_id}")
return jsonify({'success': True, 'message': 'Registro exitoso. Por favor inicia sesión.', 'redirect': '/login'})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)}), 500
# GET
return render_template('register.html', plan=request.args.get('plan', 'base'), rubro=request.args.get('rubro', 'gimnasio'))
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Login"""
if request.method == 'POST':
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No se recibieron datos'}), 400
email = data.get('email')
password = data.get('password')
if not email or not password:
return jsonify({'success': False, 'error': 'Email y contraseña son requeridos'}), 400
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT id, password FROM users WHERE email = ?', (email,))
user = c.fetchone()
conn.close()
if user and check_password_hash(user[1], password):
session['user_id'] = user[0]
user_id = user[0]
import sys
sys.stdout.flush()
print(f"✅ Login exitoso: user_id={user_id}", flush=True)
# Buscar el primer sitio del usuario para ir al customizer directamente
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT id FROM sites WHERE user_id = ? ORDER BY id LIMIT 1', (user_id,))
site = c.fetchone()
# Debug: contar todos los sitios del usuario
c.execute('SELECT COUNT(*) FROM sites WHERE user_id = ?', (user_id,))
total_sites = c.fetchone()[0]
print(f"[LOGIN] Usuario {user_id} tiene {total_sites} sitio(s)", flush=True)
conn.close()
# Si tiene sitio, ir al customizer; si no, al dashboard
if site:
site_id = site[0]
redirect_url = f'/customizer/{site_id}'
print(f"[LOGIN] Redirigiendo a customizer: site_id={site_id}, user_id={user_id}", flush=True)
else:
redirect_url = '/dashboard'
print(f"[LOGIN] Usuario sin sitios, redirigiendo a dashboard, user_id={user_id}", flush=True)
print(f"[LOGIN] Respuesta JSON: redirect={redirect_url}", flush=True)
# FORZAR respuesta JSON correcta
response_data = {'success': True, 'redirect': redirect_url}
print(f"[LOGIN] Response data: {response_data}", flush=True)
response = jsonify(response_data)
response.headers['Content-Type'] = 'application/json'
print(f"[LOGIN] Enviando respuesta JSON: {redirect_url}", flush=True)
return response
return jsonify({'success': False, 'error': 'Credenciales inválidas'}), 401
except Exception as e:
print(f"❌ Error en login: {e}")
import traceback
traceback.print_exc()
return jsonify({'success': False, 'error': f'Error al iniciar sesión: {str(e)}'}), 500
return render_template('login.html')
@app.route('/logout')
def logout():
session.pop('user_id', None)
return redirect(url_for('landing'))
# ============================================================================
# DASHBOARD CLIENTE
# ============================================================================
@app.route('/dashboard')
def dashboard():
"""Panel del cliente"""
if 'user_id' not in session:
return redirect(url_for('login'))
user_id = session['user_id']
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Info del usuario
c.execute('SELECT email, plan FROM users WHERE id = ?', (user_id,))
user_info = c.fetchone()
# Sitios
c.execute('SELECT id, slug, theme, status, created_at FROM sites WHERE user_id = ?', (user_id,))
sites = [{'id': r[0], 'slug': r[1], 'theme': r[2], 'status': r[3], 'created_at': r[4]}
for r in c.fetchall()]
# Contar media
c.execute('SELECT COUNT(*) FROM media WHERE user_id = ?', (user_id,))
media_count = c.fetchone()[0]
conn.close()
return render_template('dashboard.html',
sites=sites,
user_email=user_info[0] if user_info else '',
user_plan=user_info[1] if user_info else 'base',
media_count=media_count)
@app.route('/dashboard/admin')
def client_admin():
"""Admin del cliente - gestionar media, config, etc."""
if 'user_id' not in session:
return redirect(url_for('login'))
user_id = session['user_id']
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Info del usuario
c.execute('SELECT email, plan FROM users WHERE id = ?', (user_id,))
user_info = c.fetchone()
# Sitios del usuario
c.execute('SELECT id, slug, theme, status FROM sites WHERE user_id = ?', (user_id,))
sites = [{'id': r[0], 'slug': r[1], 'theme': r[2], 'status': r[3]} for r in c.fetchall()]
conn.close()
return render_template('client_admin.html',
user_email=user_info[0] if user_info else '',
user_plan=user_info[1] if user_info else 'base',
sites=sites)
@app.route('/dashboard/create', methods=['GET', 'POST'])
def create_site():
"""Crear nuevo sitio"""
if 'user_id' not in session:
return redirect(url_for('login'))
if request.method == 'POST':
data = request.get_json()
user_id = session['user_id']
slug = data.get('slug', f'site-{secrets.token_hex(4)}')
theme = data.get('theme', 'default')
# Contenido inicial
content = {
'site_name': data.get('site_name', 'Mi Sitio'),
'hero_title': data.get('hero_title', 'Bienvenido'),
'hero_description': data.get('hero_description', ''),
'colors': {
'primary': '#ff4d4d',
'secondary': '#1a1a1a',
'text': '#333333'
},
'typography': {
'font_family': 'Arial'
}
}
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('INSERT INTO sites (user_id, slug, theme, content_json) VALUES (?, ?, ?, ?)',
(user_id, slug, theme, json.dumps(content)))
site_id = c.lastrowid
conn.commit()
conn.close()
# ✅ TODO está en main.db - no se necesitan bases separadas
return jsonify({'success': True, 'site_id': site_id, 'slug': slug})
return render_template('create_site.html')
# ============================================================================
# CUSTOMIZER (Sistema de Personalización)
# ============================================================================
@app.route('/api/themes')
def list_themes():
"""Listar todos los templates disponibles (estilo WordPress)"""
rubro = request.args.get('rubro', None)
if rubro:
themes = get_themes_by_rubro(rubro)
else:
themes = scan_available_themes()
return jsonify({
'success': True,
'themes': themes,
'total': len(themes)
})
@app.route('/api/themes/<theme_id>')
def get_theme(theme_id):
"""Obtener información detallada de un template"""
config = get_theme_config(theme_id)
if not config:
return jsonify({'success': False, 'error': 'Template no encontrado'}), 404
return jsonify({
'success': True,
'theme': {
'id': theme_id,
**config
}
})
@app.route('/customizer/<int:site_id>')
def customizer(site_id):
"""Customizer: Sidebar + Preview"""
# Verificar que el sitio existe
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT user_id, slug, theme, content_json FROM sites WHERE id = ?', (site_id,))
site = c.fetchone()
conn.close()
if not site:
return "Sitio no encontrado", 404
# Si hay sesión, verificar que pertenece al usuario
if 'user_id' in session and site[0] != session['user_id']:
return "No autorizado", 403
content = json.loads(site[3]) if site[3] else {}
theme = site[2]
# Cargar template del tema si existe
theme_template = None
theme_path = os.path.join(THEMES_DIR, theme, 'template.html')
if os.path.exists(theme_path):
with open(theme_path, 'r', encoding='utf-8') as f:
theme_template = f.read()
# Cargar configuración del template
theme_config = get_theme_config(theme)
if theme_config:
# Si el contenido no tiene colores, usar los del template
if not content.get('colors'):
content['colors'] = theme_config.get('colors', {})
# Si no tiene tipografía, usar la del template
if not content.get('typography'):
content['typography'] = theme_config.get('typography', {})
# Obtener todos los templates disponibles para el selector
available_themes = scan_available_themes()
return render_template('customizer.html',
site_id=site_id,
slug=site[1],
theme=theme,
content=content,
theme_template=theme_template,
theme_config=theme_config,
available_themes=available_themes)
@app.route('/api/customizer/save', methods=['POST'])
def save_customizer():
"""Guardar cambios del customizer"""
data = request.get_json()
site_id = data.get('site_id')
content = data.get('content')
new_theme = data.get('theme') # Permitir cambiar template
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Si se cambió el template, actualizarlo
if new_theme:
# Verificar que el template existe
theme_config = get_theme_config(new_theme)
if theme_config:
c.execute('UPDATE sites SET theme = ? WHERE id = ?', (new_theme, site_id))
# Aplicar colores y tipografía por defecto del nuevo template si no existen
if not content.get('colors'):
content['colors'] = theme_config.get('colors', {})
if not content.get('typography'):
content['typography'] = theme_config.get('typography', {})
c.execute('UPDATE sites SET content_json = ? WHERE id = ?',
(json.dumps(content), site_id))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/api/customizer/update-field', methods=['POST'])
def update_field():
"""Actualizar un campo específico desde preview (click to edit)"""
data = request.get_json()
site_id = data.get('site_id')
field = data.get('field') # Ej: 'hero_title', 'horarios.lunes_viernes'
value = data.get('value')
if not site_id or not field:
return jsonify({'success': False, 'error': 'Faltan parámetros'}), 400
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Obtener contenido actual
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
result = c.fetchone()
if not result:
conn.close()
return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404
content = json.loads(result[0]) if result[0] else {}
# Actualizar campo (soporta campos anidados como 'horarios.lunes_viernes' o 'menu_items.1.nombre')
if '.' in field:
# Campo anidado: 'horarios.lunes_viernes' o 'menu_items.1.nombre'
parts = field.split('.')
current = content
for i, part in enumerate(parts[:-1]):
if part not in current:
current[part] = {}
# Si es un número, mantener como string key (para menu_items.1, menu_items.2, etc.)
current = current[part]
current[parts[-1]] = value
else:
# Campo simple: 'hero_title'
content[field] = value
# Guardar
c.execute('UPDATE sites SET content_json = ? WHERE id = ?',
(json.dumps(content), site_id))
conn.commit()
conn.close()
return jsonify({'success': True, 'content': content})
@app.route('/api/customizer/get-blocks/<int:site_id>')
def get_blocks(site_id):
"""Obtener bloques del sitio para el customizer"""
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'success': False, 'blocks': []})
content = json.loads(result[0]) if result[0] else {}
blocks = content.get('blocks', [])
return jsonify({'success': True, 'blocks': blocks})
@app.route('/api/customizer/get-content/<int:site_id>')
def get_content(site_id):
"""Obtener contenido completo del sitio"""
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
result = c.fetchone()
conn.close()
if not result:
return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404
content = json.loads(result[0]) if result[0] else {}
return jsonify({'success': True, 'content': content})
@app.route('/api/customizer/add-block', methods=['POST'])
def add_block():
"""Añadir un nuevo bloque al sitio"""
data = request.get_json()
site_id = data.get('site_id')
block_type = data.get('block_type')
block_content = data.get('content', {})
if not site_id or not block_type:
return jsonify({'success': False, 'error': 'Faltan parámetros'}), 400
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
result = c.fetchone()
if not result:
conn.close()
return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404
content = json.loads(result[0]) if result[0] else {}
# Inicializar blocks si no existe
if 'blocks' not in content:
content['blocks'] = []
# Crear nuevo bloque
new_block = {
'id': f'block_{int(time.time() * 1000)}',
'type': block_type,
'content': block_content,
'order': len(content['blocks'])
}
content['blocks'].append(new_block)
# Guardar
c.execute('UPDATE sites SET content_json = ? WHERE id = ?', (json.dumps(content), site_id))
conn.commit()
conn.close()
return jsonify({'success': True, 'block': new_block})
@app.route('/api/customizer/remove-block', methods=['POST'])
def remove_block():
"""Eliminar un bloque del sitio"""
data = request.get_json()
site_id = data.get('site_id')
block_id = data.get('block_id')
if not site_id or not block_id:
return jsonify({'success': False, 'error': 'Faltan parámetros'}), 400
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
result = c.fetchone()
if not result:
conn.close()
return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404
content = json.loads(result[0]) if result[0] else {}
if 'blocks' in content:
content['blocks'] = [b for b in content['blocks'] if b.get('id') != block_id]
# Reordenar
for i, block in enumerate(content['blocks']):
block['order'] = i
# Guardar
c.execute('UPDATE sites SET content_json = ? WHERE id = ?', (json.dumps(content), site_id))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/api/customizer/reorder-blocks', methods=['POST'])
def reorder_blocks():
"""Reordenar bloques"""
data = request.get_json()
site_id = data.get('site_id')
block_ids = data.get('block_ids', [])
if not site_id:
return jsonify({'success': False, 'error': 'Faltan parámetros'}), 400
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
result = c.fetchone()
if not result:
conn.close()
return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404
content = json.loads(result[0]) if result[0] else {}
if 'blocks' in content:
# Reordenar según block_ids
blocks_dict = {b['id']: b for b in content['blocks']}
content['blocks'] = [blocks_dict[bid] for bid in block_ids if bid in blocks_dict]
for i, block in enumerate(content['blocks']):
block['order'] = i
# Guardar
c.execute('UPDATE sites SET content_json = ? WHERE id = ?', (json.dumps(content), site_id))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/api/customizer/preview-frame/<int:site_id>')
def preview_frame(site_id):
"""Frame del preview - renderiza el template completo con estructura GKACHELE"""
# Obtener datos del sitio
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT theme, content_json, user_id FROM sites WHERE id = ?', (site_id,))
site = c.fetchone()
conn.close()
if not site:
return "Sitio no encontrado", 404
theme = site[0]
content = json.loads(site[1]) if site[1] else {}
user_id = site[2]
# Usar función helper para renderizar con estructura GKACHELE
try:
rendered = render_gkachele_template(theme, content, site_id, user_id)
print(f"✅ Template renderizado correctamente para site_id={site_id}, theme={theme}")
return rendered
except Exception as e:
import traceback
print(f"❌ Error renderizando template: {e}")
traceback.print_exc()
# DEVOVER HTML VISIBLE EN EL IFRAME PARA DEBUG
return f"""
<div style="padding: 20px; background: #fee; border: 2px solid red; color: red; font-family: monospace;">
<h1>❌ Error Renderizando Preview</h1>
<p><strong>Theme:</strong> {theme}</p>
<p><strong>Error:</strong> {str(e)}</p>
<pre>{traceback.format_exc()}</pre>
</div>
""", 500
# ============================================================================
# SOLICITUDES
# ============================================================================
# ============================================================================
# ADMIN
# ============================================================================
# ============================================================================
# HELPER FUNCTIONS - Permisos basados en DB (sistema de roles GKACHELE™)
# ============================================================================
def user_has_role(user_id, required_role):
"""Verificar si usuario tiene un rol específico (desde DB)"""
if not user_id:
return False
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT role FROM users WHERE id = ?', (user_id,))
result = c.fetchone()
conn.close()
if not result:
return False
user_role = result[0] or 'subscriber'
# Jerarquía de roles (sistema GKACHELE™)
role_hierarchy = {
'administrator': 4,
'editor': 3,
'author': 2,
'subscriber': 1
}
user_level = role_hierarchy.get(user_role, 1)
required_level = role_hierarchy.get(required_role, 1)
return user_level >= required_level
def user_can(user_id, capability):
"""Verificar capacidad específica (desde DB)"""
if not user_id:
return False
# Si es admin, puede todo
if user_has_role(user_id, 'administrator'):
return True
# Otras capacidades se pueden añadir aquí
# Por ahora, solo admin puede hacer cosas administrativas
return False
# --- SOLICITUDES Y APROBACIÓN ---
@app.route('/dashboard/submit/<int:site_id>', methods=['POST'])
@login_required
def submit_site(site_id):
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Verificar propiedad
c.execute('SELECT id, slug FROM sites WHERE id = ? AND user_id = ?', (site_id, session['user_id']))
site = c.fetchone()
if not site:
conn.close()
return jsonify({'success': False, 'error': 'Sitio no encontrado o no autorizado'}), 404
# Verificar si ya existe solicitud pendiente
c.execute('SELECT id FROM requests WHERE site_id = ? AND status = "pending"', (site_id,))
if c.fetchone():
conn.close()
return jsonify({'success': False, 'error': 'Ya existe una solicitud pendiente'}), 400
# Crear solicitud
try:
c.execute('INSERT INTO requests (user_id, site_id, slug, status, created_at) VALUES (?, ?, ?, "pending", CURRENT_TIMESTAMP)',
(session['user_id'], site_id, site[1]))
# Actualizar estado del sitio
c.execute('UPDATE sites SET status = "pending" WHERE id = ?', (site_id,))
conn.commit()
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
conn.close()
@app.route('/admin/approve/<int:request_id>', methods=['POST'])
@login_required
def approve_request(request_id):
# Verificar admin (hardcoded ID 1 por ahora, o verificar email)
if session.get('user_id') != 1:
return jsonify({'success': False, 'error': 'No autorizado'}), 403
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
try:
# Obtener info de la solicitud
c.execute('''SELECT r.id, r.site_id, r.user_id, s.slug, s.content_json, s.theme, u.rubro, u.email
FROM requests r
JOIN sites s ON r.site_id = s.id
JOIN users u ON r.user_id = u.id
WHERE r.id = ?''', (request_id,))
req = c.fetchone()
if not req:
return jsonify({'success': False, 'error': 'Solicitud no encontrada'}), 404
req_id, site_id, user_id, slug, content, theme, rubro, email = req
# --- AUTOMATIZACIÓN GITEA (MOVIDA AQUÍ) ---
repo_url = None
try:
import gitea_connector
# Nombre del sitio
site_name = slug
if content:
import json
try:
content_data = json.loads(content)
site_name = content_data.get('site_name', slug)
except:
pass
org_name = f"cliente-{user_id}"
repo_name = slug
print(f"🚀 [ADMIN] Iniciando creación de repo para {org_name}/{repo_name}...")
# 1. Crear Organización (si no existe)
gitea_connector.create_org(org_name)
# 2. Crear Repositorio
repo_data = gitea_connector.create_repo(org_name, repo_name, description=f"Sitio {site_name} ({rubro})")
if repo_data:
repo_url = repo_data.get('clone_url')
print(f"✅ [ADMIN] Repo creado: {repo_url}")
# TODO: Aquí iría el commit inicial con el contenido JSON actual
# O el trigger de un webhook para desplegar
except Exception as e:
print(f"❌ [ADMIN] Error Gitea: {e}")
import traceback
traceback.print_exc()
# ------------------------------------------
# Actualizar solicitud
c.execute('UPDATE requests SET status = "approved" WHERE id = ?', (request_id,))
# Actualizar sitio (Publicado + Repo)
c.execute('UPDATE sites SET status = "published", repo_url = ? WHERE id = ?', (repo_url, site_id))
# Incrementar contador de usuario (opcional, si existe la columna)
try:
c.execute('UPDATE users SET sitios_publicados = IFNULL(sitios_publicados, 0) + 1 WHERE id = ?', (user_id,))
except:
pass
conn.commit()
return jsonify({'success': True, 'repo_url': repo_url})
except Exception as e:
print(f"Error approving: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
conn.close()
@app.route('/admin')
def admin():
"""Panel admin - Solo usuarios con rol 'administrator' (desde DB)"""
if 'user_id' not in session:
return "No autorizado", 403
# Verificar rol desde la base de datos (no hardcodeado)
if not user_has_role(session['user_id'], 'administrator'):
return "Solo administradores pueden acceder", 403
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Solicitudes pendientes
c.execute('''SELECT r.id, r.site_id, r.status, s.slug, u.email, r.created_at
FROM requests r
JOIN sites s ON r.site_id = s.id
JOIN users u ON r.user_id = u.id
WHERE r.status = 'pending'
ORDER BY r.created_at DESC''')
requests = [{'id': r[0], 'site_id': r[1], 'status': r[2], 'slug': r[3],
'email': r[4], 'created_at': r[5]} for r in c.fetchall()]
# Todos los sitios
c.execute('SELECT id, slug, theme, status, user_id FROM sites')
sites = [{'id': r[0], 'slug': r[1], 'theme': r[2], 'status': r[3], 'user_id': r[4]}
for r in c.fetchall()]
# TODOS LOS USUARIOS con estadísticas
c.execute('''SELECT
u.id,
u.email,
u.plan,
u.rubro,
u.created_at,
COUNT(DISTINCT s.id) as num_sitios,
COUNT(DISTINCT CASE WHEN s.status = 'published' THEN s.id END) as sitios_publicados,
COUNT(DISTINCT r.id) as num_solicitudes
FROM users u
LEFT JOIN sites s ON u.id = s.user_id
LEFT JOIN requests r ON u.id = r.user_id
GROUP BY u.id
ORDER BY u.id DESC''')
users = []
for row in c.fetchall():
users.append({
'id': row[0],
'email': row[1],
'plan': row[2],
'rubro': row[3],
'created_at': row[4],
'num_sitios': row[5] or 0,
'sitios_publicados': row[6] or 0,
'num_solicitudes': row[7] or 0
})
conn.close()
return render_template('admin.html', requests=requests, sites=sites, users=users)
@app.route('/admin/approve/<int:request_id>', methods=['POST'])
def approve_site(request_id):
"""Admin aprueba sitio - Verificar rol desde DB"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 403
if not user_has_role(session['user_id'], 'administrator'):
return jsonify({'error': 'Solo administradores'}), 403
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Obtener site_id
c.execute('SELECT site_id FROM requests WHERE id = ?', (request_id,))
result = c.fetchone()
if not result:
conn.close()
return jsonify({'error': 'Solicitud no encontrada'}), 404
site_id = result[0]
# Aprobar: cambiar status a approved y publicar
c.execute('UPDATE sites SET status = ? WHERE id = ?', ('published', site_id))
c.execute('UPDATE requests SET status = ? WHERE id = ?', ('approved', request_id))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/admin/users/delete/<int:user_id>', methods=['POST'])
def delete_user(user_id):
"""Eliminar usuario y todos sus datos relacionados - Verificar rol desde DB"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 403
# Verificar rol desde DB (no hardcodeado)
if not user_has_role(session['user_id'], 'administrator'):
return jsonify({'error': 'Solo administradores'}), 403
# No permitir eliminar al admin principal (user_id = 1)
if user_id == 1:
return jsonify({'error': 'No se puede eliminar al administrador principal'}), 400
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
try:
# Eliminar en orden (respetando foreign keys)
# 1. Widgets
c.execute('DELETE FROM widgets WHERE user_id = ?', (user_id,))
# 2. Menús
c.execute('DELETE FROM menus WHERE user_id = ?', (user_id,))
# 3. Media
c.execute('DELETE FROM media WHERE user_id = ?', (user_id,))
# 4. Content
c.execute('DELETE FROM content WHERE user_id = ?', (user_id,))
# 5. Settings
c.execute('DELETE FROM settings WHERE user_id = ?', (user_id,))
# 6. Requests
c.execute('DELETE FROM requests WHERE user_id = ?', (user_id,))
# 7. Sites
c.execute('DELETE FROM sites WHERE user_id = ?', (user_id,))
# 8. Usuario
c.execute('DELETE FROM users WHERE id = ?', (user_id,))
conn.commit()
conn.close()
return jsonify({'success': True, 'message': f'Usuario {user_id} eliminado exitosamente'})
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': f'Error al eliminar usuario: {str(e)}'}), 500
# ============================================================================
# SITIO PÚBLICO
# ============================================================================
@app.route('/site/<slug>')
def public_site(slug):
"""Sitio público del cliente - usando estructura GKACHELE"""
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT id, theme, content_json, status, user_id FROM sites WHERE slug = ?', (slug,))
site = c.fetchone()
conn.close()
if not site or site[3] != 'published':
return "Sitio no encontrado o no publicado", 404
site_id = site[0]
theme = site[1]
content = json.loads(site[2]) if site[2] else {}
user_id = site[4]
# Renderizar con estructura GKACHELE
try:
return render_gkachele_template(theme, content, site_id, user_id)
except Exception as e:
import traceback
traceback.print_exc()
return render_template('public_site.html', content=content, theme=theme)
# ============================================================================
# MAIN
# ============================================================================
# ============================================================================
# API ADMIN CLIENTE
# ============================================================================
@app.route('/api/admin/upload', methods=['POST'])
def upload_media():
"""Subir imágenes"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 401
user_id = session['user_id']
files = request.files.getlist('files')
if not files:
return jsonify({'error': 'No hay archivos'}), 400
uploaded = []
for file in files:
if file.filename:
filename = f"{user_id}_{secrets.token_hex(8)}_{file.filename}"
filepath = os.path.join(UPLOADS_DIR, filename)
file.save(filepath)
# Guardar en DB
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT id FROM sites WHERE user_id = ? LIMIT 1', (user_id,))
site = c.fetchone()
site_id = site[0] if site else None
c.execute('INSERT INTO media (user_id, site_id, filename, filepath, file_type) VALUES (?, ?, ?, ?, ?)',
(user_id, site_id, filename, filepath, 'image'))
conn.commit()
conn.close()
uploaded.append(filename)
return jsonify({'success': True, 'files': uploaded})
@app.route('/api/admin/media')
def get_media():
"""Obtener media del usuario"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 401
user_id = session['user_id']
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('SELECT id, filename, filepath, uploaded_at FROM media WHERE user_id = ? ORDER BY uploaded_at DESC', (user_id,))
media = [{'id': r[0], 'filename': r[1], 'filepath': r[2], 'uploaded_at': r[3]} for r in c.fetchall()]
conn.close()
return jsonify({'success': True, 'media': media})
@app.route('/api/admin/media/<int:media_id>', methods=['DELETE'])
def delete_media(media_id):
"""Eliminar media"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 401
user_id = session['user_id']
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Verificar que pertenece al usuario
c.execute('SELECT filepath FROM media WHERE id = ? AND user_id = ?', (media_id, user_id))
media = c.fetchone()
if media:
# Eliminar archivo
if os.path.exists(media[0]):
os.remove(media[0])
# Eliminar de DB
c.execute('DELETE FROM media WHERE id = ?', (media_id,))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/api/admin/settings', methods=['POST'])
def update_settings():
"""Actualizar configuración del usuario"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 401
data = request.get_json()
user_id = session['user_id']
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
if data.get('password'):
c.execute('UPDATE users SET password = ? WHERE id = ?',
(generate_password_hash(data['password']), user_id))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/uploads/<filename>')
def serve_upload(filename):
"""Servir archivos subidos"""
return send_from_directory(UPLOADS_DIR, filename)
# ============================================================================
# API MENÚS Y WIDGETS
# ============================================================================
@app.route('/api/menus/<int:site_id>', methods=['GET'])
def get_menus(site_id):
"""Obtener menús del sitio"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 401
user_id = session['user_id']
menus = get_site_menus(site_id, user_id)
return jsonify({'success': True, 'menus': menus})
@app.route('/api/menus/<int:site_id>', methods=['POST'])
def save_menu(site_id):
"""Guardar/actualizar menú"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 401
data = request.get_json()
user_id = session['user_id']
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
# Eliminar menús existentes del sitio
c.execute('DELETE FROM menus WHERE site_id = ? AND user_id = ?', (site_id, user_id))
# Insertar nuevos menús
for menu_item in data.get('menus', []):
c.execute('''INSERT INTO menus (user_id, site_id, location, title, url, order_index, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?)''',
(user_id, site_id, menu_item['location'], menu_item['title'],
menu_item['url'], menu_item.get('order', 0), menu_item.get('parent_id')))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/api/widgets/<int:site_id>', methods=['GET'])
def get_widgets(site_id):
"""Obtener widgets del sitio"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 401
user_id = session['user_id']
area = request.args.get('area', 'sidebar')
widgets = get_site_widgets(site_id, user_id, area)
return jsonify({'success': True, 'widgets': widgets})
@app.route('/api/widgets/<int:site_id>', methods=['POST'])
def save_widget(site_id):
"""Guardar/actualizar widget"""
if 'user_id' not in session:
return jsonify({'error': 'No autorizado'}), 401
data = request.get_json()
user_id = session['user_id']
conn = sqlite3.connect(MAIN_DB)
c = conn.cursor()
c.execute('''INSERT INTO widgets (user_id, site_id, area, type, title, content, order_index)
VALUES (?, ?, ?, ?, ?, ?, ?)''',
(user_id, site_id, data.get('area', 'sidebar'), data.get('type', 'text'),
data.get('title'), data.get('content'), data.get('order', 0)))
conn.commit()
conn.close()
return jsonify({'success': True})
# ============================================================================
# MAIN
# ============================================================================
# Inicializar DB al importar (con manejo de errores)
try:
init_db()
except Exception as e:
print(f"⚠️ Error en init_db: {e}")
# Continuar aunque falle, se creará cuando se use
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5001))
print("🚀 Demo SaaS iniciado")
print(f"📍 http://localhost:{port}")
app.run(debug=True, host='0.0.0.0', port=port)