""" GKACHELE™ SaaS PageBuilder - Sistema Profesional © 2025 GKACHELE™. Todos los derechos reservados. Desarrollado desde noviembre 2025 por GKACHELE Código propiedad de GKACHELE © 2025 - Prohibida su reproducción sin autorización """ import os import json import sqlite3 import secrets from datetime import datetime from flask import Flask, render_template, request, jsonify, session, redirect, url_for, send_from_directory from werkzeug.security import generate_password_hash, check_password_hash app = Flask(__name__, template_folder='templates', static_folder='static') app.secret_key = 'demo-secret-key-2025' app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Manejador de errores global - siempre devolver JSON @app.errorhandler(500) def handle_500(e): print(f"❌ Error 500: {e}") import traceback traceback.print_exc() # Asegurar que siempre devuelva JSON response = jsonify({'success': False, 'error': 'Error interno del servidor'}) response.status_code = 500 return response @app.errorhandler(404) def handle_404(e): response = jsonify({'success': False, 'error': 'No encontrado'}) response.status_code = 404 return response @app.errorhandler(403) def handle_403(e): response = jsonify({'success': False, 'error': 'No autorizado'}) response.status_code = 403 return response # Manejador para excepciones no capturadas - FORZAR JSON @app.errorhandler(Exception) def handle_exception(e): print(f"❌❌❌ EXCEPCIÓN NO CAPTURADA: {e}") import traceback traceback.print_exc() # SIEMPRE devolver JSON, nunca HTML response = jsonify({'success': False, 'error': f'Error: {str(e)}'}) response.status_code = 500 response.headers['Content-Type'] = 'application/json' return response # Forzar JSON en TODAS las rutas /register, /login, /api/* @app.before_request def before_request(): # Si es POST a /register o /login, forzar JSON if request.method == 'POST' and (request.path.startswith('/register') or request.path.startswith('/login') or request.path.startswith('/api/')): try: request.get_json(force=True) except: pass @app.after_request def add_header(response): """Permitir iframes para el customizer""" # Eliminar X-Frame-Options para permitir iframe response.headers.pop('X-Frame-Options', None) response.headers['Content-Security-Policy'] = "frame-ancestors *;" return response from functools import wraps def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if 'user_id' not in session: if request.is_json: return jsonify({'success': False, 'error': 'No autorizado'}), 401 return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function # Directorios BASE_DIR = os.path.dirname(__file__) DATABASE_DIR = os.path.join(BASE_DIR, 'database') SITES_DIR = os.path.join(BASE_DIR, 'sites') THEMES_DIR = os.path.join(BASE_DIR, 'themes') STATIC_DIR = os.path.join(BASE_DIR, 'static') UPLOADS_DIR = os.path.join(BASE_DIR, 'uploads') # ============================================================================ # SISTEMA DE TEMPLATES (Estilo WordPress) - GKACHELE™ # ============================================================================ def scan_available_themes(): """Escanear todos los templates disponibles y cargar sus configuraciones""" themes = {} if not os.path.exists(THEMES_DIR): return themes for theme_dir in os.listdir(THEMES_DIR): theme_path = os.path.join(THEMES_DIR, theme_dir) if not os.path.isdir(theme_path) or theme_dir.startswith('_'): continue # Ignorar archivos y carpetas que empiezan con _ config_path = os.path.join(theme_path, 'config.json') template_path = os.path.join(theme_path, 'template.html') # Verificar que tenga config.json y template.html if not os.path.exists(config_path) or not os.path.exists(template_path): continue try: with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) themes[theme_dir] = { 'id': theme_dir, 'name': config.get('name', theme_dir), 'description': config.get('description', ''), 'rubro': config.get('rubro', 'general'), 'sections': config.get('sections', []), 'colors': config.get('colors', {}), 'typography': config.get('typography', {}), 'features': config.get('features', {}), 'preview': f'/themes/{theme_dir}/preview.jpg' if os.path.exists(os.path.join(theme_path, 'preview.jpg')) else None } except Exception as e: print(f"⚠️ Error cargando template {theme_dir}: {e}") continue return themes def get_theme_config(theme_id): """Obtener configuración de un template específico""" config_path = os.path.join(THEMES_DIR, theme_id, 'config.json') if not os.path.exists(config_path): return None try: with open(config_path, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: print(f"⚠️ Error cargando config de {theme_id}: {e}") return None def get_themes_by_rubro(rubro): """Obtener templates filtrados por rubro""" all_themes = scan_available_themes() return {k: v for k, v in all_themes.items() if v.get('rubro') == rubro or v.get('rubro') == 'general'} # Crear directorios # NOTA: Ya no se crean bases de datos separadas (cliente-X.db) # TODO está en main.db (sistema multi-tenant) for d in [DATABASE_DIR, SITES_DIR, THEMES_DIR, UPLOADS_DIR]: os.makedirs(d, exist_ok=True) # DB principal MAIN_DB = os.path.join(DATABASE_DIR, 'main.db') def init_db(): """Inicializar base de datos principal - TODO EN UNA SOLA DB (sistema multi-tenant)""" try: conn = sqlite3.connect(MAIN_DB) c = conn.cursor() # Usuarios (todos los clientes en una sola tabla) - Sistema GKACHELE™ c.execute('''CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, role TEXT DEFAULT 'subscriber', plan TEXT DEFAULT 'base', rubro TEXT DEFAULT 'gimnasio', status TEXT DEFAULT 'active', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )''') # Añadir columna role si no existe (migración) try: c.execute('ALTER TABLE users ADD COLUMN role TEXT DEFAULT "subscriber"') except: pass # Columna ya existe # Añadir columna status si no existe (migración) try: c.execute('ALTER TABLE users ADD COLUMN status TEXT DEFAULT "active"') except: pass # Columna ya existe # Crear primer admin si no existe (user_id = 1) c.execute('SELECT COUNT(*) FROM users WHERE id = 1') if c.fetchone()[0] == 0: from werkzeug.security import generate_password_hash admin_password = generate_password_hash('admin123') # Cambiar después c.execute('''INSERT INTO users (id, email, password, role, plan, rubro, status) VALUES (1, 'admin@gkachele.com', ?, 'administrator', 'premium', 'admin', 'active')''', (admin_password,)) print("✅ Usuario administrador creado: admin@gkachele.com / admin123") # Sitios (todos los sitios, filtrados por user_id) c.execute('''CREATE TABLE IF NOT EXISTS sites ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, slug TEXT UNIQUE NOT NULL, theme TEXT DEFAULT 'default', status TEXT DEFAULT 'draft', content_json TEXT, repo_url TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) )''') # Migración: Añadir repo_url si no existe try: c.execute('ALTER TABLE sites ADD COLUMN repo_url TEXT') except: pass # Ya existe # Solicitudes c.execute('''CREATE TABLE IF NOT EXISTS requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL, user_id INTEGER NOT NULL, status TEXT DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (site_id) REFERENCES sites(id), FOREIGN KEY (user_id) REFERENCES users(id) )''') # Media (fotos, imágenes del cliente) c.execute('''CREATE TABLE IF NOT EXISTS media ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, site_id INTEGER NOT NULL, filename TEXT NOT NULL, filepath TEXT NOT NULL, file_type TEXT DEFAULT 'image', uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (site_id) REFERENCES sites(id) )''') # Content (contenido por sección, filtrado por user_id y site_id) c.execute('''CREATE TABLE IF NOT EXISTS content ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, site_id INTEGER NOT NULL, section TEXT NOT NULL, data_json TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (site_id) REFERENCES sites(id) )''') # Settings (configuraciones por usuario/sitio) c.execute('''CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, site_id INTEGER, key TEXT NOT NULL, value TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (site_id) REFERENCES sites(id), UNIQUE(user_id, site_id, key) )''') # Menus (sistema de menús dinámicos) c.execute('''CREATE TABLE IF NOT EXISTS menus ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, site_id INTEGER NOT NULL, location TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, order_index INTEGER DEFAULT 0, parent_id INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (site_id) REFERENCES sites(id), FOREIGN KEY (parent_id) REFERENCES menus(id) )''') # Widgets (áreas de widgets dinámicos) c.execute('''CREATE TABLE IF NOT EXISTS widgets ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, site_id INTEGER NOT NULL, area TEXT NOT NULL, type TEXT NOT NULL, title TEXT, content TEXT, order_index INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (site_id) REFERENCES sites(id) )''') conn.commit() conn.close() print("✅ Base de datos inicializada (multi-tenant - todos los clientes en main.db)") except Exception as e: print(f"❌ Error inicializando DB: {e}") import traceback traceback.print_exc() init_db() # ============================================================================ # FUNCIONES HELPER - GKACHELE™ Template System # ============================================================================ def get_site_menus(site_id, user_id): """Obtener menús del sitio organizados por ubicación""" conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('''SELECT location, title, url, order_index, parent_id FROM menus WHERE site_id = ? AND user_id = ? ORDER BY location, order_index''', (site_id, user_id)) rows = c.fetchall() conn.close() menus = {'header': [], 'footer': [], 'sidebar': []} for row in rows: location, title, url, order_index, parent_id = row menu_item = { 'title': title, 'url': url, 'order': order_index, 'parent_id': parent_id } if location in menus: menus[location].append(menu_item) return menus def get_site_widgets(site_id, user_id, area='sidebar'): """Obtener widgets del sitio por área""" conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('''SELECT type, title, content, order_index FROM widgets WHERE site_id = ? AND user_id = ? AND area = ? ORDER BY order_index''', (site_id, user_id, area)) rows = c.fetchall() conn.close() widgets = [] for row in rows: widget_type, title, content, order_index = row widgets.append({ 'type': widget_type, 'title': title, 'content': content, 'order': order_index }) return widgets def render_gkachele_template(theme, content, site_id=None, user_id=None): """Renderizar template usando estructura GKACHELE (header.php, footer.php, sidebar.php)""" # Obtener menús y widgets si hay site_id menus = {'header': [], 'footer': [], 'sidebar': []} widgets = [] if site_id and user_id: try: menus = get_site_menus(site_id, user_id) widgets = get_site_widgets(site_id, user_id) except Exception as e: print(f"⚠️ Error obteniendo menús/widgets: {e}") # Cargar template del tema PRIMERO (es el contenido principal) theme_template = '' theme_path = os.path.join(THEMES_DIR, theme, 'template.html') if os.path.exists(theme_path): with open(theme_path, 'r', encoding='utf-8') as f: theme_template = f.read() else: # Si no existe template del tema, usar template básico theme_template = '''

{{ hero_title or 'Bienvenido' }}

{{ hero_description or 'Descripción del sitio' }}

''' # Cargar header, footer, sidebar header = '' footer = '' sidebar = '' header_path = os.path.join(THEMES_DIR, '_gkachele', 'header.php') footer_path = os.path.join(THEMES_DIR, '_gkachele', 'footer.php') sidebar_path = os.path.join(THEMES_DIR, '_gkachele', 'sidebar.php') if os.path.exists(header_path): with open(header_path, 'r', encoding='utf-8') as f: header = f.read() else: # Header básico si no existe header = ''' {{ site_name or 'GKACHELE Site' }}
''' if os.path.exists(footer_path): with open(footer_path, 'r', encoding='utf-8') as f: footer = f.read() else: footer = '''
''' if os.path.exists(sidebar_path): with open(sidebar_path, 'r', encoding='utf-8') as f: sidebar = f.read() # Preparar contexto completo - Asegurar que todas las variables esperadas existan template_data = { 'site_name': content.get('site_name', 'GKACHELE Site'), 'hero_title': content.get('hero_title', 'Bienvenido'), 'hero_description': content.get('hero_description', ''), 'colors': content.get('colors', {'primary': '#c94d4d', 'secondary': '#d97757', 'accent': '#f4a261', 'text': '#2c2c2c'}), 'typography': content.get('typography', {'font_family': 'Roboto'}), 'horarios': content.get('horarios', {}), # Asegurar que horarios siempre existe 'redes_sociales': content.get('redes_sociales', {}), # Asegurar que redes_sociales siempre existe 'especialidad_culinaria': content.get('especialidad_culinaria', {}), # Asegurar que especialidad_culinaria siempre existe 'direccion': content.get('direccion', ''), 'telefono': content.get('telefono', ''), 'email': content.get('email', ''), 'capacidad': content.get('capacidad', ''), 'mapa_url': content.get('mapa_url', ''), 'menu_url': content.get('menu_url', ''), 'menu_items': content.get('menu_items', {}), # Platos del menú 'blocks': content.get('blocks', []), # Bloques añadibles 'menus': menus, 'widgets': widgets, **content # Incluir todo el contenido adicional (esto sobrescribirá los valores por defecto si existen) } # Renderizar usando Jinja2 from jinja2 import Template # DETECCIÓN DE TEMPLATE COMPLETO (Full Page) # Forzar restaurante-moderno a ser siempre full page is_full_page = False if theme == 'restaurante-moderno': is_full_page = True elif '' in theme_template or '') def get_theme(theme_id): """Obtener información detallada de un template""" config = get_theme_config(theme_id) if not config: return jsonify({'success': False, 'error': 'Template no encontrado'}), 404 return jsonify({ 'success': True, 'theme': { 'id': theme_id, **config } }) @app.route('/customizer/') def customizer(site_id): """Customizer: Sidebar + Preview""" # Verificar que el sitio existe conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT user_id, slug, theme, content_json FROM sites WHERE id = ?', (site_id,)) site = c.fetchone() conn.close() if not site: return "Sitio no encontrado", 404 # Si hay sesión, verificar que pertenece al usuario if 'user_id' in session and site[0] != session['user_id']: return "No autorizado", 403 content = json.loads(site[3]) if site[3] else {} theme = site[2] # Cargar template del tema si existe theme_template = None theme_path = os.path.join(THEMES_DIR, theme, 'template.html') if os.path.exists(theme_path): with open(theme_path, 'r', encoding='utf-8') as f: theme_template = f.read() # Cargar configuración del template theme_config = get_theme_config(theme) if theme_config: # Si el contenido no tiene colores, usar los del template if not content.get('colors'): content['colors'] = theme_config.get('colors', {}) # Si no tiene tipografía, usar la del template if not content.get('typography'): content['typography'] = theme_config.get('typography', {}) # Obtener todos los templates disponibles para el selector available_themes = scan_available_themes() return render_template('customizer.html', site_id=site_id, slug=site[1], theme=theme, content=content, theme_template=theme_template, theme_config=theme_config, available_themes=available_themes) @app.route('/api/customizer/save', methods=['POST']) def save_customizer(): """Guardar cambios del customizer""" data = request.get_json() site_id = data.get('site_id') content = data.get('content') new_theme = data.get('theme') # Permitir cambiar template conn = sqlite3.connect(MAIN_DB) c = conn.cursor() # Si se cambió el template, actualizarlo if new_theme: # Verificar que el template existe theme_config = get_theme_config(new_theme) if theme_config: c.execute('UPDATE sites SET theme = ? WHERE id = ?', (new_theme, site_id)) # Aplicar colores y tipografía por defecto del nuevo template si no existen if not content.get('colors'): content['colors'] = theme_config.get('colors', {}) if not content.get('typography'): content['typography'] = theme_config.get('typography', {}) c.execute('UPDATE sites SET content_json = ? WHERE id = ?', (json.dumps(content), site_id)) conn.commit() conn.close() return jsonify({'success': True}) @app.route('/api/customizer/update-field', methods=['POST']) def update_field(): """Actualizar un campo específico desde preview (click to edit)""" data = request.get_json() site_id = data.get('site_id') field = data.get('field') # Ej: 'hero_title', 'horarios.lunes_viernes' value = data.get('value') if not site_id or not field: return jsonify({'success': False, 'error': 'Faltan parámetros'}), 400 conn = sqlite3.connect(MAIN_DB) c = conn.cursor() # Obtener contenido actual c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,)) result = c.fetchone() if not result: conn.close() return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404 content = json.loads(result[0]) if result[0] else {} # Actualizar campo (soporta campos anidados como 'horarios.lunes_viernes' o 'menu_items.1.nombre') if '.' in field: # Campo anidado: 'horarios.lunes_viernes' o 'menu_items.1.nombre' parts = field.split('.') current = content for i, part in enumerate(parts[:-1]): if part not in current: current[part] = {} # Si es un número, mantener como string key (para menu_items.1, menu_items.2, etc.) current = current[part] current[parts[-1]] = value else: # Campo simple: 'hero_title' content[field] = value # Guardar c.execute('UPDATE sites SET content_json = ? WHERE id = ?', (json.dumps(content), site_id)) conn.commit() conn.close() return jsonify({'success': True, 'content': content}) @app.route('/api/customizer/get-blocks/') def get_blocks(site_id): """Obtener bloques del sitio para el customizer""" conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,)) result = c.fetchone() conn.close() if not result: return jsonify({'success': False, 'blocks': []}) content = json.loads(result[0]) if result[0] else {} blocks = content.get('blocks', []) return jsonify({'success': True, 'blocks': blocks}) @app.route('/api/customizer/get-content/') def get_content(site_id): """Obtener contenido completo del sitio""" conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,)) result = c.fetchone() conn.close() if not result: return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404 content = json.loads(result[0]) if result[0] else {} return jsonify({'success': True, 'content': content}) @app.route('/api/customizer/add-block', methods=['POST']) def add_block(): """Añadir un nuevo bloque al sitio""" data = request.get_json() site_id = data.get('site_id') block_type = data.get('block_type') block_content = data.get('content', {}) if not site_id or not block_type: return jsonify({'success': False, 'error': 'Faltan parámetros'}), 400 conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,)) result = c.fetchone() if not result: conn.close() return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404 content = json.loads(result[0]) if result[0] else {} # Inicializar blocks si no existe if 'blocks' not in content: content['blocks'] = [] # Crear nuevo bloque new_block = { 'id': f'block_{int(time.time() * 1000)}', 'type': block_type, 'content': block_content, 'order': len(content['blocks']) } content['blocks'].append(new_block) # Guardar c.execute('UPDATE sites SET content_json = ? WHERE id = ?', (json.dumps(content), site_id)) conn.commit() conn.close() return jsonify({'success': True, 'block': new_block}) @app.route('/api/customizer/remove-block', methods=['POST']) def remove_block(): """Eliminar un bloque del sitio""" data = request.get_json() site_id = data.get('site_id') block_id = data.get('block_id') if not site_id or not block_id: return jsonify({'success': False, 'error': 'Faltan parámetros'}), 400 conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,)) result = c.fetchone() if not result: conn.close() return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404 content = json.loads(result[0]) if result[0] else {} if 'blocks' in content: content['blocks'] = [b for b in content['blocks'] if b.get('id') != block_id] # Reordenar for i, block in enumerate(content['blocks']): block['order'] = i # Guardar c.execute('UPDATE sites SET content_json = ? WHERE id = ?', (json.dumps(content), site_id)) conn.commit() conn.close() return jsonify({'success': True}) @app.route('/api/customizer/reorder-blocks', methods=['POST']) def reorder_blocks(): """Reordenar bloques""" data = request.get_json() site_id = data.get('site_id') block_ids = data.get('block_ids', []) if not site_id: return jsonify({'success': False, 'error': 'Faltan parámetros'}), 400 conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,)) result = c.fetchone() if not result: conn.close() return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404 content = json.loads(result[0]) if result[0] else {} if 'blocks' in content: # Reordenar según block_ids blocks_dict = {b['id']: b for b in content['blocks']} content['blocks'] = [blocks_dict[bid] for bid in block_ids if bid in blocks_dict] for i, block in enumerate(content['blocks']): block['order'] = i # Guardar c.execute('UPDATE sites SET content_json = ? WHERE id = ?', (json.dumps(content), site_id)) conn.commit() conn.close() return jsonify({'success': True}) @app.route('/api/customizer/preview-frame/') def preview_frame(site_id): """Frame del preview - renderiza el template completo con estructura GKACHELE""" # Obtener datos del sitio conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT theme, content_json, user_id FROM sites WHERE id = ?', (site_id,)) site = c.fetchone() conn.close() if not site: return "Sitio no encontrado", 404 theme = site[0] content = json.loads(site[1]) if site[1] else {} user_id = site[2] # Usar función helper para renderizar con estructura GKACHELE try: rendered = render_gkachele_template(theme, content, site_id, user_id) print(f"✅ Template renderizado correctamente para site_id={site_id}, theme={theme}") return rendered except Exception as e: import traceback print(f"❌ Error renderizando template: {e}") traceback.print_exc() # DEVOVER HTML VISIBLE EN EL IFRAME PARA DEBUG return f"""

❌ Error Renderizando Preview

Theme: {theme}

Error: {str(e)}

{traceback.format_exc()}
""", 500 # ============================================================================ # SOLICITUDES # ============================================================================ # ============================================================================ # ADMIN # ============================================================================ # ============================================================================ # HELPER FUNCTIONS - Permisos basados en DB (sistema de roles GKACHELE™) # ============================================================================ def user_has_role(user_id, required_role): """Verificar si usuario tiene un rol específico (desde DB)""" if not user_id: return False conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT role FROM users WHERE id = ?', (user_id,)) result = c.fetchone() conn.close() if not result: return False user_role = result[0] or 'subscriber' # Jerarquía de roles (sistema GKACHELE™) role_hierarchy = { 'administrator': 4, 'editor': 3, 'author': 2, 'subscriber': 1 } user_level = role_hierarchy.get(user_role, 1) required_level = role_hierarchy.get(required_role, 1) return user_level >= required_level def user_can(user_id, capability): """Verificar capacidad específica (desde DB)""" if not user_id: return False # Si es admin, puede todo if user_has_role(user_id, 'administrator'): return True # Otras capacidades se pueden añadir aquí # Por ahora, solo admin puede hacer cosas administrativas return False # --- SOLICITUDES Y APROBACIÓN --- @app.route('/dashboard/submit/', methods=['POST']) @login_required def submit_site(site_id): conn = sqlite3.connect(MAIN_DB) c = conn.cursor() # Verificar propiedad c.execute('SELECT id, slug FROM sites WHERE id = ? AND user_id = ?', (site_id, session['user_id'])) site = c.fetchone() if not site: conn.close() return jsonify({'success': False, 'error': 'Sitio no encontrado o no autorizado'}), 404 # Verificar si ya existe solicitud pendiente c.execute('SELECT id FROM requests WHERE site_id = ? AND status = "pending"', (site_id,)) if c.fetchone(): conn.close() return jsonify({'success': False, 'error': 'Ya existe una solicitud pendiente'}), 400 # Crear solicitud try: c.execute('INSERT INTO requests (user_id, site_id, slug, status, created_at) VALUES (?, ?, ?, "pending", CURRENT_TIMESTAMP)', (session['user_id'], site_id, site[1])) # Actualizar estado del sitio c.execute('UPDATE sites SET status = "pending" WHERE id = ?', (site_id,)) conn.commit() return jsonify({'success': True}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 finally: conn.close() @app.route('/admin/approve/', methods=['POST']) @login_required def approve_request(request_id): # Verificar admin (hardcoded ID 1 por ahora, o verificar email) if session.get('user_id') != 1: return jsonify({'success': False, 'error': 'No autorizado'}), 403 conn = sqlite3.connect(MAIN_DB) c = conn.cursor() try: # Obtener info de la solicitud c.execute('''SELECT r.id, r.site_id, r.user_id, s.slug, s.content_json, s.theme, u.rubro, u.email FROM requests r JOIN sites s ON r.site_id = s.id JOIN users u ON r.user_id = u.id WHERE r.id = ?''', (request_id,)) req = c.fetchone() if not req: return jsonify({'success': False, 'error': 'Solicitud no encontrada'}), 404 req_id, site_id, user_id, slug, content, theme, rubro, email = req # --- AUTOMATIZACIÓN GITEA (MOVIDA AQUÍ) --- repo_url = None try: import gitea_connector # Nombre del sitio site_name = slug if content: import json try: content_data = json.loads(content) site_name = content_data.get('site_name', slug) except: pass org_name = f"cliente-{user_id}" repo_name = slug print(f"🚀 [ADMIN] Iniciando creación de repo para {org_name}/{repo_name}...") # 1. Crear Organización (si no existe) gitea_connector.create_org(org_name) # 2. Crear Repositorio repo_data = gitea_connector.create_repo(org_name, repo_name, description=f"Sitio {site_name} ({rubro})") if repo_data: repo_url = repo_data.get('clone_url') print(f"✅ [ADMIN] Repo creado: {repo_url}") # TODO: Aquí iría el commit inicial con el contenido JSON actual # O el trigger de un webhook para desplegar except Exception as e: print(f"❌ [ADMIN] Error Gitea: {e}") import traceback traceback.print_exc() # ------------------------------------------ # Actualizar solicitud c.execute('UPDATE requests SET status = "approved" WHERE id = ?', (request_id,)) # Actualizar sitio (Publicado + Repo) c.execute('UPDATE sites SET status = "published", repo_url = ? WHERE id = ?', (repo_url, site_id)) # Incrementar contador de usuario (opcional, si existe la columna) try: c.execute('UPDATE users SET sitios_publicados = IFNULL(sitios_publicados, 0) + 1 WHERE id = ?', (user_id,)) except: pass conn.commit() return jsonify({'success': True, 'repo_url': repo_url}) except Exception as e: print(f"Error approving: {e}") return jsonify({'success': False, 'error': str(e)}), 500 finally: conn.close() @app.route('/admin') def admin(): """Panel admin - Solo usuarios con rol 'administrator' (desde DB)""" if 'user_id' not in session: return "No autorizado", 403 # Verificar rol desde la base de datos (no hardcodeado) if not user_has_role(session['user_id'], 'administrator'): return "Solo administradores pueden acceder", 403 conn = sqlite3.connect(MAIN_DB) c = conn.cursor() # Solicitudes pendientes c.execute('''SELECT r.id, r.site_id, r.status, s.slug, u.email, r.created_at FROM requests r JOIN sites s ON r.site_id = s.id JOIN users u ON r.user_id = u.id WHERE r.status = 'pending' ORDER BY r.created_at DESC''') requests = [{'id': r[0], 'site_id': r[1], 'status': r[2], 'slug': r[3], 'email': r[4], 'created_at': r[5]} for r in c.fetchall()] # Todos los sitios c.execute('SELECT id, slug, theme, status, user_id FROM sites') sites = [{'id': r[0], 'slug': r[1], 'theme': r[2], 'status': r[3], 'user_id': r[4]} for r in c.fetchall()] # TODOS LOS USUARIOS con estadísticas c.execute('''SELECT u.id, u.email, u.plan, u.rubro, u.created_at, COUNT(DISTINCT s.id) as num_sitios, COUNT(DISTINCT CASE WHEN s.status = 'published' THEN s.id END) as sitios_publicados, COUNT(DISTINCT r.id) as num_solicitudes FROM users u LEFT JOIN sites s ON u.id = s.user_id LEFT JOIN requests r ON u.id = r.user_id GROUP BY u.id ORDER BY u.id DESC''') users = [] for row in c.fetchall(): users.append({ 'id': row[0], 'email': row[1], 'plan': row[2], 'rubro': row[3], 'created_at': row[4], 'num_sitios': row[5] or 0, 'sitios_publicados': row[6] or 0, 'num_solicitudes': row[7] or 0 }) conn.close() return render_template('admin.html', requests=requests, sites=sites, users=users) @app.route('/admin/approve/', methods=['POST']) def approve_site(request_id): """Admin aprueba sitio - Verificar rol desde DB""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 403 if not user_has_role(session['user_id'], 'administrator'): return jsonify({'error': 'Solo administradores'}), 403 conn = sqlite3.connect(MAIN_DB) c = conn.cursor() # Obtener site_id c.execute('SELECT site_id FROM requests WHERE id = ?', (request_id,)) result = c.fetchone() if not result: conn.close() return jsonify({'error': 'Solicitud no encontrada'}), 404 site_id = result[0] # Aprobar: cambiar status a approved y publicar c.execute('UPDATE sites SET status = ? WHERE id = ?', ('published', site_id)) c.execute('UPDATE requests SET status = ? WHERE id = ?', ('approved', request_id)) conn.commit() conn.close() return jsonify({'success': True}) @app.route('/admin/users/delete/', methods=['POST']) def delete_user(user_id): """Eliminar usuario y todos sus datos relacionados - Verificar rol desde DB""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 403 # Verificar rol desde DB (no hardcodeado) if not user_has_role(session['user_id'], 'administrator'): return jsonify({'error': 'Solo administradores'}), 403 # No permitir eliminar al admin principal (user_id = 1) if user_id == 1: return jsonify({'error': 'No se puede eliminar al administrador principal'}), 400 conn = sqlite3.connect(MAIN_DB) c = conn.cursor() try: # Eliminar en orden (respetando foreign keys) # 1. Widgets c.execute('DELETE FROM widgets WHERE user_id = ?', (user_id,)) # 2. Menús c.execute('DELETE FROM menus WHERE user_id = ?', (user_id,)) # 3. Media c.execute('DELETE FROM media WHERE user_id = ?', (user_id,)) # 4. Content c.execute('DELETE FROM content WHERE user_id = ?', (user_id,)) # 5. Settings c.execute('DELETE FROM settings WHERE user_id = ?', (user_id,)) # 6. Requests c.execute('DELETE FROM requests WHERE user_id = ?', (user_id,)) # 7. Sites c.execute('DELETE FROM sites WHERE user_id = ?', (user_id,)) # 8. Usuario c.execute('DELETE FROM users WHERE id = ?', (user_id,)) conn.commit() conn.close() return jsonify({'success': True, 'message': f'Usuario {user_id} eliminado exitosamente'}) except Exception as e: conn.rollback() conn.close() return jsonify({'error': f'Error al eliminar usuario: {str(e)}'}), 500 # ============================================================================ # SITIO PÚBLICO # ============================================================================ @app.route('/site/') def public_site(slug): """Sitio público del cliente - usando estructura GKACHELE""" conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT id, theme, content_json, status, user_id FROM sites WHERE slug = ?', (slug,)) site = c.fetchone() conn.close() if not site or site[3] != 'published': return "Sitio no encontrado o no publicado", 404 site_id = site[0] theme = site[1] content = json.loads(site[2]) if site[2] else {} user_id = site[4] # Renderizar con estructura GKACHELE try: return render_gkachele_template(theme, content, site_id, user_id) except Exception as e: import traceback traceback.print_exc() return render_template('public_site.html', content=content, theme=theme) # ============================================================================ # MAIN # ============================================================================ # ============================================================================ # API ADMIN CLIENTE # ============================================================================ @app.route('/api/admin/upload', methods=['POST']) def upload_media(): """Subir imágenes""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 401 user_id = session['user_id'] files = request.files.getlist('files') if not files: return jsonify({'error': 'No hay archivos'}), 400 uploaded = [] for file in files: if file.filename: filename = f"{user_id}_{secrets.token_hex(8)}_{file.filename}" filepath = os.path.join(UPLOADS_DIR, filename) file.save(filepath) # Guardar en DB conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT id FROM sites WHERE user_id = ? LIMIT 1', (user_id,)) site = c.fetchone() site_id = site[0] if site else None c.execute('INSERT INTO media (user_id, site_id, filename, filepath, file_type) VALUES (?, ?, ?, ?, ?)', (user_id, site_id, filename, filepath, 'image')) conn.commit() conn.close() uploaded.append(filename) return jsonify({'success': True, 'files': uploaded}) @app.route('/api/admin/media') def get_media(): """Obtener media del usuario""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 401 user_id = session['user_id'] conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('SELECT id, filename, filepath, uploaded_at FROM media WHERE user_id = ? ORDER BY uploaded_at DESC', (user_id,)) media = [{'id': r[0], 'filename': r[1], 'filepath': r[2], 'uploaded_at': r[3]} for r in c.fetchall()] conn.close() return jsonify({'success': True, 'media': media}) @app.route('/api/admin/media/', methods=['DELETE']) def delete_media(media_id): """Eliminar media""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 401 user_id = session['user_id'] conn = sqlite3.connect(MAIN_DB) c = conn.cursor() # Verificar que pertenece al usuario c.execute('SELECT filepath FROM media WHERE id = ? AND user_id = ?', (media_id, user_id)) media = c.fetchone() if media: # Eliminar archivo if os.path.exists(media[0]): os.remove(media[0]) # Eliminar de DB c.execute('DELETE FROM media WHERE id = ?', (media_id,)) conn.commit() conn.close() return jsonify({'success': True}) @app.route('/api/admin/settings', methods=['POST']) def update_settings(): """Actualizar configuración del usuario""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 401 data = request.get_json() user_id = session['user_id'] conn = sqlite3.connect(MAIN_DB) c = conn.cursor() if data.get('password'): c.execute('UPDATE users SET password = ? WHERE id = ?', (generate_password_hash(data['password']), user_id)) conn.commit() conn.close() return jsonify({'success': True}) @app.route('/uploads/') def serve_upload(filename): """Servir archivos subidos""" return send_from_directory(UPLOADS_DIR, filename) # ============================================================================ # API MENÚS Y WIDGETS # ============================================================================ @app.route('/api/menus/', methods=['GET']) def get_menus(site_id): """Obtener menús del sitio""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 401 user_id = session['user_id'] menus = get_site_menus(site_id, user_id) return jsonify({'success': True, 'menus': menus}) @app.route('/api/menus/', methods=['POST']) def save_menu(site_id): """Guardar/actualizar menú""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 401 data = request.get_json() user_id = session['user_id'] conn = sqlite3.connect(MAIN_DB) c = conn.cursor() # Eliminar menús existentes del sitio c.execute('DELETE FROM menus WHERE site_id = ? AND user_id = ?', (site_id, user_id)) # Insertar nuevos menús for menu_item in data.get('menus', []): c.execute('''INSERT INTO menus (user_id, site_id, location, title, url, order_index, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?)''', (user_id, site_id, menu_item['location'], menu_item['title'], menu_item['url'], menu_item.get('order', 0), menu_item.get('parent_id'))) conn.commit() conn.close() return jsonify({'success': True}) @app.route('/api/widgets/', methods=['GET']) def get_widgets(site_id): """Obtener widgets del sitio""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 401 user_id = session['user_id'] area = request.args.get('area', 'sidebar') widgets = get_site_widgets(site_id, user_id, area) return jsonify({'success': True, 'widgets': widgets}) @app.route('/api/widgets/', methods=['POST']) def save_widget(site_id): """Guardar/actualizar widget""" if 'user_id' not in session: return jsonify({'error': 'No autorizado'}), 401 data = request.get_json() user_id = session['user_id'] conn = sqlite3.connect(MAIN_DB) c = conn.cursor() c.execute('''INSERT INTO widgets (user_id, site_id, area, type, title, content, order_index) VALUES (?, ?, ?, ?, ?, ?, ?)''', (user_id, site_id, data.get('area', 'sidebar'), data.get('type', 'text'), data.get('title'), data.get('content'), data.get('order', 0))) conn.commit() conn.close() return jsonify({'success': True}) # ============================================================================ # MAIN # ============================================================================ # Inicializar DB al importar (con manejo de errores) try: init_db() except Exception as e: print(f"⚠️ Error en init_db: {e}") # Continuar aunque falle, se creará cuando se use if __name__ == '__main__': port = int(os.environ.get('PORT', 5001)) print("🚀 Demo SaaS iniciado") print(f"📍 http://localhost:{port}") app.run(debug=True, host='0.0.0.0', port=port)