"""
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 = '''
{{ hero_title or 'Bienvenido' }}
{{ hero_description or 'Descripción del sitio' }}
'''
# 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 = '''
{{ site_name or 'GKACHELE Site' }}
'''
if os.path.exists(footer_path):
with open(footer_path, 'r', encoding='utf-8') as f:
footer = f.read()
else:
footer = '''
'''
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 '' in theme_template or '')
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/')
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/')
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/')
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/')
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"""
❌ Error Renderizando Preview
Theme: {theme}
Error: {str(e)}
{traceback.format_exc()}
""", 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/', 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/', 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/', 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/', 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/')
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/', 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/')
def serve_upload(filename):
"""Servir archivos subidos"""
return send_from_directory(UPLOADS_DIR, filename)
# ============================================================================
# API MENÚS Y WIDGETS
# ============================================================================
@app.route('/api/menus/', 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/', 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/', 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/', 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)