1701 lines
63 KiB
Python
1701 lines
63 KiB
Python
"""
|
||
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)
|