Files

1317 lines
80 KiB
HTML

{% extends "base.html" %}
{% block title %}Configuración Global del Sistema - Admin Edu-Space{% endblock %}
{% block extra_css %}
<style>
.settings-nav-tabs .nav-link {
color: var(--bs-secondary-color);
font-weight: 500;
border: none;
border-bottom: 2px solid transparent;
padding: 0.75rem 1.25rem;
transition: all 0.2s ease-in-out;
}
.settings-nav-tabs .nav-link.active {
color: #B43E8E;
border-bottom: 2px solid #B43E8E;
background: transparent;
font-weight: 600;
}
[data-bs-theme="dark"] .settings-nav-tabs .nav-link.active {
color: #FF8AB6;
border-bottom-color: #FF8AB6;
}
.config-card {
border-radius: 12px;
transition: box-shadow 0.2s ease;
}
.stat-badge-pill {
border-radius: 30px;
font-size: 0.82rem;
padding: 0.35rem 0.75rem;
}
.dlq-alert {
border-left: 4px solid #ef4444;
}
.pass-toggle-btn {
cursor: pointer;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid pt-4 pb-5 px-3 px-md-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h1 class="h4 mb-1 d-flex align-items-center">
<i class="bi bi-gear-wide-connected text-primary me-2"></i>
<span>Panel de Configuración Global</span>
</h1>
<p class="text-muted small mb-0">Gestión centralizada de Servidor de Correo SMTP, Métodos de Autenticación SSO y Sincronización Moodle 4.1</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" onclick="refreshQueueStats()" id="btnRefreshStats">
<i class="bi bi-arrow-clockwise me-1"></i>Actualizar Estado
</button>
<a href="/dashboard" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Volver
</a>
</div>
</div>
<!-- Feedback Alerts -->
<div id="settingsAlertContainer"></div>
<!-- Navegación por Pestañas -->
<div class="card border-0 shadow-sm mb-4 config-card">
<div class="card-header bg-transparent border-bottom px-3 pt-2 pb-0">
<ul class="nav settings-nav-tabs" id="settingsTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-smtp-btn" data-bs-toggle="tab" data-bs-target="#tab-smtp" type="button" role="tab">
<i class="bi bi-envelope-at me-2 text-warning"></i>Servidor de Correo (SMTP)
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-sso-btn" data-bs-toggle="tab" data-bs-target="#tab-sso" type="button" role="tab">
<i class="bi bi-shield-lock me-2 text-info"></i>Métodos de Login & Google OAuth
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-moodle-btn" data-bs-toggle="tab" data-bs-target="#tab-moodle" type="button" role="tab">
<i class="bi bi-mortarboard me-2 text-danger"></i>Moodle 4.1 & Cola de Sync
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-ui-btn" data-bs-toggle="tab" data-bs-target="#tab-ui" type="button" role="tab">
<i class="bi bi-window-desktop me-2 text-primary"></i>Interfaz & Dashboard
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link text-danger" id="tab-maintenance-btn" data-bs-toggle="tab" data-bs-target="#tab-maintenance" type="button" role="tab">
<i class="bi bi-radioactive me-2 text-danger"></i>Mantenimiento &amp; Reinicio
</button>
</li>
</ul>
</div>
<div class="card-body p-3 p-md-4">
<div class="tab-content" id="settingsTabsContent">
<!-- ========================================== -->
<!-- PESTAÑA 1: SERVIDOR DE CORREO (SMTP) -->
<!-- ========================================== -->
<div class="tab-pane fade show active" id="tab-smtp" role="tabpanel">
<div class="row g-4">
<div class="col-lg-8">
<form id="smtpForm" onsubmit="saveSmtpConfig(event)">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label small fw-semibold">Servidor Host SMTP</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-hdd-network"></i></span>
<input type="text" class="form-control" id="smtp_host" name="host" placeholder="smtp.gmail.com" value="{{ settings.smtp.host if settings and settings.smtp else 'smtp.gmail.com' }}" required>
</div>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Puerto</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-hash"></i></span>
<input type="number" class="form-control" id="smtp_port" name="port" placeholder="587" value="{{ settings.smtp.port if settings and settings.smtp else '587' }}" required>
</div>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Usuario / Cuenta de Envío</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" class="form-control" id="smtp_user" name="user" placeholder="notificaciones@unicaba.edu.ar" value="{{ settings.smtp.user if settings and settings.smtp else '' }}">
</div>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Contraseña / App Password (Cifrada)</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="smtp_password" name="password" placeholder="••••••••••••" value="{{ settings.smtp.password_masked if settings and settings.smtp else '' }}">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassVisibility('smtp_password')">
<i class="bi bi-eye"></i>
</button>
</div>
<small class="text-muted" style="font-size: 11px;">Almacenada con cifrado simétrico Fernet. Ingrese nuevo valor solo si desea cambiarla.</small>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Protocolo de Seguridad</label>
<select class="form-select form-select-sm" id="smtp_security" name="security">
<option value="tls" {% if not settings or not settings.smtp or settings.smtp.security == 'tls' %}selected{% endif %}>TLS / STARTTLS (Puerto 587)</option>
<option value="ssl" {% if settings and settings.smtp and settings.smtp.security == 'ssl' %}selected{% endif %}>SSL Directo (Puerto 465)</option>
<option value="none" {% if settings and settings.smtp and settings.smtp.security == 'none' %}selected{% endif %}>Sin Cifrado (Puerto 25)</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Email Remitente (From)</label>
<input type="email" class="form-control form-control-sm" id="smtp_from_email" name="from_email" placeholder="notificaciones@unicaba.edu.ar" value="{{ settings.smtp.from_email if settings and settings.smtp else '' }}">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Nombre Remitente</label>
<input type="text" class="form-control form-control-sm" id="smtp_from_name" name="from_name" placeholder="Admin Edu-Space UniCABA" value="{{ settings.smtp.from_name if settings and settings.smtp else 'Admin Edu-Space' }}">
</div>
</div>
<div class="mt-4 pt-3 border-top d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-sm btn-primary px-3" id="btnSaveSmtp">
<i class="bi bi-check2-circle me-1"></i>Guardar Parámetros SMTP
</button>
<button type="button" class="btn btn-sm btn-outline-success px-3" data-bs-toggle="modal" data-bs-target="#testSmtpModal">
<i class="bi bi-send-check me-1"></i>Probar Conexión SMTP
</button>
</div>
</form>
</div>
<div class="col-lg-4">
<div class="card bg-body-tertiary border-0 p-3 rounded-3 h-100">
<h6 class="fw-bold mb-2 d-flex align-items-center">
<i class="bi bi-info-circle text-primary me-2"></i>Instrucciones de Correo
</h6>
<p class="small text-muted mb-2">Este módulo administra el transporte dinámico para el envío de correos transaccionales:</p>
<ul class="small text-muted ps-3 mb-3">
<li>Notificación de asignación de comisiones a profesores.</li>
<li>Convocatorias a mesas de exámenes finales.</li>
<li>Alertas de auditoría e incidentes de seguridad.</li>
</ul>
<div class="alert alert-info py-2 px-3 small mb-0">
<i class="bi bi-shield-lock-fill me-1"></i>
<strong>Google Workspace:</strong> Use contraseñas de aplicación (App Passwords) generadas desde la consola de Google si tiene 2FA activo.
</div>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- PESTAÑA 2: MÉTODOS DE LOGIN & GOOGLE OAUTH -->
<!-- ========================================== -->
<div class="tab-pane fade" id="tab-sso" role="tabpanel">
<div class="row g-4">
<div class="col-lg-6">
{% set is_local_active = (not settings or not settings.auth_providers or settings.auth_providers.local or settings.auth_providers.local_enabled) %}
{% set is_google_active = (settings and settings.auth_providers and (settings.auth_providers.google or settings.auth_providers.google_enabled)) %}
{% set is_moodle_active = (settings and settings.auth_providers and (settings.auth_providers.moodle or settings.auth_providers.moodle_enabled)) %}
<div class="card border-0 bg-body-tertiary p-3 rounded-3 mb-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold mb-0 d-flex align-items-center">
<i class="bi bi-toggle2-on text-primary me-2"></i>Métodos de Autenticación Habilitados
</h6>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle" style="font-size: 0.72rem;">
Políticas de Acceso
</span>
</div>
<form id="providersForm" onsubmit="saveAuthProviders(event)">
<!-- Login Local -->
<div class="p-2 rounded mb-2 border bg-body">
<div class="d-flex align-items-center justify-content-between mb-1">
<div class="d-flex align-items-center">
<i class="bi bi-key-fill text-primary me-2 fs-6"></i>
<span class="fw-semibold small">Login Nativo con Contraseña</span>
</div>
<div class="d-flex align-items-center gap-2">
<span id="badgeLocalStatus" class="badge {% if is_local_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-danger-subtle text-danger border border-danger-subtle{% endif %}" style="font-size: 0.72rem;">
{% if is_local_active %}Habilitado{% else %}Desactivado{% endif %}
</span>
<div class="form-check form-switch fs-5 m-0">
<input class="form-check-input" type="checkbox" id="auth_local_enabled" onchange="updateProviderStatusBadges()" {% if is_local_active %}checked{% endif %}>
</div>
</div>
</div>
<small class="text-muted d-block ps-4" style="font-size: 11px;">Permite inicio de sesión local para alumnos, docentes y bedelía. Si se desactiva, queda reservado exclusivamente para rol ADMIN como contingencia.</small>
</div>
<!-- Google OAuth -->
<div class="p-2 rounded mb-2 border bg-body">
<div class="d-flex align-items-center justify-content-between mb-1">
<div class="d-flex align-items-center">
<i class="bi bi-google text-danger me-2 fs-6"></i>
<span class="fw-semibold small">Google OAuth 2.0 (Google Workspace)</span>
</div>
<div class="d-flex align-items-center gap-2">
<span id="badgeGoogleStatus" class="badge {% if is_google_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border border-secondary-subtle{% endif %}" style="font-size: 0.72rem;">
{% if is_google_active %}Habilitado{% else %}Desactivado{% endif %}
</span>
<div class="form-check form-switch fs-5 m-0">
<input class="form-check-input" type="checkbox" id="auth_google_enabled" onchange="updateProviderStatusBadges()" {% if is_google_active %}checked{% endif %}>
</div>
</div>
</div>
<small class="text-muted d-block ps-4" style="font-size: 11px;">Inicio de sesión con cuentas institucionales @unicaba.edu.ar mediante Single Sign-On (SSO).</small>
</div>
<!-- Moodle SSO -->
<div class="p-2 rounded mb-3 border bg-body">
<div class="d-flex align-items-center justify-content-between mb-1">
<div class="d-flex align-items-center">
<i class="bi bi-mortarboard-fill text-warning me-2 fs-6"></i>
<span class="fw-semibold small">Moodle SSO Delegado</span>
</div>
<div class="d-flex align-items-center gap-2">
<span id="badgeMoodleStatus" class="badge {% if is_moodle_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border border-secondary-subtle{% endif %}" style="font-size: 0.72rem;">
{% if is_moodle_active %}Habilitado{% else %}Desactivado{% endif %}
</span>
<div class="form-check form-switch fs-5 m-0">
<input class="form-check-input" type="checkbox" id="auth_moodle_enabled" onchange="updateProviderStatusBadges()" {% if is_moodle_active %}checked{% endif %}>
</div>
</div>
</div>
<small class="text-muted d-block ps-4" style="font-size: 11px;">Valida credenciales contra el servidor de Moodle 4.1 y auto-aprovisiona perfiles.</small>
</div>
<!-- Aviso de contingencia si local está apagado -->
<div id="alertLocalDisabled" class="alert alert-warning py-2 px-3 small mb-3 {% if is_local_active %}d-none{% endif %}" style="font-size: 11px;">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Atención de Contingencia:</strong> Al apagar el Login Nativo, solo los usuarios con rol <code>ADMIN</code> podrán iniciar sesión con usuario y contraseña local.
</div>
<div class="d-flex justify-content-between align-items-center">
<button type="submit" class="btn btn-sm btn-primary px-3" id="btnSaveProviders">
<i class="bi bi-save me-1"></i>Guardar Métodos de Autenticación
</button>
<button type="button" class="btn btn-sm btn-link text-decoration-none text-muted py-0" onclick="refreshAuthProvidersLive()" title="Recargar estado desde servidor">
<i class="bi bi-arrow-clockwise me-1"></i>Sincronizar
</button>
</div>
</form>
</div>
<!-- Control de Modo Demo y Cuentas de Prueba Iniciales -->
<div class="card border border-warning-subtle shadow-sm p-3 rounded-3 mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-bold mb-0 d-flex align-items-center text-body">
<i class="bi bi-shield-lock-fill text-warning me-2"></i>Acceso Rápido Demo & Cuentas de Prueba
</h6>
<span id="demoModeBadge" class="badge {% if demo_access_enabled %}bg-warning-subtle text-warning border border-warning-subtle{% else %}bg-secondary-subtle text-secondary border border-secondary-subtle{% endif %}">
{% if demo_access_enabled %}Activo en Login{% else %}Deshabilitado{% endif %}
</span>
</div>
<p class="small text-muted mb-2" style="font-size: 0.82rem;">
Permite ocultar el bloque de <em>"Acceso Rápido Demo (1 Clic)"</em> de la pantalla de inicio de sesión y deshabilitar las 3 cuentas de usuario de prueba iniciales para pasar a producción.
</p>
<div class="p-2 rounded bg-body-tertiary border mb-3 small">
<div class="d-flex justify-content-between align-items-center mb-1">
<span><i class="bi bi-building-gear me-1 text-warning"></i><strong>Bedelía:</strong> <code>bedelia@edu-space.com</code></span>
<span id="badgeBedelia" class="badge {% if demo_accounts_active %}bg-success-subtle text-success{% else %}bg-danger-subtle text-danger{% endif %}">
{% if demo_accounts_active %}Habilitado{% else %}Desactivado{% endif %}
</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-1">
<span><i class="bi bi-person-video3 me-1 text-info"></i><strong>Docente:</strong> <code>docente@edu-space.com</code></span>
<span id="badgeDocente" class="badge {% if demo_accounts_active %}bg-success-subtle text-success{% else %}bg-danger-subtle text-danger{% endif %}">
{% if demo_accounts_active %}Habilitado{% else %}Desactivado{% endif %}
</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span><i class="bi bi-mortarboard me-1 text-success"></i><strong>Alumno:</strong> <code>alumno@edu-space.com</code></span>
<span id="badgeAlumno" class="badge {% if demo_accounts_active %}bg-success-subtle text-success{% else %}bg-danger-subtle text-danger{% endif %}">
{% if demo_accounts_active %}Habilitado{% else %}Desactivado{% endif %}
</span>
</div>
</div>
<div>
<button type="button" class="btn btn-sm {% if demo_access_enabled or demo_accounts_active %}btn-outline-danger{% else %}btn-outline-success{% endif %}" id="btnToggleDemoMode" onclick="toggleDemoMode()">
<i class="bi {% if demo_access_enabled or demo_accounts_active %}bi-shield-slash{% else %}bi-shield-check{% endif %} me-1"></i>
<span id="btnToggleDemoText">
{% if demo_access_enabled or demo_accounts_active %}
Deshabilitar Acceso Demo y Cuentas Default
{% else %}
Reactivar Acceso Demo y Cuentas Default
{% endif %}
</span>
</button>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border p-3 rounded-3">
<h6 class="fw-bold mb-3 d-flex align-items-center">
<i class="bi bi-google text-danger me-2"></i>Parámetros Google OAuth 2.0
</h6>
<form id="googleOauthForm" onsubmit="saveGoogleOAuth(event)">
<div class="mb-2">
<label class="form-label small fw-semibold">Client ID</label>
<input type="text" class="form-control form-control-sm" id="google_client_id" placeholder="apps.googleusercontent.com" value="{{ settings.google_oauth.client_id if settings and settings.google_oauth else '' }}">
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Client Secret (Cifrado)</label>
<div class="input-group input-group-sm">
<input type="password" class="form-control" id="google_client_secret" placeholder="••••••••••••" value="{{ settings.google_oauth.client_secret_masked if settings and settings.google_oauth else '' }}">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassVisibility('google_client_secret')">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Dominios Permitidos (separados por coma)</label>
<input type="text" class="form-control form-control-sm" id="google_allowed_domains" placeholder="unicaba.edu.ar, lasalle.edu.ar" value="{{ settings.google_oauth.allowed_domains if settings and settings.google_oauth else 'unicaba.edu.ar' }}">
<small class="text-muted" style="font-size: 11px;">Restricción arquitectónica estricta similar al módulo de AlumnosLS.</small>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Ruta de Redirección Autorizada (Callback URL)</label>
<input type="text" class="form-control form-control-sm" id="google_callback_url" value="{{ settings.google_oauth.callback_url if settings and settings.google_oauth else '/auth/google/callback' }}">
</div>
<button type="submit" class="btn btn-sm btn-primary" id="btnSaveGoogle">
<i class="bi bi-check-circle me-1"></i>Guardar Credenciales Google
</button>
</form>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- PESTAÑA 3: MOODLE 4.1 & COLA ASÍNCRONA -->
<!-- ========================================== -->
<div class="tab-pane fade" id="tab-moodle" role="tabpanel">
<div class="row g-4 mb-4">
<div class="col-lg-6">
<div class="card border p-3 rounded-3 h-100">
<h6 class="fw-bold mb-3 d-flex align-items-center">
<i class="bi bi-cpu text-primary me-2"></i>Conexión Web Services Moodle 4.1
</h6>
<form id="moodleConfigForm" onsubmit="saveMoodleConfig(event)">
<div class="mb-2">
<label class="form-label small fw-semibold">URL Base del Servidor Moodle</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-link-45deg"></i></span>
<input type="url" class="form-control" id="moodle_server_url" placeholder="http://10.0.0.207/moodle" value="{{ settings.moodle.server_url if settings and settings.moodle else 'http://10.0.0.207/moodle' }}" required>
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Token de Web Services (Cifrado)</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="moodle_ws_token" placeholder="••••••••••••" value="{{ settings.moodle.ws_token_masked if settings and settings.moodle else '' }}" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePassVisibility('moodle_ws_token')">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label small fw-semibold">Timeout (segundos)</label>
<input type="number" class="form-control form-control-sm" id="moodle_timeout" value="{{ settings.moodle.timeout if settings and settings.moodle else '10' }}">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Intervalo Cron (min)</label>
<input type="number" class="form-control form-control-sm" id="moodle_sync_interval" value="{{ settings.moodle.sync_interval_minutes if settings and settings.moodle else '15' }}">
</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="moodle_auto_sync" {% if not settings or not settings.moodle or settings.moodle.auto_sync_enabled %}checked{% endif %}>
<label class="form-check-label small fw-semibold" for="moodle_auto_sync">Habilitar Sincronización Automática en Background</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-sm btn-primary" id="btnSaveMoodle">
<i class="bi bi-save me-1"></i>Guardar Parámetros
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="testMoodleConn()" id="btnTestMoodle">
<i class="bi bi-broadcast me-1"></i>Probar Conexión
</button>
</div>
</form>
</div>
</div>
<div class="col-lg-6">
<div class="card border p-3 rounded-3 h-100 bg-body-tertiary">
<h6 class="fw-bold mb-3 d-flex align-items-center">
<i class="bi bi-bar-chart-steps text-info me-2"></i>Monitor de Cola & Tolerancia a Fallos
</h6>
<p class="small text-muted mb-3">
Las creaciones de usuarios, matriculaciones y asignaciones no bloquean a Edu-Space. Se encolan y reintentan con <strong>Exponential Backoff</strong>. Si superan los intentos máximos, van a la <strong>Dead Letter Queue (DLQ)</strong>.
</p>
<div class="row g-2 mb-3 text-center">
<div class="col-4">
<div class="p-2 border rounded bg-body">
<div class="fs-4 fw-bold text-primary" id="statPending">0</div>
<small class="text-muted" style="font-size:11px;">Pendientes</small>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded bg-body">
<div class="fs-4 fw-bold text-warning" id="statRetrying">0</div>
<small class="text-muted" style="font-size:11px;">Reintentando</small>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded bg-body">
<div class="fs-4 fw-bold text-danger" id="statDlq">0</div>
<small class="text-muted" style="font-size:11px;">Fallidas (DLQ)</small>
</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-primary flex-grow-1" onclick="processQueueNow()" id="btnProcessNow">
<i class="bi bi-lightning-charge me-1"></i>Forzar Sincronización Ahora
</button>
<button class="btn btn-sm btn-outline-danger" onclick="retryAllDlq()" id="btnRetryDlq">
<i class="bi bi-arrow-repeat me-1"></i>Reintentar Fallidas DLQ
</button>
</div>
</div>
</div>
</div>
<!-- Tabla de Tareas Recientes / DLQ -->
<div class="card border-0 shadow-sm rounded-3">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center px-3 py-2">
<span class="fw-semibold small"><i class="bi bi-list-task me-1"></i>Tareas Recientes de Sincronización</span>
<div class="d-flex gap-2">
<select class="form-select form-select-sm" id="filterStatus" onchange="loadQueueTasks(1)" style="width: 140px;">
<option value="">Todas</option>
<option value="FAILED">Fallidas (DLQ)</option>
<option value="PENDING">Pendientes</option>
<option value="RETRYING">Reintentando</option>
<option value="COMPLETED">Completadas</option>
</select>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0" style="font-size: 12.5px;">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Acción</th>
<th>Entidad</th>
<th>Estado</th>
<th>Intentos</th>
<th>Detalle / Error</th>
<th>Fecha</th>
<th class="text-end">Acciones</th>
</tr>
</thead>
<tbody id="queueTasksTableBody">
<tr>
<td colspan="8" class="text-center py-3 text-muted">Cargando tareas de sincronización...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- PESTAÑA 4: INTERFAZ & DASHBOARD -->
<!-- ========================================== -->
<div class="tab-pane fade" id="tab-ui" role="tabpanel">
<div class="row g-4">
<div class="col-lg-8">
<form id="uiConfigForm" onsubmit="saveUiConfig(event)">
<div class="card border shadow-none bg-body-tertiary p-3 mb-4 rounded-3">
<h6 class="fw-bold mb-3 d-flex align-items-center">
<i class="bi bi-layout-text-window-reverse text-primary me-2"></i>Personalización Visual del Dashboard
</h6>
<div class="form-check form-switch p-0 d-flex align-items-center justify-content-between mb-2 border rounded p-3 bg-body">
<div class="pe-3">
<label class="form-check-label fw-bold mb-1 d-block text-body" for="show_role_banner_switch">
Mostrar banner de saludo y selector de roles en el Dashboard
</label>
<div class="text-muted small">
Habilita el banner superior <code>¡Hola, {Usuario}!</code> junto a la insignia de rol y la barra de cambio de perspectiva de roles (Admin, Bedelía, Docente, Alumno) en el Dashboard.
<div class="text-warning-emphasis small mt-1">
<i class="bi bi-info-circle me-1"></i>Si se desactiva, el dashboard se mostrará directamente con los indicadores operativos del rol del usuario, evitando confusiones.
</div>
</div>
</div>
<input class="form-check-input ms-2 fs-4" type="checkbox" role="switch" id="show_role_banner_switch"
{% if settings.ui and settings.ui.show_role_banner %}checked{% elif not settings.ui or settings.ui.show_role_banner is not defined %}checked{% endif %}>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary btn-sm px-4" id="btnSaveUi">
<i class="bi bi-check2-circle me-1"></i>Guardar Preferencias de Interfaz
</button>
</div>
</form>
</div>
<!-- Panel de Consejos / UX -->
<div class="col-lg-4">
<div class="card border-0 bg-primary-subtle text-primary-emphasis p-3 rounded-3 mb-3">
<h6 class="fw-bold mb-2"><i class="bi bi-lightbulb me-1"></i>Experiencia de Usuario (UX)</h6>
<p class="small mb-0">
El banner de saludo institucional y selector de perspectiva es ideal durante capacitaciones. Al deshabilitarlo para el uso diario institucional, los administradores y usuarios acceden de inmediato a sus módulos de gestión y carteleras sin distracciones ni dudas de rol.
</p>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- PESTAÑA 5: MANTENIMIENTO & REINICIO -->
<!-- ========================================== -->
<div class="tab-pane fade" id="tab-maintenance" role="tabpanel">
<div class="row g-4">
<div class="col-lg-8">
<!-- Danger Zone Card -->
<div class="card border border-danger shadow-sm rounded-3 overflow-hidden">
<div class="card-header bg-danger text-white py-3">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="d-flex align-items-center">
<i class="bi bi-exclamation-triangle-fill fs-4 me-2"></i>
<div>
<h5 class="h6 fw-bold mb-0 text-white">Zona de Peligro: Reinicialización Total del Sistema</h5>
<small class="text-white-50">Depuración integral de datos operacionales para inicio de ciclo o migración en limpio</small>
</div>
</div>
<span class="badge bg-white text-danger font-monospace px-2 py-1 small">Requiere Clave Admin</span>
</div>
</div>
<div class="card-body p-4 bg-body">
<p class="mb-3 text-body">
Esta herramienta permite vaciar completamente los datos operacionales generados por la actividad académica y de uso diario en la plataforma, restableciendo el sistema a su estado inicial.
</p>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="p-3 rounded-3 bg-danger-subtle border border-danger-subtle h-100">
<h6 class="fw-bold text-danger mb-2 d-flex align-items-center">
<i class="bi bi-trash3-fill me-2"></i>Datos que se eliminarán:
</h6>
<ul class="small mb-0 ps-3 text-danger-emphasis">
<li><strong>Historiales:</strong> Toda la bitácora histórica y registros previos de auditoría.</li>
<li><strong>Reservas:</strong> Todas las reservas de aulas, asignaciones de espacios y estados.</li>
<li><strong>Espacios y Sedes:</strong> Todas las aulas físicas, virtuales, recursos y edificios/sedes.</li>
<li><strong>Académica:</strong> Carreras, materias, comisiones, docentes asignados, matrículas, notas y ciclos lectivos.</li>
<li><strong>Colas de sincronización:</strong> Tareas pendientes o fallidas hacia sistemas externos.</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded-3 bg-success-subtle border border-success-subtle h-100">
<h6 class="fw-bold text-success mb-2 d-flex align-items-center">
<i class="bi bi-shield-check me-2"></i>Datos que se PRESERVAN intactos:
</h6>
<ul class="small mb-0 ps-3 text-success-emphasis">
<li><strong>Cuentas de Usuarios:</strong> Todos los usuarios (Administradores, Bedeles, Docentes, Alumnos) con sus contraseñas activas.</li>
<li><strong>Roles y Permisos (RBAC):</strong> Estructura completa de perfiles institucionales.</li>
<li><strong>Configuración Global:</strong> Preferencias de correo SMTP, Single Sign-On, Moodle y opciones de interfaz.</li>
<li><strong>Tipos de Hitos:</strong> Tipos maestros configurados (Parciales, Finales, TP, etc.).</li>
<li><strong>Módulo de Administración:</strong> Todos los componentes de la pestaña Administración.</li>
</ul>
</div>
</div>
</div>
<div class="alert alert-warning py-2 px-3 small d-flex align-items-center mb-4">
<i class="bi bi-shield-lock-fill text-warning fs-5 me-2 flex-shrink-0"></i>
<div>
Por estrictas razones de seguridad institucional, para ejecutar esta acción deberá ingresar la <strong>contraseña del usuario Administrador</strong> actualmente autenticado.
</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-danger btn-sm px-4 fw-semibold shadow-sm" data-bs-toggle="modal" data-bs-target="#modalResetSystem">
<i class="bi bi-radioactive me-2"></i>Reinicializar Sistema...
</button>
</div>
</div>
</div>
</div>
<!-- Info / Good Practices -->
<div class="col-lg-4">
<div class="card border-0 bg-body-tertiary p-3 rounded-3 mb-3">
<h6 class="fw-bold mb-2"><i class="bi bi-info-circle text-primary me-1"></i>¿Cuándo usar esta función?</h6>
<p class="small text-muted mb-2">
Utilice la reinicialización cuando desee comenzar un nuevo ciclo lectivo desde cero, tras finalizar períodos de prueba o capacitación, o para cargar una nueva grilla de sedes y carreras sin arrastrar información residual.
</p>
<p class="small text-muted mb-0">
Al conservar intactos los usuarios y las configuraciones de seguridad, el personal administrativo y docente mantiene su acceso ininterrumpido a la plataforma.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Prueba Conexión SMTP -->
<div class="modal fade" id="testSmtpModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title fw-bold"><i class="bi bi-send-check text-primary me-2"></i>Prueba de Conexión SMTP</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
</div>
<div class="modal-body">
<p class="small text-muted mb-3">Se verificará el handshake con el servidor SMTP y opcionalmente se enviará un correo real de prueba con diseño institucional.</p>
<div class="mb-3">
<label class="form-label small fw-semibold">Correo de Destino para Prueba</label>
<input type="email" class="form-control form-control-sm" id="test_smtp_recipient" placeholder="su-email@unicaba.edu.ar">
</div>
<div id="testSmtpFeedback"></div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cerrar</button>
<button type="button" class="btn btn-sm btn-primary" onclick="runSmtpTest()" id="btnRunSmtpTest">
<i class="bi bi-play-fill me-1"></i>Ejecutar Prueba
</button>
</div>
</div>
</div>
</div>
<!-- Modal Confirmar Reinicialización del Sistema -->
<div class="modal fade" id="modalResetSystem" tabindex="-1" aria-labelledby="modalResetSystemLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-danger text-white py-2">
<h6 class="modal-title fw-bold" id="modalResetSystemLabel">
<i class="bi bi-shield-exclamation me-2"></i>Confirmación Crítica: Reinicio Total del Sistema
</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<div class="alert alert-danger py-2 px-3 small mb-3">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>¡Atención!</strong> Esta acción es irreversible. Se eliminarán todas las reservas, aulas, sedes, materias, carreras, comisiones, notas y registros de auditoría previa.
</div>
<p class="small text-muted mb-3">
Las cuentas de usuario y la configuración del sistema permanecerán intactas. Para autorizar la purga definitiva, ingrese su <strong>contraseña de Administrador</strong>:
</p>
<div id="resetFeedbackAlert"></div>
<div class="mb-3">
<label for="admin_reset_password" class="form-label small fw-bold">Contraseña del Administrador en sesión <span class="text-danger">*</span></label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key-fill text-danger"></i></span>
<input type="password" class="form-control" id="admin_reset_password" placeholder="Ingrese su contraseña institucional" required autocomplete="current-password">
<button class="btn btn-outline-secondary pass-toggle-btn" type="button" onclick="togglePassVisibility('admin_reset_password')" title="Mostrar/ocultar contraseña">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="confirm_reset_checkbox" onchange="document.getElementById('btnConfirmSystemReset').disabled = !this.checked">
<label class="form-check-label small text-body" for="confirm_reset_checkbox">
Entiendo las consecuencias irreversibles de esta purga y confirmo la reinicialización de los datos.
</label>
</div>
</div>
<div class="modal-footer py-2 bg-light">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal" id="btnCancelReset">Cancelar</button>
<button type="button" class="btn btn-sm btn-danger fw-semibold px-3" id="btnConfirmSystemReset" disabled onclick="executeSystemReset()">
<i class="bi bi-radioactive me-1"></i>Confirmar Reinicio Completo
</button>
</div>
</div>
</div>
</div>
<script>
function showAlert(message, type = 'success') {
const container = document.getElementById('settingsAlertContainer');
container.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show py-2 px-3 small" role="alert">
<i class="bi bi-${type === 'success' ? 'check-circle' : 'exclamation-triangle'} me-2"></i>
${message}
<button type="button" class="btn-close py-2" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function togglePassVisibility(inputId) {
const input = document.getElementById(inputId);
input.type = input.type === 'password' ? 'text' : 'password';
}
// -------------------------------------------------------------
// Guardar Configuración SMTP
// -------------------------------------------------------------
async function saveSmtpConfig(e) {
e.preventDefault();
const btn = document.getElementById('btnSaveSmtp');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Guardando...';
const payload = {
host: document.getElementById('smtp_host').value.trim(),
port: document.getElementById('smtp_port').value.trim(),
user: document.getElementById('smtp_user').value.trim(),
password: document.getElementById('smtp_password').value.trim(),
security: document.getElementById('smtp_security').value,
from_email: document.getElementById('smtp_from_email').value.trim(),
from_name: document.getElementById('smtp_from_name').value.trim()
};
try {
const res = await fetch('/api/v1/admin/settings/smtp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (res.ok) {
showAlert(data.message || 'Configuración SMTP guardada exitosamente.', 'success');
} else {
showAlert(data.message || 'Error al guardar configuración SMTP.', 'danger');
}
} catch (err) {
showAlert('Error de comunicación con el servidor: ' + err.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check2-circle me-1"></i>Guardar Parámetros SMTP';
}
}
// -------------------------------------------------------------
// Probar Conexión SMTP en Vivo
// -------------------------------------------------------------
async function runSmtpTest() {
const btn = document.getElementById('btnRunSmtpTest');
const fb = document.getElementById('testSmtpFeedback');
const recipient = document.getElementById('test_smtp_recipient').value.trim();
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Probando...';
fb.innerHTML = '<div class="text-muted small py-2"><span class="spinner-border spinner-border-sm me-1"></span>Conectando al servidor SMTP...</div>';
try {
const res = await fetch('/api/v1/admin/settings/smtp/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test_recipient: recipient })
});
const data = await res.json();
if (res.ok) {
fb.innerHTML = `<div class="alert alert-success py-2 px-3 small mt-2 mb-0"><i class="bi bi-check-circle me-1"></i>${data.message}</div>`;
} else {
fb.innerHTML = `<div class="alert alert-danger py-2 px-3 small mt-2 mb-0"><i class="bi bi-x-circle me-1"></i>${data.message}</div>`;
}
} catch (err) {
fb.innerHTML = `<div class="alert alert-danger py-2 px-3 small mt-2 mb-0">Error: ${err.message}</div>`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-fill me-1"></i>Ejecutar Prueba';
}
}
// -------------------------------------------------------------
// Reactividad Dinámica de Badges de Métodos de Autenticación
// -------------------------------------------------------------
function updateProviderStatusBadges() {
const localCheck = document.getElementById('auth_local_enabled');
const googleCheck = document.getElementById('auth_google_enabled');
const moodleCheck = document.getElementById('auth_moodle_enabled');
const badgeLocal = document.getElementById('badgeLocalStatus');
const badgeGoogle = document.getElementById('badgeGoogleStatus');
const badgeMoodle = document.getElementById('badgeMoodleStatus');
const alertLocal = document.getElementById('alertLocalDisabled');
if (localCheck && badgeLocal) {
if (localCheck.checked) {
badgeLocal.className = 'badge bg-success-subtle text-success border border-success-subtle';
badgeLocal.textContent = 'Habilitado';
if (alertLocal) alertLocal.classList.add('d-none');
} else {
badgeLocal.className = 'badge bg-danger-subtle text-danger border border-danger-subtle';
badgeLocal.textContent = 'Desactivado';
if (alertLocal) alertLocal.classList.remove('d-none');
}
}
if (googleCheck && badgeGoogle) {
if (googleCheck.checked) {
badgeGoogle.className = 'badge bg-success-subtle text-success border border-success-subtle';
badgeGoogle.textContent = 'Habilitado';
} else {
badgeGoogle.className = 'badge bg-secondary-subtle text-secondary border border-secondary-subtle';
badgeGoogle.textContent = 'Desactivado';
}
}
if (moodleCheck && badgeMoodle) {
if (moodleCheck.checked) {
badgeMoodle.className = 'badge bg-success-subtle text-success border border-success-subtle';
badgeMoodle.textContent = 'Habilitado';
} else {
badgeMoodle.className = 'badge bg-secondary-subtle text-secondary border border-secondary-subtle';
badgeMoodle.textContent = 'Desactivado';
}
}
}
// -------------------------------------------------------------
// Sincronización en Vivo de Métodos de Autenticación desde API
// -------------------------------------------------------------
async function refreshAuthProvidersLive() {
try {
const res = await fetch('/admin/settings/auth-providers');
if (res.ok) {
const data = await res.json();
if (data && data.auth_providers) {
const ap = data.auth_providers;
const localCheck = document.getElementById('auth_local_enabled');
const googleCheck = document.getElementById('auth_google_enabled');
const moodleCheck = document.getElementById('auth_moodle_enabled');
if (localCheck && ap.local !== undefined) localCheck.checked = Boolean(ap.local);
if (googleCheck && ap.google !== undefined) googleCheck.checked = Boolean(ap.google);
if (moodleCheck && ap.moodle !== undefined) moodleCheck.checked = Boolean(ap.moodle);
updateProviderStatusBadges();
showAlert('Estados de métodos de autenticación sincronizados con el servidor.', 'info');
}
}
} catch (e) {
console.warn('Error al sincronizar auth providers en vivo:', e.message);
}
}
// -------------------------------------------------------------
// Guardar Métodos de Autenticación
// -------------------------------------------------------------
async function saveAuthProviders(e) {
e.preventDefault();
const btn = document.getElementById('btnSaveProviders');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Guardando...';
const payload = {
local_enabled: document.getElementById('auth_local_enabled').checked,
local: document.getElementById('auth_local_enabled').checked,
google_enabled: document.getElementById('auth_google_enabled').checked,
google: document.getElementById('auth_google_enabled').checked,
moodle_enabled: document.getElementById('auth_moodle_enabled').checked,
moodle: document.getElementById('auth_moodle_enabled').checked
};
try {
let res = await fetch('/admin/settings/auth-providers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
});
if (!res.ok) {
// Reintento por endpoint API directo
res = await fetch('/api/v1/admin/settings/auth-providers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
const data = await res.json();
if (res.ok) {
if (data.auth_providers) {
const ap = data.auth_providers;
if (ap.local !== undefined) document.getElementById('auth_local_enabled').checked = Boolean(ap.local);
if (ap.google !== undefined) document.getElementById('auth_google_enabled').checked = Boolean(ap.google);
if (ap.moodle !== undefined) document.getElementById('auth_moodle_enabled').checked = Boolean(ap.moodle);
}
updateProviderStatusBadges();
showAlert(data.message || 'Métodos de autenticación actualizados y guardados exitosamente.', 'success');
} else {
showAlert(data.message || 'Error al actualizar métodos de acceso.', 'danger');
}
} catch (err) {
showAlert('Error de comunicación: ' + err.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-save me-1"></i>Guardar Métodos de Autenticación';
}
}
document.addEventListener('DOMContentLoaded', () => {
updateProviderStatusBadges();
});
// -------------------------------------------------------------
// Alternar Modo Demo y Cuentas de Prueba Default
// -------------------------------------------------------------
async function toggleDemoMode() {
const btn = document.getElementById('btnToggleDemoMode');
const btnText = document.getElementById('btnToggleDemoText');
const badge = document.getElementById('demoModeBadge');
btn.disabled = true;
const isCurrentlyActive = btn.classList.contains('btn-outline-danger');
const targetEnable = !isCurrentlyActive;
try {
const res = await fetch('/admin/settings/demo-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ enabled: targetEnable })
});
const contentType = res.headers.get('content-type') || '';
let data = {};
if (contentType.includes('application/json')) {
data = await res.json();
} else {
const rawText = await res.text();
throw new Error(res.ok ? 'Respuesta no válida' : `Error del servidor (${res.status})`);
}
if (res.ok) {
showAlert(data.message, targetEnable ? 'info' : 'success');
if (targetEnable) {
btn.className = 'btn btn-sm btn-outline-danger';
btn.querySelector('i').className = 'bi bi-shield-slash me-1';
btnText.textContent = 'Deshabilitar Acceso Demo y Cuentas Default';
badge.className = 'badge bg-warning-subtle text-warning border border-warning-subtle';
badge.textContent = 'Activo en Login';
['badgeBedelia', 'badgeDocente', 'badgeAlumno'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.className = 'badge bg-success-subtle text-success'; el.textContent = 'Habilitado'; }
});
} else {
btn.className = 'btn btn-sm btn-outline-success';
btn.querySelector('i').className = 'bi bi-shield-check me-1';
btnText.textContent = 'Reactivar Acceso Demo y Cuentas Default';
badge.className = 'badge bg-secondary-subtle text-secondary border border-secondary-subtle';
badge.textContent = 'Deshabilitado';
['badgeBedelia', 'badgeDocente', 'badgeAlumno'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.className = 'badge bg-danger-subtle text-danger'; el.textContent = 'Desactivado'; }
});
}
} else {
showAlert(data.message || 'Error al cambiar modo demo', 'danger');
}
} catch (err) {
showAlert('Error de conexión: ' + err.message, 'danger');
} finally {
btn.disabled = false;
}
}
// -------------------------------------------------------------
// Guardar Google OAuth 2.0
// -------------------------------------------------------------
async function saveGoogleOAuth(e) {
e.preventDefault();
const btn = document.getElementById('btnSaveGoogle');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Guardando...';
const payload = {
client_id: document.getElementById('google_client_id').value.trim(),
client_secret: document.getElementById('google_client_secret').value.trim(),
allowed_domains: document.getElementById('google_allowed_domains').value.trim(),
callback_url: document.getElementById('google_callback_url').value.trim()
};
try {
const res = await fetch('/api/v1/admin/settings/google-oauth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (res.ok) {
showAlert(data.message || 'Credenciales de Google OAuth guardadas.', 'success');
} else {
showAlert(data.message || 'Error al guardar Google OAuth.', 'danger');
}
} catch (err) {
showAlert('Error: ' + err.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Guardar Credenciales Google';
}
}
// -------------------------------------------------------------
// Guardar Configuración Moodle
// -------------------------------------------------------------
async function saveMoodleConfig(e) {
e.preventDefault();
const btn = document.getElementById('btnSaveMoodle');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Guardando...';
const payload = {
server_url: document.getElementById('moodle_server_url').value.trim(),
ws_token: document.getElementById('moodle_ws_token').value.trim(),
timeout: document.getElementById('moodle_timeout').value.trim(),
auto_sync_enabled: document.getElementById('moodle_auto_sync').checked,
sync_interval_minutes: document.getElementById('moodle_sync_interval').value.trim()
};
try {
const res = await fetch('/api/v1/admin/settings/moodle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (res.ok) {
showAlert(data.message || 'Configuración Moodle guardada.', 'success');
} else {
showAlert(data.message || 'Error al guardar Moodle.', 'danger');
}
} catch (err) {
showAlert('Error: ' + err.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-save me-1"></i>Guardar Parámetros';
}
}
// -------------------------------------------------------------
// Probar Conexión Moodle
// -------------------------------------------------------------
async function testMoodleConn() {
const btn = document.getElementById('btnTestMoodle');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Probando...';
const payload = {
server_url: document.getElementById('moodle_server_url').value.trim(),
ws_token: document.getElementById('moodle_ws_token').value.trim()
};
try {
const res = await fetch('/api/v1/admin/settings/moodle/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (res.ok) {
showAlert(`Conexión exitosa con Moodle: Sitio '${data.sitename}' (Versión Moodle ${data.version || '4.1'})`, 'success');
} else {
showAlert(`Fallo de conexión Moodle: ${data.message || 'Verifique URL y Token'}`, 'danger');
}
} catch (err) {
showAlert('Error de conexión con Moodle: ' + err.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-broadcast me-1"></i>Probar Conexión';
}
}
// -------------------------------------------------------------
// Monitor de Cola & DLQ
// -------------------------------------------------------------
async function refreshQueueStats() {
try {
const res = await fetch('/api/v1/admin/moodle/queue/stats');
const data = await res.json();
if (res.ok && data.data) {
const s = data.data.summary || {};
document.getElementById('statPending').innerText = (s.PENDING || 0) + (s.PROCESSING || 0);
document.getElementById('statRetrying').innerText = s.RETRYING || 0;
document.getElementById('statDlq').innerText = s.FAILED || 0;
}
await loadQueueTasks(1);
} catch (err) {
console.warn('Queue stats refresh notice:', err);
}
}
async function loadQueueTasks(page = 1) {
const tbody = document.getElementById('queueTasksTableBody');
const status = document.getElementById('filterStatus').value;
try {
const res = await fetch(`/api/v1/admin/moodle/queue/tasks?page=${page}&status=${status}`);
const data = await res.json();
if (!res.ok || !data.tasks || data.tasks.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-3 text-muted">No hay tareas de sincronización registradas.</td></tr>';
return;
}
tbody.innerHTML = data.tasks.map(t => {
let statusBadge = '<span class="badge bg-secondary">PENDIENTE</span>';
if (t.status === 'COMPLETED') statusBadge = '<span class="badge bg-success">COMPLETADA</span>';
else if (t.status === 'PROCESSING') statusBadge = '<span class="badge bg-info text-dark">PROCESANDO</span>';
else if (t.status === 'RETRYING') statusBadge = '<span class="badge bg-warning text-dark">REINTENTANDO</span>';
else if (t.status === 'FAILED') statusBadge = '<span class="badge bg-danger">FALLIDA (DLQ)</span>';
const errText = t.error_message ? `<span class="text-danger" title="${escapeHtml(t.error_message)}">${escapeHtml(t.error_message.slice(0, 45))}...</span>` : '<span class="text-muted">-</span>';
const actionBtn = (t.status === 'FAILED' || t.status === 'RETRYING')
? `<button class="btn btn-xs btn-outline-primary" onclick="retrySingleTask(${t.id})" title="Reintentar"><i class="bi bi-arrow-repeat"></i></button>`
: '';
return `
<tr>
<td><strong>#${t.id}</strong></td>
<td><code>${escapeHtml(t.action)}</code></td>
<td>${escapeHtml(t.entity_type)}:${escapeHtml(t.entity_id || '')}</td>
<td>${statusBadge}</td>
<td>${t.attempts}/${t.max_attempts}</td>
<td>${errText}</td>
<td><small class="text-muted">${t.created_at ? t.created_at.slice(0, 16) : '-'}</small></td>
<td class="text-end">${actionBtn}</td>
</tr>
`;
}).join('');
} catch (err) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-3 text-danger">Error al cargar tareas: ${err.message}</td></tr>`;
}
}
async function processQueueNow() {
const btn = document.getElementById('btnProcessNow');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Procesando...';
try {
const res = await fetch('/api/v1/admin/moodle/queue/process-now', { method: 'POST' });
const data = await res.json();
showAlert(data.message || 'Sincronización procesada.', res.ok ? 'success' : 'danger');
await refreshQueueStats();
} catch (err) {
showAlert('Error al disparar sincronización: ' + err.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-lightning-charge me-1"></i>Forzar Sincronización Ahora';
}
}
async function retryAllDlq() {
if (!confirm('¿Confirma que desea reintentar todas las tareas fallidas de la Dead Letter Queue?')) return;
try {
const res = await fetch('/api/v1/admin/moodle/queue/retry-all', { method: 'POST' });
const data = await res.json();
showAlert(data.message || 'Tareas reiniciadas.', 'info');
await refreshQueueStats();
} catch (err) {
showAlert('Error: ' + err.message, 'danger');
}
}
async function retrySingleTask(id) {
try {
const res = await fetch(`/api/v1/admin/moodle/queue/tasks/${id}/retry`, { method: 'POST' });
const data = await res.json();
showAlert(data.message || `Tarea #${id} reiniciada.`, 'info');
await refreshQueueStats();
} catch (err) {
showAlert('Error: ' + err.message, 'danger');
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, function(m) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m];
});
}
// -------------------------------------------------------------
// Guardar Configuración de Interfaz (UI & Dashboard)
// -------------------------------------------------------------
async function saveUiConfig(e) {
e.preventDefault();
const btn = document.getElementById('btnSaveUi');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Guardando...';
const showRoleBanner = document.getElementById('show_role_banner_switch').checked;
try {
const res = await fetch('/admin/settings/ui', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ show_role_banner: showRoleBanner })
});
const data = await res.json();
if (res.ok) {
showAlert(data.message || 'Preferencias de interfaz guardadas correctamente.', 'success');
} else {
showAlert(data.message || 'Error al guardar preferencias de interfaz.', 'danger');
}
} catch (err) {
showAlert('Error de comunicación con el servidor: ' + err.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check2-circle me-1"></i>Guardar Preferencias de Interfaz';
}
}
// -------------------------------------------------------------
// Reinicialización Total del Sistema (Requiere Contraseña Admin)
// -------------------------------------------------------------
async function executeSystemReset() {
const passwordInput = document.getElementById('admin_reset_password');
const feedback = document.getElementById('resetFeedbackAlert');
const btnConfirm = document.getElementById('btnConfirmSystemReset');
const btnCancel = document.getElementById('btnCancelReset');
const check = document.getElementById('confirm_reset_checkbox');
const password = (passwordInput ? passwordInput.value : '').trim();
if (!password) {
feedback.innerHTML = '<div class="alert alert-warning py-2 px-3 small mb-3"><i class="bi bi-exclamation-circle me-1"></i>Por favor ingrese su contraseña de administrador.</div>';
if (passwordInput) passwordInput.focus();
return;
}
btnConfirm.disabled = true;
btnCancel.disabled = true;
btnConfirm.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Reinicializando sistema...';
feedback.innerHTML = '<div class="alert alert-info py-2 px-3 small mb-3"><span class="spinner-border spinner-border-sm me-1"></span>Procesando purga de datos operacionales...</div>';
try {
const res = await fetch('/admin/settings/reset_system', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (res.ok && data.status === 'success') {
feedback.innerHTML = `<div class="alert alert-success py-2 px-3 small mb-3"><i class="bi bi-check-circle-fill me-1"></i>${data.message}</div>`;
showAlert(data.message, 'success');
passwordInput.value = '';
if (check) check.checked = false;
setTimeout(() => {
const modalEl = document.getElementById('modalResetSystem');
const modalInstance = bootstrap.Modal.getInstance(modalEl);
if (modalInstance) modalInstance.hide();
window.location.reload();
}, 2000);
} else {
feedback.innerHTML = `<div class="alert alert-danger py-2 px-3 small mb-3"><i class="bi bi-x-circle-fill me-1"></i>${data.message || 'Error al autorizar la reinicialización.'}</div>`;
btnConfirm.disabled = false;
btnCancel.disabled = false;
btnConfirm.innerHTML = '<i class="bi bi-radioactive me-1"></i>Confirmar Reinicio Completo';
if (passwordInput) passwordInput.focus();
}
} catch (err) {
feedback.innerHTML = `<div class="alert alert-danger py-2 px-3 small mb-3"><i class="bi bi-exclamation-triangle-fill me-1"></i>Error de comunicación: ${err.message}</div>`;
btnConfirm.disabled = false;
btnCancel.disabled = false;
btnConfirm.innerHTML = '<i class="bi bi-radioactive me-1"></i>Confirmar Reinicio Completo';
}
}
// Cargar estadísticas y tareas al iniciar
document.addEventListener('DOMContentLoaded', () => {
refreshQueueStats();
});
</script>
{% endblock %}