1039 lines
38 KiB
Python
1039 lines
38 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/')):
|
|
# Asegurar que acepta JSON
|
|
if not request.is_json and request.content_type and 'application/json' in request.content_type:
|
|
try:
|
|
request.get_json(force=True)
|
|
except:
|
|
pass
|
|
|
|
# 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')
|
|
|
|
# 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)
|
|
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password TEXT NOT NULL,
|
|
plan TEXT DEFAULT 'base',
|
|
rubro TEXT DEFAULT 'gimnasio',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)''')
|
|
|
|
# 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,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
)''')
|
|
|
|
# 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 '#d32f2f' }};
|
|
--secondary: {{ colors.secondary or '#ff6f00' }};
|
|
--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
|
|
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': '#d32f2f', 'secondary': '#ff6f00', 'text': '#2c2c2c'}),
|
|
'typography': content.get('typography', {'font_family': 'Roboto'}),
|
|
'menus': menus,
|
|
'widgets': widgets,
|
|
**content # Incluir todo el contenido adicional
|
|
}
|
|
|
|
# Renderizar usando Jinja2
|
|
from jinja2 import Template
|
|
# Construir template completo: header + contenido del tema + sidebar + footer
|
|
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
|
|
import random
|
|
theme = 'default'
|
|
if rubro == 'restaurante':
|
|
theme = random.choice(['restaurante-moderno', 'restaurante-elegante'])
|
|
elif rubro in ['gimnasio', 'gimnasios']:
|
|
theme = 'gimnasio-claro'
|
|
|
|
# Sitio
|
|
site_name = email.split('@')[0].title()
|
|
content = json.dumps({
|
|
'site_name': site_name + ' Site',
|
|
'hero_title': 'Bienvenido',
|
|
'hero_description': '',
|
|
'colors': {'primary': '#d32f2f', 'secondary': '#ff6f00', 'text': '#2c2c2c'},
|
|
'typography': {'font_family': 'Roboto'}
|
|
})
|
|
|
|
slug = f'site-{secrets.token_hex(4)}'
|
|
c.execute('INSERT INTO sites (user_id, slug, theme, content_json) VALUES (?, ?, ?, ?)',
|
|
(user_id, slug, theme, content))
|
|
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 (como wp-admin) 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]
|
|
print(f"✅ Login exitoso: user_id={user[0]}")
|
|
# Después del login, redirigir al dashboard (el usuario puede ir al customizer desde ahí)
|
|
return jsonify({'success': True, 'redirect': '/dashboard'})
|
|
|
|
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('/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()
|
|
|
|
return render_template('customizer.html',
|
|
site_id=site_id,
|
|
slug=site[1],
|
|
theme=theme,
|
|
content=content,
|
|
theme_template=theme_template)
|
|
|
|
@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')
|
|
|
|
conn = sqlite3.connect(MAIN_DB)
|
|
c = conn.cursor()
|
|
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()
|
|
# Fallback: cargar template directamente si existe
|
|
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:
|
|
template_str = f.read()
|
|
from jinja2 import Template
|
|
template = Template(template_str)
|
|
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': '#d32f2f', 'secondary': '#ff6f00', 'text': '#2c2c2c'}),
|
|
'typography': content.get('typography', {'font_family': 'Roboto'}),
|
|
**content
|
|
}
|
|
return template.render(**template_data)
|
|
|
|
# Fallback final a preview simple
|
|
return f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><meta charset="UTF-8"><title>Preview</title></head>
|
|
<body style="padding: 40px; font-family: {content.get('typography', {}).get('font_family', 'Arial')};">
|
|
<h1 style="color: {content.get('colors', {}).get('primary', '#ff4d4d')};">
|
|
{content.get('hero_title', 'Título')}
|
|
</h1>
|
|
<p style="color: {content.get('colors', {}).get('text', '#333')};">
|
|
{content.get('hero_description', 'Descripción')}
|
|
</p>
|
|
<p style="color: #999; font-size: 12px; margin-top: 20px;">
|
|
© 2025 GKACHELE™. Todos los derechos reservados.
|
|
</p>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# ============================================================================
|
|
# SOLICITUDES
|
|
# ============================================================================
|
|
|
|
@app.route('/dashboard/submit/<int:site_id>', methods=['POST'])
|
|
def submit_site(site_id):
|
|
"""Cliente envía sitio para aprobación"""
|
|
if 'user_id' not in session:
|
|
return jsonify({'error': 'No autorizado'}), 401
|
|
|
|
conn = sqlite3.connect(MAIN_DB)
|
|
c = conn.cursor()
|
|
|
|
# Cambiar status a pending
|
|
c.execute('UPDATE sites SET status = ? WHERE id = ?', ('pending', site_id))
|
|
|
|
# Crear solicitud
|
|
c.execute('INSERT INTO requests (site_id, user_id) VALUES (?, ?)',
|
|
(site_id, session['user_id']))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return jsonify({'success': True})
|
|
|
|
# ============================================================================
|
|
# ADMIN
|
|
# ============================================================================
|
|
|
|
@app.route('/admin')
|
|
def admin():
|
|
"""Panel admin"""
|
|
# Admin simple: user_id = 1
|
|
if 'user_id' not in session or session['user_id'] != 1:
|
|
return "Solo admin", 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()]
|
|
|
|
conn.close()
|
|
|
|
return render_template('admin.html', requests=requests, sites=sites)
|
|
|
|
@app.route('/admin/approve/<int:request_id>', methods=['POST'])
|
|
def approve_site(request_id):
|
|
"""Admin aprueba sitio"""
|
|
if 'user_id' not in session or session['user_id'] != 1:
|
|
return jsonify({'error': 'No autorizado'}), 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})
|
|
|
|
# ============================================================================
|
|
# 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)
|