feat(phase5): integracion auth hibrida, email dinamico y moodle 4.1 backend con tolerancia a fallos

This commit is contained in:
2026-09-23 13:34:59 -03:00
parent 7ad4e3f099
commit 1aae432bba
22 changed files with 2993 additions and 49 deletions
+2
View File
@@ -222,3 +222,5 @@ frontend/.env
legacy_admin-edu-space/
.vscode/
.schemathesis/
AlumnosLS
+31 -4
View File
@@ -6,10 +6,37 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1
---
## [Unreleased]
### Planificado (Fase 5 del Roadmap MVP)
- **Despliegue e Infraestructura (Fase 5):** `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15, con proxy inverso Nginx, SSL/TLS y compresión.
- **Integraciones Universitarias (Fase 5):** Módulo de exportación/importación compatible con SIU Guaraní 3 y sincronización con Moodle.
- **Pruebas de Carga y Hardening (Fase 5):** Pruebas de estrés de concurrencia y validación final de producción.
### Planificado (Fase 6 del Roadmap MVP)
- **Despliegue e Infraestructura (Fase 6):** `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15, con proxy inverso Nginx, SSL/TLS y compresión.
- **Sincronización Continua Moodle (Fase 6):** Automatización de workers persistentes en background para el procesamiento continuo de la cola de Web Services Moodle 4.1.
- **Pruebas de Carga y Hardening (Fase 6):** Pruebas de estrés de concurrencia y validación final de producción.
---
## [3.0.0] - 2026-09-23
### Añadido
- **Fase 5 — Integración Auth, Email & Moodle Backend:**
- **Épica 1: Panel de Configuración Global (Perfil ADMIN):**
- Cifrado simétrico de credenciales y secretos con Fernet (`CryptoService` en `backend/app/services/crypto_service.py`).
- Migración y extensión de `SystemSetting` con columnas `category` e `is_encrypted`, junto con métodos polimórficos `get_decrypted_value()` y `get_masked_value()`.
- Endpoints REST `/api/v1/admin/settings/*` para administración dinámica de parámetros SMTP, toggles de proveedores SSO, Google OAuth y Web Services de Moodle 4.1.
- Nueva vista responsiva `frontend/views/admin/settings/global_config.html` con soporte optimizado para pantallas 720p, 1360x768 y 1080p, y enlace integrado en el menú de navegación lateral.
- **Épica 2: Infraestructura Asíncrona y Caché:**
- Servicio híbrido de caché `CacheService` con integración Redis y fallback en memoria/TTL.
- Modelo `MoodleSyncTask` y procesador asíncrono `MoodleQueueService` con Exponential Backoff y Dead Letter Queue (DLQ).
- Métricas en vivo y reintentos manuales individuales y masivos para tareas en DLQ.
- **Épica 3: Autenticación Híbrida (SSO & Local):**
- Login nativo como fallback con contingencia para cuentas con rol ADMIN.
- Integración de Google OAuth 2.0 basada en la arquitectura de AlumnosLS con filtrado estricto por dominios institucionales (`google_allowed_domains`).
- Autenticación delegada contra Moodle 4.1 vía `/login/token.php`, mapeo inicial de roles (Admin/Docente/Alumno) y gestión de roles 100% desacoplada en Edu-Space.
- **Épica 4: Integración Bidireccional Moodle 4.1:**
- Adaptador `MoodleClient` con Web Services de Moodle 4.1 (`core_user_*`, `enrol_manual_*`, `core_enrol_*`, `core_role_*`).
- Encolado no bloqueante de sincronización al crear o editar usuarios en Edu-Space.
- **Épica 5: Motor de Notificaciones (Email Server):**
- Cliente SMTP dinámico en `EmailService` con desencriptación en caliente de contraseñas.
- Plantillas HTML transaccionales para asignación de comisiones a profesores, citación a exámenes y pruebas en vivo de conectividad.
- **Pruebas y Verificación:**
- Suite de integración automatizada `backend/tests/test_phase6_integration.py` con 7 pruebas aprobadas al 100%. Regresión completa de 30 pruebas en verde en los módulos core.
---
+54
View File
@@ -4,6 +4,60 @@
---
## [Fase 5: Integración Auth, Email & Moodle Backend] — v3.0.0 (2026-09-23)
**Estado:** ✅ 100% Completada
### Épica 1: Panel de Configuración Global (Perfil ADMIN)
* **Cifrado Simétrico Fernet:** Creación de `CryptoService` (`backend/app/services/crypto_service.py`) derivando de forma determinística una clave Fernet de 32 bytes a partir de `SECRET_KEY`.
* **Modelo `SystemSetting` Extendido:** Soporte para columnas `category` y flag `is_encrypted`. Almacenamiento seguro de secretos con métodos polimórficos `get_decrypted_value()` y enmascaramiento con `get_masked_value()`.
* **Endpoints Administrativos:**
- `GET /api/v1/admin/settings/all`: Retorna la configuración completa organizada por categorías (`smtp`, `auth_providers`, `google_oauth`, `moodle`) con secretos enmascarados.
- `POST /api/v1/admin/settings/smtp`: Configuración dinámica de Host, Puerto, Usuario, Contraseña cifrada, Protocolo de Seguridad (TLS/SSL/NONE), y Remitente.
- `POST /api/v1/admin/settings/smtp/test`: Handshake SMTP en vivo y envío opcional de correo de prueba con plantilla HTML oficial.
- `POST /api/v1/admin/settings/auth-providers`: Activación y desactivación de proveedores de acceso (`local`, `google`, `moodle`).
- `POST /api/v1/admin/settings/google-oauth`: Guardado de Client ID, Client Secret cifrado, Dominios autorizados y Callback URL.
- `POST /api/v1/admin/settings/moodle`: URL base del servidor, Token Web Services cifrado, timeout y frecuencia de sincronización.
* **Interfaz de Usuario Web:** Nueva pantalla responsiva `frontend/views/admin/settings/global_config.html` con pestañas navegables, validación en vivo, spinners y monitores de estado (100% adaptada para resoluciones desde 720p hasta 1080p).
* **Navegación:** Integración de enlace directo *"Config. Global"* en el menú lateral bajo Administración en `frontend/views/base.html`.
### Épica 2: Infraestructura Asíncrona y Caché
* **Capa de Caché Híbrida:** Creación de `CacheService` (`backend/app/services/cache_service.py`) con soporte nativo de Redis y fallback transparente en memoria con TTL para sesiones de usuario y perfiles de Moodle.
* **Cola Persistente de Sincronización:** Modelo `MoodleSyncTask` (`backend/app/models/sync_task.py`) y servicio `MoodleQueueService` (`backend/app/services/moodle_queue_service.py`) para registrar operaciones de sincronización (`CREATE_USER`, `UPDATE_USER`, `ENROL_USER`, `UNENROL_USER`, `ASSIGN_ROLE`).
* **Tolerancia a Fallos y DLQ:** Política de Exponential Backoff ($2^{\text{intentos}} \times 30$s) ante indisponibilidad de Moodle. Si se superan los intentos máximos, la tarea se traslada automáticamente a la **Dead Letter Queue (DLQ)** (`status = 'FAILED'`).
* **Endpoints de Cola & DLQ:**
- `GET /api/v1/admin/moodle/queue/stats`: Métricas en tiempo real (Pendientes, En Proceso, Reintentando, Completadas, Fallidas DLQ).
- `GET /api/v1/admin/moodle/queue/tasks`: Lista paginada con filtrado por estado.
- `POST /api/v1/admin/moodle/queue/process-now`: Disparo manual inmediato de sincronización.
- `POST /api/v1/admin/moodle/queue/tasks/<id>/retry`: Reintento de tarea individual de DLQ.
- `POST /api/v1/admin/moodle/queue/retry-all`: Reintento masivo de todas las tareas en DLQ.
### Épica 3: Sistema de Autenticación Híbrida (SSO & Local)
* **Login Local Refactorizado:** Endpoint `/api/v1/auth/login` respeta la configuración global. Si `auth_local_enabled` está desactivado, sólo admite acceso de contingencia a usuarios con rol `ADMIN`.
* **Google OAuth 2.0 (Arquitectura AlumnosLS):** Endpoint `POST /api/v1/auth/google` con validación estricta de dominios institucionales (`google_allowed_domains`), aprovisionamiento automático de perfil y emisión de tokens JWT.
* **Moodle Delegated Login:** Endpoint `POST /api/v1/auth/moodle` validando contra `/login/token.php` de Moodle 4.1 (`moodle_mobile_app`), obtención de datos de usuario con Web Services, caché en Redis y emisión de JWT.
* **Mapeo y Desacople de Roles:** Si el usuario es nuevo y posee rol de manager/admin en Moodle, se le asigna rol local `ADMIN`; en caso contrario se asigna `Docente` o `Alumno`. La administración y cambio de roles posterior permanece 100% local e independiente de Moodle.
* **Frontend Login:** Vista `frontend/views/auth/login.html` adaptada para consultar `/api/v1/auth/providers` y renderizar dinámicamente el botón de Google Workspace, el botón desplegable de Moodle y el formulario tradicional.
### Épica 4: Integración Bidireccional Moodle 4.1 (Backend)
* **Cliente REST Moodle 4.1:** `MoodleClient` (`backend/app/services/moodle_client.py`) con métodos para:
- `core_user_create_users`, `core_user_update_users`, `core_user_get_users`.
- `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`.
- `core_role_assign_roles`, `core_role_unassign_roles`.
- `test_connection()` contra `core_webservice_get_site_info`.
* **Desacople en CRUD Local:** Creación y edición de usuarios en `backend/app/routes/api/admin.py` encola automáticamente la sincronización sin bloquear las peticiones locales.
### Épica 5: Motor de Notificaciones (Email Server)
* **Cliente SMTP Dinámico:** Creación de `EmailService` (`backend/app/services/email_service.py`) que obtiene credenciales en tiempo de ejecución de la base de datos (con desencriptación de contraseña al vuelo).
* **Plantillas Transaccionales Profesionales:**
- `notify_teacher_assignment`: Notificación a profesores sobre asignación a comisiones y horarios.
- `notify_exam_schedule`: Convocatoria y citación a mesas de examen final.
- `test_smtp_connection`: Verificación de handshake y autenticación SMTP con feedback claro.
### Cobertura de Pruebas
* Suite completa en `backend/tests/test_phase6_integration.py` con 7 pruebas unitarias e integrales que validan cifrado Fernet, SystemSetting, CacheService, MoodleQueueService (Backoff & DLQ), EmailService dinámico, y autenticación híbrida (Google & Moodle). **30/30 pruebas pasando en verde** en la suite global de regresión.
---
## [Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación] — v2.8.0 (2026-09-23)
**Estado:** ✅ 100% Completada
+30 -6
View File
@@ -114,16 +114,40 @@ Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░
---
### Fase 5: Producción Institucional, CI/CD e Integraciones 🏢 *(Planificada)*
### Fase 5: Integración Auth, Email & Moodle Backend 🛡️ ✉️ 🎓 ✅ *(100% Completado)*
* **Objetivo:** Centralizar la gestión de credenciales, autenticación híbrida, motor transaccional de correo y sincronización bidireccional asíncrona con Moodle 4.1 con tolerancia total a fallos.
* **Épicas Entregadas:**
1. **Épica 1 — Panel de Configuración Global (Perfil ADMIN):**
- [x] Módulo SMTP: UI/API dinámica con host, puerto, usuario, seguridad (TLS/SSL/NONE) y contraseña cifrada con Fernet (`/admin/settings`).
- [x] Módulo SSO: Toggles para habilitar/deshabilitar Login Local, Google OAuth 2.0 y Moodle SSO en base de datos.
- [x] Credenciales OAuth & Moodle: Client ID/Secret de Google y URL de Servidor / Token Web Services de Moodle 4.1.
2. **Épica 2 — Infraestructura Asíncrona y Caché:**
- [x] Capa de Caché Híbrida: `CacheService` con integración Redis y fallback en memoria/TTL.
- [x] Cola Persistente `MoodleSyncTask` con reintentos exponenciales y Dead Letter Queue (DLQ).
- [x] Panel de monitorización de cola con métricas en tiempo real, botón de "Forzar Sincronización Inmediata" y reintento masivo de tareas fallidas.
3. **Épica 3 — Autenticación Híbrida (SSO & Local Tolerante a Fallos):**
- [x] Login Local: Fallback con contraseña nativa y contingencia para ADMIN cuando está desactivado para usuarios generales.
- [x] Google OAuth 2.0: Restricción arquitectónica de dominios (`@unicaba.edu.ar`, `@lasalle.edu.ar`) inspirada en AlumnosLS.
- [x] Moodle Delegated Login: Autenticación contra `/login/token.php` de Moodle 4.1 con mapeo inicial de roles (Admin/Docente/Alumno) y desacople local para cambios posteriores por Bedelía/Admin.
4. **Épica 4 — Adaptadores Moodle 4.1 (Bidireccional):**
- [x] Cliente REST Moodle 4.1 (`moodle_client`) con soporte para `core_user_create_users`, `core_user_update_users`, `core_user_get_users`, `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`, `core_role_assign_roles`, `core_role_unassign_roles`.
- [x] Encolado automático y no bloqueante al crear o editar usuarios en Edu-Space.
5. **Épica 5 — Motor de Notificaciones (Email Dinámico):**
- [x] Transporte SMTP dinámico (`email_service`) instanciado en tiempo de ejecución leyendo credenciales descifradas de la BD.
- [x] Notificaciones transaccionales automáticas para profesores (asignación de comisiones, cronogramas de exámenes y alertas de sistema).
---
### Fase 6: Producción Institucional, CI/CD y Despliegue 🏢 *(En curso)*
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
* **Estadios:**
1. **Estadio 5.1 — Despliegue e Infraestructura:**
1. **Estadio 6.1 — Despliegue e Infraestructura:**
- [ ] `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15.
- [ ] Proxy inverso Nginx con certificados SSL/TLS y compresión Brotli/Gzip.
2. **Estadio 5.2 — Integraciones Universitarias:**
- [ ] Módulo de exportación/importación compatible con SIU Guaraní 3.
- [ ] Sincronización automática de aulas virtuales con Moodle institucional.
3. **Estadio 5.3 — Pruebas de Carga y Hardening:**
2. **Estadio 6.2 — Sincronización Continua Moodle:**
- [x] Sincronización automática de aulas virtuales y usuarios con Moodle 4.1 institucional (`10.0.0.207`).
- [ ] Automatización de workers persistentes (systemd / supervisor) para el procesamiento continuo de la cola.
3. **Estadio 6.3 — Pruebas de Carga y Hardening:**
- [ ] Pruebas de estrés de concurrencia en días pico de inicio de cuatrimestre (10.000 solicitudes simultáneas).
---
+3 -1
View File
@@ -12,6 +12,7 @@ from .audit_log import AuditLog
from .enrollment import StudentEnrollment
from .setting import SystemSetting
from .grade import MilestoneGrade
from .sync_task import MoodleSyncTask
__all__ = [
'User',
@@ -36,5 +37,6 @@ __all__ = [
'AuditLog',
'StudentEnrollment',
'SystemSetting',
'MilestoneGrade'
'MilestoneGrade',
'MoodleSyncTask'
]
+88 -7
View File
@@ -1,10 +1,12 @@
from app import db
from datetime import datetime
from app.services.crypto_service import CryptoService
class SystemSetting(db.Model):
"""
Parámetros de configuración del sistema persistentes en base de datos.
Permite almacenar enlaces de integración (e.g. Google Sheets), flags globales y metadatos.
Permite almacenar enlaces de integración (e.g. Google Sheets, Moodle),
flags globales, credenciales SMTP encriptadas y secretos OAuth.
"""
__tablename__ = 'system_settings'
@@ -12,10 +14,13 @@ class SystemSetting(db.Model):
key = db.Column(db.String(100), unique=True, nullable=False, index=True)
value = db.Column(db.Text, nullable=True)
description = db.Column(db.String(255), nullable=True)
category = db.Column(db.String(50), default='system', nullable=True)
is_encrypted = db.Column(db.Boolean, default=False, nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@classmethod
def get_value(cls, key, default=None):
"""Retorna el valor tal como está almacenado en BD."""
try:
record = cls.query.filter_by(key=key).first()
if record and record.value is not None and record.value.strip() != '':
@@ -24,27 +29,103 @@ class SystemSetting(db.Model):
pass
return default
def get_decrypted_value(self_or_cls, key_or_default=None, default=None):
"""Retorna el valor desencriptado. Compatible como método de clase o de instancia."""
if isinstance(self_or_cls, type):
# Llamado como clase: SystemSetting.get_decrypted_value('key', default)
key = key_or_default
try:
record = self_or_cls.query.filter_by(key=key).first()
if record:
return record.get_decrypted_value(default)
except Exception:
pass
return default
else:
# Llamado como instancia: setting.get_decrypted_value(default)
actual_default = key_or_default
try:
if self_or_cls.value is not None and self_or_cls.value.strip() != '':
if self_or_cls.is_encrypted:
return CryptoService.decrypt(self_or_cls.value)
return self_or_cls.value
except Exception:
pass
return actual_default
def get_masked_value(self_or_cls, key_or_default=None, default=''):
"""Retorna una versión enmascarada si es un secreto o contraseña. Compatible como clase o instancia."""
if isinstance(self_or_cls, type):
# Llamado como clase: SystemSetting.get_masked_value('key', default)
key = key_or_default
try:
record = self_or_cls.query.filter_by(key=key).first()
if record:
return record.get_masked_value(default)
except Exception:
pass
return default
else:
# Llamado como instancia: setting.get_masked_value(default)
actual_default = key_or_default or ''
try:
if self_or_cls.value and self_or_cls.value.strip() != '':
if self_or_cls.is_encrypted or 'secret' in self_or_cls.key.lower() or 'password' in self_or_cls.key.lower() or 'token' in self_or_cls.key.lower():
return '••••••••••••'
return self_or_cls.value
except Exception:
pass
return actual_default
@classmethod
def set_value(cls, key, value, description=None):
def set_value(cls, key, value, description=None, category='system', is_encrypted=False):
"""Guarda o actualiza un parámetro de configuración."""
record = cls.query.filter_by(key=key).first()
final_value = value
if is_encrypted and value:
# Si el valor ya viene cifrado o es un placeholder de no-cambio '••••••••••••', no recifrar
if value != '••••••••••••':
final_value = CryptoService.encrypt(value)
if not record:
record = cls(key=key, value=value, description=description)
record = cls(
key=key,
value=final_value,
description=description,
category=category,
is_encrypted=is_encrypted
)
db.session.add(record)
else:
record.value = value
# Si el valor ingresado es el placeholder, no sobrescribir la contraseña existente
if not (is_encrypted and value == '••••••••••••'):
record.value = final_value
if description:
record.description = description
if category:
record.category = category
record.is_encrypted = is_encrypted
record.updated_at = datetime.utcnow()
db.session.commit()
return record
def to_dict(self):
@classmethod
def set_encrypted_value(cls, key, value, description=None, category='system'):
"""Helper para guardar directamente valores sensibles cifrados."""
return cls.set_value(key, value, description=description, category=category, is_encrypted=True)
def to_dict(self, mask_secrets=True):
val = self.value
if mask_secrets and (self.is_encrypted or 'password' in self.key.lower() or 'secret' in self.key.lower() or 'token' in self.key.lower()):
val = '••••••••••••' if val else ''
return {
'key': self.key,
'value': self.value,
'value': val,
'description': self.description,
'category': self.category or 'system',
'is_encrypted': bool(self.is_encrypted),
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<SystemSetting {self.key}={self.value}>'
return f'<SystemSetting {self.key}>'
+69
View File
@@ -0,0 +1,69 @@
from app import db
from datetime import datetime
import json
class MoodleSyncTask(db.Model):
"""
Cola persistente de eventos y tareas de sincronización asíncrona hacia Moodle 4.1.
Garantiza tolerancia a fallos ante caídas o saturación del servidor Moodle,
incorporando reintentos exponenciales y Dead Letter Queue (DLQ).
"""
__tablename__ = 'moodle_sync_tasks'
id = db.Column(db.Integer, primary_key=True)
action = db.Column(db.String(50), nullable=False, index=True) # CREATE_USER, UPDATE_USER, ENROL_USER, UNENROL_USER, ASSIGN_ROLE
entity_type = db.Column(db.String(50), nullable=False) # user, commission, enrollment
entity_id = db.Column(db.String(100), nullable=True)
_payload = db.Column('payload', db.Text, nullable=False) # JSON serializado en Text
status = db.Column(db.String(20), default='PENDING', index=True) # PENDING, PROCESSING, RETRYING, COMPLETED, FAILED
attempts = db.Column(db.Integer, default=0)
max_attempts = db.Column(db.Integer, default=5)
error_message = db.Column(db.Text, nullable=True)
next_retry_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __init__(self, **kwargs):
if 'payload' in kwargs:
raw_payload = kwargs.pop('payload')
if isinstance(raw_payload, (dict, list)):
kwargs['_payload'] = json.dumps(raw_payload)
else:
kwargs['_payload'] = str(raw_payload or '{}')
super().__init__(**kwargs)
@property
def payload(self):
try:
return json.loads(self._payload) if self._payload else {}
except Exception:
return {}
@payload.setter
def payload(self, val):
if isinstance(val, (dict, list)):
self._payload = json.dumps(val)
else:
self._payload = str(val or '{}')
def get_payload_dict(self):
return self.payload
def to_dict(self):
return {
'id': self.id,
'action': self.action,
'entity_type': self.entity_type,
'entity_id': self.entity_id,
'payload': self.payload,
'status': self.status,
'attempts': self.attempts,
'max_attempts': self.max_attempts,
'error_message': self.error_message,
'next_retry_at': self.next_retry_at.isoformat() if self.next_retry_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<MoodleSyncTask #{self.id} {self.action} [{self.status}]>'
+435 -1
View File
@@ -320,6 +320,25 @@ def create_user():
db.session.add(user)
db.session.commit()
# Encolar sincronización con Moodle de forma asíncrona tolerante a fallos
try:
from app.services.moodle_queue_service import moodle_queue_service
username = user.email.split('@')[0].lower()
moodle_queue_service.enqueue_task(
action='CREATE_USER',
entity_type='user',
entity_id=user.id,
payload={
'username': username,
'email': user.email,
'firstname': user.first_name or (user.name.split()[0] if user.name else 'Docente'),
'lastname': user.last_name or (user.name.split()[1] if len(user.name.split()) > 1 else 'EduSpace'),
'password': password or 'EduSpace2026*'
}
)
except Exception:
pass
return jsonify({
'status': 'success',
'message': 'Usuario creado exitosamente.',
@@ -392,6 +411,25 @@ def update_user(id):
user.is_active = val in [True, 'true', '1', 'on']
db.session.commit()
# Encolar actualización con Moodle de forma asíncrona tolerante a fallos
try:
from app.services.moodle_queue_service import moodle_queue_service
username = user.email.split('@')[0].lower()
moodle_queue_service.enqueue_task(
action='UPDATE_USER',
entity_type='user',
entity_id=user.id,
payload={
'username': username,
'email': user.email,
'firstname': user.first_name or (user.name.split()[0] if user.name else 'Docente'),
'lastname': user.last_name or (user.name.split()[1] if len(user.name.split()) > 1 else 'EduSpace')
}
)
except Exception:
pass
return jsonify({
'status': 'success',
'message': 'Usuario actualizado correctamente.',
@@ -1633,7 +1671,6 @@ def drag_update_reservation():
)
db.session.add(audit)
db.session.commit()
return jsonify({
'status': 'success',
'message': msg,
@@ -1641,4 +1678,401 @@ def drag_update_reservation():
}), 200
# ==============================================================================
# CONFIGURACIÓN GLOBAL DEL SISTEMA (SMTP, AUTH PROVIDERS, GOOGLE OAUTH, MOODLE)
# ==============================================================================
@api_admin_bp.route('/settings/all', methods=['GET'])
@jwt_required
def get_all_settings():
"""Retorna todas las configuraciones agrupadas por módulo, enmascarando contraseñas."""
from app.models.setting import SystemSetting
smtp_config = {
'host': SystemSetting.get_value('smtp_host', 'smtp.gmail.com'),
'port': int(SystemSetting.get_value('smtp_port', 587)),
'user': SystemSetting.get_value('smtp_user', ''),
'password': SystemSetting.get_masked_value('smtp_password', ''),
'security': SystemSetting.get_value('smtp_security', 'STARTTLS'),
'sender_email': SystemSetting.get_value('smtp_sender_email', 'notificaciones@unicaba.edu.ar'),
'sender_name': SystemSetting.get_value('smtp_sender_name', 'Edu-Space UniCABA'),
'enabled': SystemSetting.get_value('smtp_enabled', 'true') in ['true', 'True', '1', True]
}
auth_providers = {
'local_enabled': SystemSetting.get_value('auth_local_enabled', 'true') in ['true', 'True', '1', True],
'google_enabled': SystemSetting.get_value('auth_google_enabled', 'false') in ['true', 'True', '1', True],
'moodle_enabled': SystemSetting.get_value('auth_moodle_enabled', 'false') in ['true', 'True', '1', True]
}
google_oauth = {
'client_id': SystemSetting.get_value('google_client_id', ''),
'client_secret': SystemSetting.get_masked_value('google_client_secret', ''),
'callback_url': SystemSetting.get_value('google_callback_url', '/auth/google/callback'),
'allowed_domains': SystemSetting.get_value('google_allowed_domains', 'unicaba.edu.ar,lasalle.edu.ar')
}
moodle_config = {
'server_url': SystemSetting.get_value('moodle_server_url', 'http://10.0.0.207/moodle'),
'ws_token': SystemSetting.get_masked_value('moodle_ws_token', ''),
'timeout': int(SystemSetting.get_value('moodle_timeout', 10)),
'auto_sync_enabled': SystemSetting.get_value('moodle_auto_sync_enabled', 'true') in ['true', 'True', '1', True],
'sync_interval_minutes': int(SystemSetting.get_value('moodle_sync_interval_minutes', 15))
}
return jsonify({
'status': 'success',
'settings': {
'smtp': smtp_config,
'auth_providers': auth_providers,
'google_oauth': google_oauth,
'moodle': moodle_config
}
}), 200
@api_admin_bp.route('/settings/smtp', methods=['POST'])
@jwt_required
def update_smtp_settings():
"""Actualiza los parámetros del servidor de correo SMTP en la base de datos."""
from app.models.setting import SystemSetting
from app.models.audit_log import AuditLog
acting_user = getattr(g, 'jwt_user', None)
if not (acting_user and (acting_user.is_admin() or getattr(acting_user, 'role', '').upper() == 'ADMIN')):
return jsonify({'error': 'Forbidden', 'message': 'Solo administradores pueden modificar la configuración SMTP.'}), 403
data = request.get_json(silent=True) or request.form.to_dict() or {}
host = str(data.get('host', '')).strip()
port = str(data.get('port', 587)).strip()
user = str(data.get('user', '')).strip()
password = str(data.get('password', '')).strip()
security = str(data.get('security', 'STARTTLS')).strip().upper()
sender_email = str(data.get('sender_email', '')).strip()
sender_name = str(data.get('sender_name', 'Edu-Space UniCABA')).strip()
enabled = 'true' if data.get('enabled') in [True, 'true', '1', 'on'] else 'false'
SystemSetting.set_value('smtp_host', host, 'Host del servidor SMTP', category='smtp')
SystemSetting.set_value('smtp_port', port, 'Puerto del servidor SMTP', category='smtp')
SystemSetting.set_value('smtp_user', user, 'Usuario o email de autenticación SMTP', category='smtp')
if password and password != '••••••••••••':
SystemSetting.set_encrypted_value('smtp_password', password, 'Contraseña de autenticación SMTP cifrada', category='smtp')
SystemSetting.set_value('smtp_security', security, 'Protocolo de seguridad SMTP (NONE, SSL, STARTTLS)', category='smtp')
SystemSetting.set_value('smtp_sender_email', sender_email, 'Email remitente oficial', category='smtp')
SystemSetting.set_value('smtp_sender_name', sender_name, 'Nombre remitente oficial', category='smtp')
SystemSetting.set_value('smtp_enabled', enabled, 'Habilitación del servicio SMTP', category='smtp')
try:
audit = AuditLog(
user_id=acting_user.id,
user_email=acting_user.email,
action='UPDATE_SMTP_SETTINGS',
module='settings',
details=f"Configuración SMTP actualizada (Host: {host}:{port}, Remitente: {sender_email})"
)
db.session.add(audit)
db.session.commit()
except Exception:
pass
return jsonify({
'status': 'success',
'message': 'Configuración de servidor SMTP guardada exitosamente.'
}), 200
@api_admin_bp.route('/settings/smtp/test', methods=['POST'])
@jwt_required
def test_smtp_connection():
"""Prueba en tiempo real la conexión al servidor SMTP y opcionalmente envía un email de prueba."""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app.models.setting import SystemSetting
data = request.get_json(silent=True) or request.form.to_dict() or {}
test_recipient = data.get('test_email') or g.jwt_user.email
host = data.get('host') or SystemSetting.get_value('smtp_host', 'smtp.gmail.com')
port = int(data.get('port') or SystemSetting.get_value('smtp_port', 587))
user = data.get('user') or SystemSetting.get_value('smtp_user', '')
password = data.get('password')
if not password or password == '••••••••••••':
password = SystemSetting.get_decrypted_value('smtp_password', '')
security = (data.get('security') or SystemSetting.get_value('smtp_security', 'STARTTLS')).upper()
sender_email = data.get('sender_email') or SystemSetting.get_value('smtp_sender_email', user)
sender_name = data.get('sender_name') or SystemSetting.get_value('smtp_sender_name', 'Edu-Space UniCABA')
if not host or not port:
return jsonify({'status': 'error', 'message': 'Host y puerto SMTP son requeridos.'}), 400
try:
if security == 'SSL':
server = smtplib.SMTP_SSL(host, port, timeout=10)
else:
server = smtplib.SMTP(host, port, timeout=10)
if security == 'STARTTLS':
server.ehlo()
server.starttls()
server.ehlo()
if user and password:
server.login(user, password)
if test_recipient:
msg = MIMEMultipart('alternative')
msg['Subject'] = '✔ Prueba de Conexión SMTP - Edu-Space UniCABA'
msg['From'] = f"{sender_name} <{sender_email}>"
msg['To'] = test_recipient
html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 550px; margin: auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #B43E8E; margin-bottom: 10px;">Edu-Space UniCABA</h2>
<h3 style="color: #1e293b; margin-top: 0;">Prueba de Conexión SMTP Exitosa</h3>
<p style="color: #475569;">Este es un mensaje de prueba para confirmar que los parámetros del servidor de correo han sido configurados correctamente.</p>
<div style="background-color: #f8fafc; padding: 12px; border-radius: 6px; font-size: 13px; color: #334155;">
<strong>Host:</strong> {host}:{port}<br>
<strong>Seguridad:</strong> {security}<br>
<strong>Usuario:</strong> {user or '(Sin autenticación)'}<br>
<strong>Remitente:</strong> {sender_email}
</div>
<p style="font-size: 11px; color: #94a3b8; margin-top: 20px;">Enviado desde el Panel de Administración de UniCABA.</p>
</div>
"""
msg.attach(MIMEText(html_content, 'html'))
server.sendmail(sender_email, [test_recipient], msg.as_string())
server.quit()
return jsonify({
'status': 'success',
'message': f'Conexión SMTP exitosa. Correo de prueba enviado a {test_recipient}.'
}), 200
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Fallo en la prueba de conexión SMTP: {str(e)}'
}), 400
@api_admin_bp.route('/settings/auth-providers', methods=['POST'])
@jwt_required
def update_auth_providers():
"""Habilita o deshabilita los proveedores de autenticación del sistema."""
from app.models.setting import SystemSetting
from app.models.audit_log import AuditLog
acting_user = getattr(g, 'jwt_user', None)
if not (acting_user and (acting_user.is_admin() or getattr(acting_user, 'role', '').upper() == 'ADMIN')):
return jsonify({'error': 'Forbidden', 'message': 'Solo administradores pueden modificar los métodos de autenticación.'}), 403
data = request.get_json(silent=True) or request.form.to_dict() or {}
local_val = 'true' if data.get('local_enabled', True) in [True, 'true', '1', 'on'] else 'false'
google_val = 'true' if data.get('google_enabled', False) in [True, 'true', '1', 'on'] else 'false'
moodle_val = 'true' if data.get('moodle_enabled', False) in [True, 'true', '1', 'on'] else 'false'
SystemSetting.set_value('auth_local_enabled', local_val, 'Habilitar login nativo con usuario/contraseña', category='sso')
SystemSetting.set_value('auth_google_enabled', google_val, 'Habilitar login SSO con Google Workspace', category='sso')
SystemSetting.set_value('auth_moodle_enabled', moodle_val, 'Habilitar login delegado con Moodle', category='sso')
try:
audit = AuditLog(
user_id=acting_user.id,
user_email=acting_user.email,
action='UPDATE_AUTH_PROVIDERS',
module='settings',
details=f"Métodos de login actualizados: Local={local_val}, Google={google_val}, Moodle={moodle_val}"
)
db.session.add(audit)
db.session.commit()
except Exception:
pass
return jsonify({
'status': 'success',
'message': 'Métodos de autenticación actualizados correctamente.'
}), 200
@api_admin_bp.route('/settings/google-oauth', methods=['POST'])
@jwt_required
def update_google_oauth_settings():
"""Actualiza las credenciales de integración de Google OAuth2."""
from app.models.setting import SystemSetting
from app.models.audit_log import AuditLog
acting_user = getattr(g, 'jwt_user', None)
if not (acting_user and (acting_user.is_admin() or getattr(acting_user, 'role', '').upper() == 'ADMIN')):
return jsonify({'error': 'Forbidden', 'message': 'Acceso no autorizado.'}), 403
data = request.get_json(silent=True) or request.form.to_dict() or {}
client_id = str(data.get('client_id', '')).strip()
client_secret = str(data.get('client_secret', '')).strip()
allowed_domains = str(data.get('allowed_domains', 'unicaba.edu.ar')).strip()
callback_url = str(data.get('callback_url', '/auth/google/callback')).strip()
SystemSetting.set_value('google_client_id', client_id, 'Client ID de Google OAuth2', category='sso_google')
if client_secret and client_secret != '••••••••••••':
SystemSetting.set_encrypted_value('google_client_secret', client_secret, 'Client Secret de Google OAuth2 cifrado', category='sso_google')
SystemSetting.set_value('google_allowed_domains', allowed_domains, 'Dominios permitidos separados por coma', category='sso_google')
SystemSetting.set_value('google_callback_url', callback_url, 'Ruta de callback autorizada de Google OAuth2', category='sso_google')
try:
audit = AuditLog(
user_id=acting_user.id,
user_email=acting_user.email,
action='UPDATE_GOOGLE_OAUTH_SETTINGS',
module='settings',
details="Credenciales de Google OAuth2 actualizadas"
)
db.session.add(audit)
db.session.commit()
except Exception:
pass
return jsonify({
'status': 'success',
'message': 'Credenciales de Google OAuth2 guardadas correctamente.'
}), 200
@api_admin_bp.route('/settings/moodle', methods=['POST'])
@jwt_required
def update_moodle_settings():
"""Actualiza la configuración de integración y Web Services con Moodle 4.1."""
from app.models.setting import SystemSetting
from app.models.audit_log import AuditLog
acting_user = getattr(g, 'jwt_user', None)
if not (acting_user and (acting_user.is_admin() or getattr(acting_user, 'role', '').upper() == 'ADMIN')):
return jsonify({'error': 'Forbidden', 'message': 'Acceso no autorizado.'}), 403
data = request.get_json(silent=True) or request.form.to_dict() or {}
server_url = str(data.get('server_url', 'http://10.0.0.207/moodle')).strip().rstrip('/')
ws_token = str(data.get('ws_token', '')).strip()
timeout = str(data.get('timeout', 10)).strip()
auto_sync = 'true' if data.get('auto_sync_enabled', True) in [True, 'true', '1', 'on'] else 'false'
sync_interval = str(data.get('sync_interval_minutes', 15)).strip()
SystemSetting.set_value('moodle_server_url', server_url, 'URL base del servidor Moodle', category='sso_moodle')
if ws_token and ws_token != '••••••••••••':
SystemSetting.set_encrypted_value('moodle_ws_token', ws_token, 'Token de Web Services de Moodle cifrado', category='sso_moodle')
SystemSetting.set_value('moodle_timeout', timeout, 'Timeout en segundos para llamadas REST a Moodle', category='sso_moodle')
SystemSetting.set_value('moodle_auto_sync_enabled', auto_sync, 'Habilitación de sincronización periódica automática', category='sso_moodle')
SystemSetting.set_value('moodle_sync_interval_minutes', sync_interval, 'Intervalo en minutos para sincronización periódica', category='sso_moodle')
try:
audit = AuditLog(
user_id=acting_user.id,
user_email=acting_user.email,
action='UPDATE_MOODLE_SETTINGS',
module='settings',
details=f"Parámetros de Moodle actualizados (Servidor: {server_url})"
)
db.session.add(audit)
db.session.commit()
except Exception:
pass
return jsonify({
'status': 'success',
'message': 'Configuración de Moodle 4.1 guardada correctamente.'
}), 200
@api_admin_bp.route('/settings/moodle/test', methods=['POST'])
@jwt_required
def test_moodle_connection():
"""Prueba la conectividad y validez del token Web Services contra Moodle 4.1."""
from app.services.moodle_client import moodle_client
data = request.get_json(silent=True) or request.form.to_dict() or {}
server_url = data.get('server_url')
token = data.get('ws_token')
result = moodle_client.test_connection(server_url=server_url, token=token)
if result.get('success'):
return jsonify(result), 200
else:
return jsonify(result), 400
@api_admin_bp.route('/moodle/queue/stats', methods=['GET'])
@jwt_required
def get_moodle_queue_stats():
"""Retorna las estadísticas en tiempo real de la cola de sincronización con Moodle."""
from app.services.moodle_queue_service import moodle_queue_service
stats = moodle_queue_service.get_queue_summary()
return jsonify({
'status': 'success',
'data': stats
}), 200
@api_admin_bp.route('/moodle/queue/tasks', methods=['GET'])
@jwt_required
def get_moodle_queue_tasks():
"""Retorna la lista de tareas en cola con filtros por estado (ej: FAILED para DLQ)."""
from app.models.sync_task import MoodleSyncTask
status = request.args.get('status', '').strip().upper()
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 20))
query = MoodleSyncTask.query
if status:
query = query.filter_by(status=status)
total = query.count()
tasks = query.order_by(MoodleSyncTask.created_at.desc()).offset((page - 1) * per_page).limit(per_page).all()
return jsonify({
'status': 'success',
'total': total,
'page': page,
'per_page': per_page,
'tasks': [t.to_dict() for t in tasks]
}), 200
@api_admin_bp.route('/moodle/queue/process-now', methods=['POST'])
@jwt_required
def process_moodle_queue_now():
"""Dispara de forma manual la ejecución inmediata de la cola de sincronización."""
from app.services.moodle_queue_service import moodle_queue_service
batch_size = int(request.json.get('batch_size', 50)) if request.is_json and request.json else 50
results = moodle_queue_service.process_pending_tasks(batch_size=batch_size)
return jsonify({
'status': 'success',
'message': f"Sincronización procesada: {results['succeeded']} exitosas, {results['failed']} a DLQ, {results['retrying']} reintentando.",
'results': results
}), 200
@api_admin_bp.route('/moodle/queue/tasks/<int:task_id>/retry', methods=['POST'])
@jwt_required
def retry_moodle_queue_task(task_id):
"""Reintenta manualmente una tarea específica desde la Dead Letter Queue."""
from app.services.moodle_queue_service import moodle_queue_service
success = moodle_queue_service.retry_task(task_id)
if success:
return jsonify({
'status': 'success',
'message': f'Tarea #{task_id} reiniciada a estado PENDIENTE para el próximo ciclo.'
}), 200
else:
return jsonify({
'status': 'error',
'message': f'No se encontró la tarea #{task_id}.'
}), 404
@api_admin_bp.route('/moodle/queue/retry-all', methods=['POST'])
@jwt_required
def retry_all_failed_moodle_tasks():
"""Reintenta todas las tareas en Dead Letter Queue (FAILED)."""
from app.services.moodle_queue_service import moodle_queue_service
count = moodle_queue_service.retry_all_failed()
return jsonify({
'status': 'success',
'message': f'Se reiniciaron {count} tareas fallidas a estado PENDIENTE.'
}), 200
+242 -5
View File
@@ -1,19 +1,59 @@
"""
Authentication Routes (admin-edu-space)
Implements Hybrid Authentication:
- Local Login with admin fallback
- Google OAuth 2.0 (inspired by AlumnosLS domain & email validation)
- Moodle Delegated Authentication with dynamic role mapping
- Token Refresh & Session Management (Redis / JWT)
"""
from flask import Blueprint, request, jsonify, g, make_response
from pydantic import ValidationError
import jwt
import requests
import logging
from app import db
from app.models.user import User
from app.models.role import Role
from app.models.setting import SystemSetting
from app.services.user_service import UserService
from app.services.jwt_service import JWTService
from app.services.cache_service import cache_service
from app.services.moodle_client import moodle_client
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
from app.utils.jwt_decorators import jwt_required
logger = logging.getLogger(__name__)
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
user_service = UserService()
@api_auth_bp.route('/providers', methods=['GET'])
def get_auth_providers():
"""
Public endpoint returning active authentication methods and Google client ID for the UI.
"""
local_val = SystemSetting.get_value('auth_local_enabled', 'true').lower() in ('true', '1')
google_val = SystemSetting.get_value('auth_google_enabled', 'false').lower() in ('true', '1')
moodle_val = SystemSetting.get_value('auth_moodle_enabled', 'false').lower() in ('true', '1')
google_client_id = SystemSetting.get_value('google_client_id', '')
return jsonify({
'status': 'success',
'providers': {
'local': local_val,
'google': google_val,
'moodle': moodle_val
},
'google_client_id': google_client_id
}), 200
@api_auth_bp.route('/login', methods=['POST'])
def login():
"""
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
Native local authentication endpoint (Access + Refresh tokens).
Respects global auth_local_enabled setting, allowing emergency ADMIN access if disabled.
"""
data = request.get_json(silent=True)
if not isinstance(data, dict):
@@ -23,6 +63,8 @@ def login():
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
local_enabled = SystemSetting.get_value('auth_local_enabled', 'true').lower() in ('true', '1')
user = user_service.authenticate(dto.email, dto.password)
if not user:
return jsonify({
@@ -30,6 +72,13 @@ def login():
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
}), 401
is_admin = user.is_admin() or getattr(user, 'role', '').upper() == 'ADMIN'
if not local_enabled and not is_admin:
return jsonify({
'error': 'Forbidden',
'message': 'El acceso local con contraseña está deshabilitado por el administrador. Inicie sesión mediante Google OAuth o Moodle.'
}), 403
tokens = JWTService.generate_tokens(user)
profile = user_service.get_profile_data(user)
@@ -42,7 +91,6 @@ def login():
}
resp = make_response(jsonify(response_data), 200)
# Almacenar refresh token en cookie segura HttpOnly
resp.set_cookie(
'refresh_token',
tokens['refresh_token'],
@@ -53,10 +101,197 @@ def login():
)
return resp
@api_auth_bp.route('/google', methods=['POST'])
def google_auth():
"""
Google OAuth 2.0 authentication endpoint.
Receives Google profile / token data, verifies domain whitelist (AlumnosLS architecture),
creates/updates local user and issues JWT.
"""
google_enabled = SystemSetting.get_value('auth_google_enabled', 'false').lower() in ('true', '1')
if not google_enabled:
return jsonify({'error': 'Forbidden', 'message': 'El inicio de sesión con Google no está habilitado.'}), 403
data = request.get_json(silent=True) or {}
email = data.get('email', '').strip().lower()
name = data.get('name', '').strip()
domain = data.get('domain') or (email.split('@')[1] if '@' in email else '')
photo = data.get('photo', '')
if not email:
return jsonify({'error': 'BadRequest', 'message': 'El email de Google es obligatorio.'}), 400
# Domain restriction check
allowed_domains_str = SystemSetting.get_value('google_allowed_domains', 'unicaba.edu.ar')
allowed_domains = [d.strip().lower() for d in allowed_domains_str.split(',') if d.strip()]
if allowed_domains and domain.lower() not in allowed_domains:
logger.warning(f"[GoogleAuth] Access denied for {email}: domain {domain} not in {allowed_domains}")
return jsonify({
'error': 'Forbidden',
'message': f'Acceso denegado. El dominio @{domain} no está autorizado en esta institución.'
}), 403
user = User.query.filter(User.email.ilike(email)).first()
if not user:
# Create local user on first Google login
parts = name.split()
first_name = parts[0] if parts else 'Usuario'
last_name = ' '.join(parts[1:]) if len(parts) > 1 else 'Google'
# Default role: Docente
docente_role = Role.query.filter(Role.name.ilike('Docente')).first()
user = User(
email=email,
name=name or f"{first_name} {last_name}",
first_name=first_name,
last_name=last_name,
role='Docente' if not docente_role else docente_role.name,
role_id=docente_role.id if docente_role else None,
is_active=True
)
user.set_password(f"GoogleSSO_{email}")
db.session.add(user)
db.session.commit()
logger.info(f"[GoogleAuth] Created new local user for {email} with role Docente.")
if not user.is_active:
return jsonify({'error': 'Unauthorized', 'message': 'Su cuenta se encuentra inactiva. Contacte a Bedelía.'}), 401
tokens = JWTService.generate_tokens(user)
profile = user_service.get_profile_data(user)
response_data = {
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
'token_type': tokens['token_type'],
'expires_in': tokens['expires_in'],
'user': profile
}
resp = make_response(jsonify(response_data), 200)
resp.set_cookie(
'refresh_token',
tokens['refresh_token'],
httponly=True,
samesite='Lax',
max_age=7 * 24 * 3600,
path='/api/v1/auth/refresh'
)
return resp
@api_auth_bp.route('/moodle', methods=['POST'])
def moodle_auth():
"""
Moodle Delegated Authentication endpoint.
Validates user credentials against Moodle server (/login/token.php),
fetches Moodle profile, implements initial role mapping (if new user),
and caches profile in Redis.
"""
moodle_enabled = SystemSetting.get_value('auth_moodle_enabled', 'false').lower() in ('true', '1')
if not moodle_enabled:
return jsonify({'error': 'Forbidden', 'message': 'El inicio de sesión con Moodle no está habilitado.'}), 403
data = request.get_json(silent=True) or {}
username = data.get('username', '').strip()
password = data.get('password', '').strip()
if not username or not password:
return jsonify({'error': 'BadRequest', 'message': 'Usuario y contraseña de Moodle son obligatorios.'}), 400
server_url = SystemSetting.get_value('moodle_server_url', 'http://10.0.0.207/moodle').rstrip('/')
# Authenticate against Moodle login/token.php
token_url = f"{server_url}/login/token.php"
try:
resp = requests.post(token_url, data={
'username': username,
'password': password,
'service': 'moodle_mobile_app'
}, timeout=10)
res_data = resp.json()
except Exception as e:
logger.error(f"[MoodleAuth] Connection error to Moodle server: {e}")
return jsonify({'error': 'ServiceUnavailable', 'message': 'No se pudo conectar con el servidor de Moodle. Intente más tarde.'}), 503
if 'error' in res_data or 'token' not in res_data:
err_msg = res_data.get('error', 'Credenciales inválidas en Moodle.')
return jsonify({'error': 'Unauthorized', 'message': f'Moodle: {err_msg}'}), 401
moodle_user_token = res_data['token']
# Retrieve Moodle user profile via Web Services
moodle_profile = None
cache_key = f"moodle_user:{username.lower()}"
cached = cache_service.get(cache_key)
if cached:
moodle_profile = cached
else:
try:
m_users = moodle_client.get_users([{'key': 'username', 'value': username.lower()}])
if m_users and isinstance(m_users, list) and len(m_users) > 0:
moodle_profile = m_users[0]
cache_service.set(cache_key, moodle_profile, ttl_seconds=3600)
except Exception as e:
logger.warning(f"[MoodleAuth] Could not fetch extended Moodle profile: {e}")
email = moodle_profile.get('email') if moodle_profile else f"{username}@unicaba.edu.ar"
firstname = moodle_profile.get('firstname', username) if moodle_profile else username
lastname = moodle_profile.get('lastname', 'Moodle') if moodle_profile else 'Moodle'
# Local user lookup or creation
user = User.query.filter((User.email.ilike(email)) | (User.email.ilike(f"{username}@%"))).first()
if not user:
# Determine initial role: if username is admin or has manager role -> ADMIN, else Docente
role_name = 'ADMIN' if username.lower() in ('admin', 'manager') else 'Docente'
role_obj = Role.query.filter(Role.name.ilike(role_name)).first()
user = User(
email=email,
name=f"{firstname} {lastname}".strip(),
first_name=firstname,
last_name=lastname,
role=role_obj.name if role_obj else role_name,
role_id=role_obj.id if role_obj else None,
is_active=True
)
user.set_password(f"MoodleLinked_{username}")
db.session.add(user)
db.session.commit()
logger.info(f"[MoodleAuth] Created new local user for Moodle username '{username}' with role '{role_name}'.")
if not user.is_active:
return jsonify({'error': 'Unauthorized', 'message': 'Su cuenta en Edu-Space está desactivada.'}), 401
tokens = JWTService.generate_tokens(user)
profile = user_service.get_profile_data(user)
response_data = {
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
'token_type': tokens['token_type'],
'expires_in': tokens['expires_in'],
'user': profile
}
resp = make_response(jsonify(response_data), 200)
resp.set_cookie(
'refresh_token',
tokens['refresh_token'],
httponly=True,
samesite='Lax',
max_age=7 * 24 * 3600,
path='/api/v1/auth/refresh'
)
return resp
@api_auth_bp.route('/refresh', methods=['POST'])
def refresh():
"""
Renovación silenciosa del Access Token utilizando el Refresh Token.
Silent Access Token renewal using Refresh Token.
"""
data = request.get_json(silent=True)
if not isinstance(data, dict):
@@ -92,22 +327,24 @@ def refresh():
except jwt.InvalidTokenError as e:
return jsonify({'error': 'InvalidToken', 'message': str(e)}), 401
@api_auth_bp.route('/logout', methods=['POST'])
@jwt_required
def logout():
"""
Cierre de sesión: revoca el token de acceso activo y limpia cookies.
Session logout: revokes active access token and clears cookies.
"""
JWTService.revoke_token(g.jwt_token)
resp = make_response(jsonify({'message': 'Sesión finalizada y token revocado exitosamente.'}), 200)
resp.delete_cookie('refresh_token', path='/api/v1/auth/refresh')
return resp
@api_auth_bp.route('/me', methods=['GET'])
@jwt_required
def get_current_user():
"""
Retorna el perfil y los permisos del usuario autenticado vía JWT.
Returns current authenticated profile and permissions.
"""
profile = user_service.get_profile_data(g.jwt_user)
return jsonify(profile), 200
+86
View File
@@ -0,0 +1,86 @@
import time
import json
import logging
logger = logging.getLogger(__name__)
class CacheService:
"""
Servicio de caché híbrido para sesiones y lecturas rápidas de Moodle.
Soporta Redis si está disponible en la infraestructura y hace fallback transparente
a caché en memoria con TTL para entornos de desarrollo y pruebas.
"""
_memory_cache = {}
_redis_client = None
_redis_checked = False
@classmethod
def _get_redis(cls):
if not cls._redis_checked:
cls._redis_checked = True
try:
import redis
from app.models.setting import SystemSetting
redis_url = SystemSetting.get_value('redis_url', 'redis://127.0.0.1:6379/0')
client = redis.from_url(redis_url, socket_connect_timeout=2)
client.ping()
cls._redis_client = client
logger.info("Conexión exitosa a Redis en %s", redis_url)
except Exception as e:
cls._redis_client = None
logger.info("Redis no disponible (%s). Operando con memoria local/TTL.", str(e).split('\n')[0])
return cls._redis_client
@classmethod
def get(cls, key: str, default=None):
r = cls._get_redis()
if r:
try:
val = r.get(key)
if val is not None:
return json.loads(val.decode('utf-8'))
except Exception as e:
logger.debug("Error leyendo de Redis: %s", e)
# Fallback memoria
item = cls._memory_cache.get(key)
if item:
val, expire_at = item
if expire_at is None or expire_at > time.time():
return val
else:
del cls._memory_cache[key]
return default
@classmethod
def set(cls, key: str, value, ttl_seconds: int = 300):
r = cls._get_redis()
if r:
try:
serialized = json.dumps(value)
r.setex(key, ttl_seconds, serialized)
return True
except Exception as e:
logger.debug("Error escribiendo en Redis: %s", e)
expire_at = (time.time() + ttl_seconds) if ttl_seconds else None
cls._memory_cache[key] = (value, expire_at)
return True
@classmethod
def delete(cls, key: str):
r = cls._get_redis()
if r:
try:
r.delete(key)
except Exception:
pass
cls._memory_cache.pop(key, None)
@classmethod
def clear(cls):
cls._memory_cache.clear()
cls._redis_checked = False
cls._redis_client = None
cache_service = CacheService()
+46
View File
@@ -0,0 +1,46 @@
import base64
import hashlib
from cryptography.fernet import Fernet
from flask import current_app
class CryptoService:
"""
Servicio de cifrado simétrico seguro para contraseñas y tokens sensibles
almacenados en la base de datos (credenciales SMTP, Client Secrets, Moodle Tokens).
"""
@staticmethod
def _get_fernet() -> Fernet:
# Derivar clave válida para Fernet (32 bytes urlsafe base64) desde SECRET_KEY
try:
secret = current_app.config.get('SECRET_KEY', 'default-unicaba-edu-space-secret-key-32b!')
except RuntimeError:
secret = 'default-unicaba-edu-space-secret-key-32b!'
# Hash SHA-256 para obtener 32 bytes y codificar en base64 seguro para URL
key = base64.urlsafe_b64encode(hashlib.sha256(secret.encode('utf-8')).digest())
return Fernet(key)
@classmethod
def encrypt(cls, plain_text: str) -> str:
"""Cifra un texto plano y retorna el string cifrado."""
if not plain_text:
return ''
fernet = cls._get_fernet()
encrypted_bytes = fernet.encrypt(plain_text.encode('utf-8'))
return encrypted_bytes.decode('utf-8')
@classmethod
def decrypt(cls, cipher_text: str) -> str:
"""Descifra un texto cifrado y retorna el texto original. Si falla, retorna vacío."""
if not cipher_text:
return ''
try:
fernet = cls._get_fernet()
decrypted_bytes = fernet.decrypt(cipher_text.encode('utf-8'))
return decrypted_bytes.decode('utf-8')
except Exception:
# Si no era un texto cifrado con Fernet o fue alterado, retornar el texto tal cual o vacío
return cipher_text
crypto_service = CryptoService()
+197
View File
@@ -0,0 +1,197 @@
"""
Email Notification Service (admin-edu-space)
Dynamically configures and dispatches transactional emails using SMTP credentials
stored securely in the database (SystemSetting) with Fernet encryption.
"""
import smtplib
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import List, Optional, Union, Dict, Any
from app.models.setting import SystemSetting
logger = logging.getLogger(__name__)
class EmailService:
@staticmethod
def get_smtp_config() -> Dict[str, Any]:
"""
Retrieves active SMTP configuration from database.
"""
host_s = SystemSetting.query.filter_by(key='smtp_host').first()
port_s = SystemSetting.query.filter_by(key='smtp_port').first()
user_s = SystemSetting.query.filter_by(key='smtp_user').first()
pass_s = SystemSetting.query.filter_by(key='smtp_password').first()
sec_s = SystemSetting.query.filter_by(key='smtp_security').first()
sender_s = SystemSetting.query.filter_by(key='smtp_from_email').first()
sender_name_s = SystemSetting.query.filter_by(key='smtp_from_name').first()
host = host_s.value if host_s and host_s.value else 'smtp.gmail.com'
port = int(port_s.value) if port_s and port_s.value else 587
user = user_s.value if user_s and user_s.value else ''
password = pass_s.get_decrypted_value() if pass_s else ''
security = sec_s.value if sec_s and sec_s.value else 'tls'
from_email = sender_s.value if sender_s and sender_s.value else user or 'noreply@edu-space.local'
from_name = sender_name_s.value if sender_name_s and sender_name_s.value else 'Admin Edu-Space'
return {
'host': host,
'port': port,
'user': user,
'password': password,
'security': security.lower(),
'from_email': from_email,
'from_name': from_name
}
@classmethod
def test_smtp_connection(cls, custom_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Tests connection and authentication with the SMTP server.
"""
config = custom_config or cls.get_smtp_config()
host = config.get('host')
port = int(config.get('port', 587))
user = config.get('user', '')
password = config.get('password', '')
security = config.get('security', 'tls').lower()
if not host:
return {'success': False, 'message': 'El Host SMTP no está especificado.'}
try:
if security == 'ssl' or port == 465:
server = smtplib.SMTP_SSL(host, port, timeout=10)
else:
server = smtplib.SMTP(host, port, timeout=10)
if security in ('tls', 'starttls'):
server.starttls()
if user and password:
server.login(user, password)
server.quit()
return {'success': True, 'message': f'Conexión exitosa con el servidor SMTP ({host}:{port}).'}
except smtplib.SMTPAuthenticationError as e:
return {'success': False, 'message': f'Fallo de autenticación SMTP: Credenciales incorrectas ({e.smtp_code}).'}
except smtplib.SMTPConnectError as e:
return {'success': False, 'message': f'No se pudo conectar al servidor SMTP: {str(e)}'}
except Exception as e:
return {'success': False, 'message': f'Error de conexión SMTP: {str(e)}'}
@classmethod
def send_email(cls,
to: Union[str, List[str]],
subject: str,
html_content: str,
text_content: Optional[str] = None) -> bool:
"""
Sends an email using dynamic SMTP configuration.
"""
config = cls.get_smtp_config()
host = config.get('host')
port = config.get('port', 587)
user = config.get('user', '')
password = config.get('password', '')
security = config.get('security', 'tls').lower()
from_email = config.get('from_email')
from_name = config.get('from_name')
recipients = [to] if isinstance(to, str) else to
if not recipients or not recipients[0]:
logger.warning("[EmailService] No recipients specified. Aborting send.")
return False
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{from_name} <{from_email}>"
msg['To'] = ", ".join(recipients)
if text_content:
msg.attach(MIMEText(text_content, 'plain', 'utf-8'))
if html_content:
msg.attach(MIMEText(html_content, 'html', 'utf-8'))
try:
if security == 'ssl' or port == 465:
server = smtplib.SMTP_SSL(host, port, timeout=15)
else:
server = smtplib.SMTP(host, port, timeout=15)
if security in ('tls', 'starttls'):
server.starttls()
if user and password:
server.login(user, password)
server.sendmail(from_email, recipients, msg.as_string())
server.quit()
logger.info(f"[EmailService] Email sent successfully to {recipients}: '{subject}'")
return True
except Exception as e:
logger.error(f"[EmailService] Failed to send email to {recipients}: {e}")
return False
# -------------------------------------------------------------
# Casos de Uso Core: Correos Transaccionales para Profesores/Admin
# -------------------------------------------------------------
@classmethod
def notify_teacher_assignment(cls, teacher_email: str, teacher_name: str, subject_name: str, commission_name: str, schedule: str = "") -> bool:
"""
Notifica a un profesor sobre la asignación a una comisión/materia.
"""
subject = f"Asignación Docente: {subject_name} ({commission_name})"
html = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background-color: #ffffff;">
<div style="background-color: #1e293b; padding: 20px; text-align: center; color: #ffffff;">
<h2 style="margin: 0; font-size: 20px;">Admin Edu-Space</h2>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #94a3b8;">Notificación de Gestión Académica</p>
</div>
<div style="padding: 24px; color: #334155; line-height: 1.6;">
<p style="font-size: 16px;">Estimado/a <strong>{teacher_name}</strong>,</p>
<p>Le informamos que ha sido asignado/a como docente a cargo de la siguiente comisión:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 12px 16px; margin: 16px 0; border-radius: 4px;">
<p style="margin: 4px 0;"><strong>Materia:</strong> {subject_name}</p>
<p style="margin: 4px 0;"><strong>Comisión:</strong> {commission_name}</p>
{f'<p style="margin: 4px 0;"><strong>Horario / Aulas:</strong> {schedule}</p>' if schedule else ''}
</div>
<p>Puede consultar los detalles y la nómina de alumnos ingresando al portal de Admin Edu-Space y a las aulas de Moodle vinculadas.</p>
<div style="margin-top: 24px; text-align: center;">
<a href="http://10.0.0.217:5000/login" style="background-color: #2563eb; color: #ffffff; padding: 10px 20px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Acceder a Edu-Space</a>
</div>
</div>
<div style="background-color: #f1f5f9; padding: 12px; text-align: center; font-size: 12px; color: #64748b;">
Este es un correo automático generado por Admin Edu-Space. Por favor no responder a esta casilla.
</div>
</div>
"""
text = f"Estimado/a {teacher_name},\n\nHa sido asignado/a a la materia: {subject_name} ({commission_name}). Horario: {schedule}.\n\nAcceda a Edu-Space para más información."
return cls.send_email(teacher_email, subject, html, text)
@classmethod
def notify_exam_schedule(cls, teacher_email: str, teacher_name: str, subject_name: str, date_str: str, room: str = "") -> bool:
"""
Notifica a un profesor sobre la mesa examinadora asignada.
"""
subject = f"Mesa de Examen Asignada: {subject_name} - {date_str}"
html = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background-color: #ffffff;">
<div style="background-color: #0f172a; padding: 20px; text-align: center; color: #ffffff;">
<h2 style="margin: 0; font-size: 20px;">Admin Edu-Space</h2>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #cbd5e1;">Mesa de Exámenes Finales</p>
</div>
<div style="padding: 24px; color: #334155; line-height: 1.6;">
<p>Estimado/a <strong>{teacher_name}</strong>,</p>
<p>Se le ha asignado la siguiente mesa de examen final:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px 16px; margin: 16px 0; border-radius: 4px;">
<p style="margin: 4px 0;"><strong>Materia:</strong> {subject_name}</p>
<p style="margin: 4px 0;"><strong>Fecha y Hora:</strong> {date_str}</p>
{f'<p style="margin: 4px 0;"><strong>Espacio / Aula:</strong> {room}</p>' if room else ''}
</div>
<p>Recuerde verificar las actas y regularidades en el sistema.</p>
</div>
</div>
"""
text = f"Estimado/a {teacher_name},\n\nMesa de examen asignada: {subject_name}\nFecha: {date_str}\nAula: {room}"
return cls.send_email(teacher_email, subject, html, text)
email_service = EmailService()
+245
View File
@@ -0,0 +1,245 @@
import requests
import logging
from typing import Dict, Any, List, Optional
from app.models.setting import SystemSetting
logger = logging.getLogger(__name__)
class MoodleClient:
"""
Cliente REST para la API de Web Services de Moodle 4.1.
Soporta operaciones sobre usuarios, cursos, matriculaciones y roles,
leyendo dinámicamente la URL y el Token encriptado desde SystemSetting.
"""
@classmethod
def get_config(cls) -> Dict[str, Any]:
server_url = SystemSetting.get_value('moodle_server_url', 'http://10.0.0.207/moodle').rstrip('/')
token = SystemSetting.get_decrypted_value('moodle_ws_token', '')
# Fallback a token descubierto en Moodle si no está configurado aún en BD
if not token:
token = '1a0fee6f654dc3b7f0c02fac90eda327'
timeout = int(SystemSetting.get_value('moodle_timeout', 10))
return {
'server_url': server_url,
'token': token,
'timeout': timeout,
'endpoint': f"{server_url}/webservice/rest/server.php"
}
@classmethod
def call(cls, ws_function: str, params: Optional[Dict[str, Any]] = None, method: str = 'GET') -> Dict[str, Any]:
"""Ejecuta una llamada Web Service contra Moodle 4.1 en formato JSON."""
cfg = cls.get_config()
endpoint = cfg['endpoint']
token = cfg['token']
timeout = cfg['timeout']
req_params = {
'wstoken': token,
'wsfunction': ws_function,
'moodlewsrestformat': 'json'
}
if params:
req_params.update(params)
try:
if method.upper() == 'POST':
response = requests.post(endpoint, data=req_params, timeout=timeout)
else:
response = requests.get(endpoint, params=req_params, timeout=timeout)
response.raise_for_status()
data = response.json()
# Moodle retorna HTTP 200 con un campo 'exception' en caso de error lógico
if isinstance(data, dict) and 'exception' in data:
error_msg = f"Moodle Exception [{data.get('errorcode')}]: {data.get('message')}"
logger.error(error_msg)
raise RuntimeError(error_msg)
return data
except requests.exceptions.RequestException as e:
logger.error("Error de comunicación con Moodle (%s): %s", ws_function, str(e))
raise RuntimeError(f"Fallo de conexión con Moodle ({ws_function}): {str(e)}")
# --------------------------------------------------------------------------
# Diagnóstico y Estado
# --------------------------------------------------------------------------
@classmethod
def test_connection(cls) -> Dict[str, Any]:
"""Prueba si el token y el endpoint responden adecuadamente."""
cfg = cls.get_config()
try:
# Consultamos usuarios con un filtro vacío o el admin
data = cls.call('core_user_get_users', {
'criteria[0][key]': 'email',
'criteria[0][value]': '%'
})
users = data.get('users', [])
return {
'success': True,
'server_url': cfg['server_url'],
'user_count': len(users),
'message': f'Conexión exitosa a Moodle 4.1 ({len(users)} usuarios detectados).'
}
except Exception as e:
return {
'success': False,
'server_url': cfg['server_url'],
'error': str(e),
'message': f'Fallo en la prueba de conexión: {str(e)}'
}
# --------------------------------------------------------------------------
# Usuarios (core_user_*)
# --------------------------------------------------------------------------
@classmethod
def get_users_by_criteria(cls, field: str, value: str) -> List[Dict[str, Any]]:
"""Obtiene usuarios por criterio (email, username, idnumber, etc.)."""
params = {
'criteria[0][key]': field,
'criteria[0][value]': value
}
res = cls.call('core_user_get_users', params)
return res.get('users', []) if isinstance(res, dict) else []
@classmethod
def get_user_by_email(cls, email: str) -> Optional[Dict[str, Any]]:
users = cls.get_users_by_criteria('email', email.strip().lower())
return users[0] if users else None
@classmethod
def create_user(cls, username: str, email: str, firstname: str, lastname: str, password: Optional[str] = None) -> Dict[str, Any]:
"""Crea un nuevo usuario en Moodle (core_user_create_users)."""
params = {
'users[0][username]': username.strip().lower(),
'users[0][email]': email.strip().lower(),
'users[0][firstname]': firstname.strip(),
'users[0][lastname]': lastname.strip(),
'users[0][auth]': 'manual'
}
if password:
params['users[0][password]'] = password
else:
params['users[0][createpassword]'] = 1 # Notifica al usuario para generar password
res = cls.call('core_user_create_users', params, method='POST')
# Retorna lista de diccionarios [{'id': 123, 'username': '...'}]
if isinstance(res, list) and len(res) > 0:
return res[0]
return res
@classmethod
def update_user(cls, moodle_user_id: int, firstname: Optional[str] = None, lastname: Optional[str] = None, email: Optional[str] = None) -> Any:
"""Actualiza datos de un usuario en Moodle (core_user_update_users)."""
params = {'users[0][id]': moodle_user_id}
if firstname:
params['users[0][firstname]'] = firstname
if lastname:
params['users[0][lastname]'] = lastname
if email:
params['users[0][email]'] = email
return cls.call('core_user_update_users', params, method='POST')
# --------------------------------------------------------------------------
# Matriculación y Cursos (enrol_manual_*, core_enrol_*)
# --------------------------------------------------------------------------
@classmethod
def enrol_user(cls, course_id: int, user_id: int, role_id: int = 5) -> Any:
"""
Matricula a un usuario en un curso Moodle (enrol_manual_enrol_users).
Role ID 5 = Estudiante, 3 = Docente con permiso de edición, 4 = Docente sin permiso.
"""
params = {
'enrolments[0][roleid]': role_id,
'enrolments[0][userid]': user_id,
'enrolments[0][courseid]': course_id
}
return cls.call('enrol_manual_enrol_users', params, method='POST')
@classmethod
def unenrol_user(cls, course_id: int, user_id: int, role_id: int = 5) -> Any:
"""Desmatricula a un usuario de un curso Moodle (enrol_manual_unenrol_users)."""
params = {
'enrolments[0][roleid]': role_id,
'enrolments[0][userid]': user_id,
'enrolments[0][courseid]': course_id
}
return cls.call('enrol_manual_unenrol_users', params, method='POST')
@classmethod
def get_enrolled_users(cls, course_id: int) -> List[Dict[str, Any]]:
"""Obtiene todos los usuarios matriculados en un curso (core_enrol_get_enrolled_users)."""
params = {'courseid': course_id}
return cls.call('core_enrol_get_enrolled_users', params)
# --------------------------------------------------------------------------
# Asignación de Roles (core_role_*)
# --------------------------------------------------------------------------
@classmethod
def assign_role(cls, role_id: int, user_id: int, context_id: int = 1) -> Any:
"""Asigna un rol en Moodle a un usuario en un contexto específico (core_role_assign_roles)."""
params = {
'assignments[0][roleid]': role_id,
'assignments[0][userid]': user_id,
'assignments[0][contextid]': context_id
}
return cls.call('core_role_assign_roles', params, method='POST')
@classmethod
def unassign_role(cls, role_id: int, user_id: int, context_id: int = 1) -> Any:
"""Remueve un rol en Moodle (core_role_unassign_roles)."""
params = {
'unassignments[0][roleid]': role_id,
'unassignments[0][userid]': user_id,
'unassignments[0][contextid]': context_id
}
return cls.call('core_role_unassign_roles', params, method='POST')
@classmethod
def create_users(cls, users: List[Dict[str, Any]]) -> Any:
params = {}
for idx, u in enumerate(users):
for k, v in u.items():
params[f'users[{idx}][{k}]'] = v
return cls.call('core_user_create_users', params, method='POST')
@classmethod
def update_users(cls, users: List[Dict[str, Any]]) -> Any:
params = {}
for idx, u in enumerate(users):
for k, v in u.items():
params[f'users[{idx}][{k}]'] = v
return cls.call('core_user_update_users', params, method='POST')
@classmethod
def enrol_users(cls, enrolments: List[Dict[str, Any]]) -> Any:
params = {}
for idx, e in enumerate(enrolments):
params[f'enrolments[{idx}][roleid]'] = e.get('roleid', e.get('role_id', 5))
params[f'enrolments[{idx}][userid]'] = e.get('userid', e.get('user_id'))
params[f'enrolments[{idx}][courseid]'] = e.get('courseid', e.get('course_id'))
return cls.call('enrol_manual_enrol_users', params, method='POST')
@classmethod
def unenrol_users(cls, enrolments: List[Dict[str, Any]]) -> Any:
params = {}
for idx, e in enumerate(enrolments):
params[f'enrolments[{idx}][roleid]'] = e.get('roleid', e.get('role_id', 5))
params[f'enrolments[{idx}][userid]'] = e.get('userid', e.get('user_id'))
params[f'enrolments[{idx}][courseid]'] = e.get('courseid', e.get('course_id'))
return cls.call('enrol_manual_unenrol_users', params, method='POST')
@classmethod
def get_users(cls, criteria: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
params = {}
for idx, c in enumerate(criteria):
params[f'criteria[{idx}][key]'] = c.get('key')
params[f'criteria[{idx}][value]'] = c.get('value')
res = cls.call('core_user_get_users', params)
return res.get('users', []) if isinstance(res, dict) else []
moodle_client = MoodleClient()
@@ -0,0 +1,203 @@
"""
Moodle Queue Service (admin-edu-space)
Provides asynchronous, fault-tolerant queuing and processing of synchronization
operations between admin-edu-space and Moodle 4.1.
Implements Exponential Backoff and Dead Letter Queue (DLQ).
"""
import logging
from datetime import datetime, timedelta
from app import db
from app.models.sync_task import MoodleSyncTask
from app.services.moodle_client import moodle_client
logger = logging.getLogger(__name__)
class MoodleQueueService:
@staticmethod
def enqueue_task(action: str, entity_type: str, entity_id: str = None, payload: dict = None, max_attempts: int = 5) -> MoodleSyncTask:
"""
Enqueues a new synchronization task to be processed asynchronously.
Guarantees that local transactions are never blocked by Moodle unavailability.
"""
task = MoodleSyncTask(
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id else None,
payload=payload or {},
status='PENDING',
attempts=0,
max_attempts=max_attempts,
next_retry_at=datetime.utcnow()
)
db.session.add(task)
db.session.commit()
logger.info(f"[MoodleQueue] Enqueued task {task.id}: action={action}, entity={entity_type}:{entity_id}")
return task
@staticmethod
def process_pending_tasks(batch_size: int = 20) -> dict:
"""
Processes a batch of pending or retrying tasks.
Uses exponential backoff for retries and sends to Dead Letter Queue (FAILED)
if max_attempts are exceeded.
"""
now = datetime.utcnow()
tasks = MoodleSyncTask.query.filter(
MoodleSyncTask.status.in_(['PENDING', 'RETRYING']),
(MoodleSyncTask.next_retry_at == None) | (MoodleSyncTask.next_retry_at <= now)
).order_by(MoodleSyncTask.created_at.asc()).limit(batch_size).all()
results = {
'processed': 0,
'succeeded': 0,
'failed': 0,
'retrying': 0
}
if not tasks:
return results
for task in tasks:
results['processed'] += 1
task.status = 'PROCESSING'
task.updated_at = datetime.utcnow()
db.session.commit()
try:
MoodleQueueService._execute_task_action(task)
task.status = 'COMPLETED'
task.error_message = None
task.updated_at = datetime.utcnow()
db.session.commit()
results['succeeded'] += 1
logger.info(f"[MoodleQueue] Task {task.id} ({task.action}) completed successfully.")
except Exception as e:
task.attempts += 1
task.error_message = str(e)
task.updated_at = datetime.utcnow()
if task.attempts >= task.max_attempts:
task.status = 'FAILED' # Dead Letter Queue (DLQ)
task.next_retry_at = None
results['failed'] += 1
logger.error(f"[MoodleQueue] Task {task.id} permanently failed (DLQ): {e}")
else:
task.status = 'RETRYING'
# Exponential Backoff: 30s, 60s, 120s, 240s... capped at 1 hour
delay_seconds = min(3600, (2 ** task.attempts) * 30)
task.next_retry_at = datetime.utcnow() + timedelta(seconds=delay_seconds)
results['retrying'] += 1
logger.warning(f"[MoodleQueue] Task {task.id} failed attempt {task.attempts}/{task.max_attempts}. Next retry in {delay_seconds}s: {e}")
db.session.commit()
return results
@staticmethod
def _execute_task_action(task: MoodleSyncTask):
"""
Executes the specific Moodle Web Service operation.
Raises an exception on failure or error response.
"""
payload = task.payload or {}
action = task.action.upper()
if action == 'CREATE_USER':
users = payload.get('users') or [payload]
res = moodle_client.create_users(users)
return res
elif action == 'UPDATE_USER':
users = payload.get('users') or [payload]
res = moodle_client.update_users(users)
return res
elif action == 'ENROL_USER':
enrolments = payload.get('enrolments') or [payload]
res = moodle_client.enrol_users(enrolments)
return res
elif action == 'UNENROL_USER':
enrolments = payload.get('enrolments') or [payload]
res = moodle_client.unenrol_users(enrolments)
return res
elif action == 'ASSIGN_ROLE':
role_id = payload.get('role_id')
user_id = payload.get('user_id')
context_id = payload.get('context_id', 1)
res = moodle_client.assign_role(role_id, user_id, context_id)
return res
elif action == 'UNASSIGN_ROLE':
role_id = payload.get('role_id')
user_id = payload.get('user_id')
context_id = payload.get('context_id', 1)
res = moodle_client.unassign_role(role_id, user_id, context_id)
return res
else:
raise ValueError(f"Unsupported sync action: {action}")
@staticmethod
def retry_task(task_id: int) -> bool:
"""
Manually re-enqueues a task from DLQ or error state back to PENDING.
"""
task = MoodleSyncTask.query.get(task_id)
if not task:
return False
task.status = 'PENDING'
task.attempts = 0
task.next_retry_at = datetime.utcnow()
task.error_message = None
task.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"[MoodleQueue] Task {task_id} manually reset to PENDING.")
return True
@staticmethod
def retry_all_failed() -> int:
"""
Retries all tasks in FAILED status (DLQ).
"""
failed_tasks = MoodleSyncTask.query.filter_by(status='FAILED').all()
count = 0
for task in failed_tasks:
task.status = 'PENDING'
task.attempts = 0
task.next_retry_at = datetime.utcnow()
task.updated_at = datetime.utcnow()
count += 1
db.session.commit()
logger.info(f"[MoodleQueue] Reset {count} failed tasks back to PENDING.")
return count
@staticmethod
def get_queue_summary() -> dict:
"""
Returns stats about tasks currently in the queue.
"""
counts = {
'PENDING': 0,
'PROCESSING': 0,
'RETRYING': 0,
'COMPLETED': 0,
'FAILED': 0
}
from sqlalchemy import func
rows = db.session.query(MoodleSyncTask.status, func.count(MoodleSyncTask.id)).group_by(MoodleSyncTask.status).all()
for status, count in rows:
if status in counts:
counts[status] = count
total = sum(counts.values())
return {
'summary': counts,
'total': total,
'pending_total': counts['PENDING'] + counts['RETRYING'] + counts['PROCESSING'],
'failed_dlq': counts['FAILED']
}
moodle_queue_service = MoodleQueueService()
+24
View File
@@ -0,0 +1,24 @@
from app import create_app, db
from sqlalchemy import text
app = create_app()
with app.app_context():
print("Verificando columnas en system_settings...")
try:
db.session.execute(text("ALTER TABLE system_settings ADD COLUMN category VARCHAR(50) DEFAULT 'system'"))
db.session.commit()
print("Columna 'category' agregada exitosamente.")
except Exception as e:
db.session.rollback()
print("Aviso al agregar 'category':", str(e).split('\n')[0])
try:
db.session.execute(text("ALTER TABLE system_settings ADD COLUMN is_encrypted BOOLEAN DEFAULT 0"))
db.session.commit()
print("Columna 'is_encrypted' agregada exitosamente.")
except Exception as e:
db.session.rollback()
print("Aviso al agregar 'is_encrypted':", str(e).split('\n')[0])
print("Migración de system_settings completa.")
+29
View File
@@ -0,0 +1,29 @@
"""
Migration script to create moodle_sync_tasks table if it doesn't exist.
Supports both SQLite and PostgreSQL.
"""
from app import create_app, db
from app.models.sync_task import MoodleSyncTask
from sqlalchemy import inspect
def run_migration():
app = create_app()
with app.app_context():
engine = db.engine
print(f"Connecting to database using engine: {engine.name}")
# db.create_all() creates any missing tables including moodle_sync_tasks
db.create_all()
print("db.create_all() executed successfully. Checking table existence...")
inspector = inspect(engine)
tables = inspector.get_table_names()
if 'moodle_sync_tasks' in tables:
print("SUCCESS: 'moodle_sync_tasks' table exists and is ready.")
cols = [c['name'] for c in inspector.get_columns('moodle_sync_tasks')]
print(f"Columns in moodle_sync_tasks: {cols}")
else:
print("ERROR: 'moodle_sync_tasks' table was not created.")
if __name__ == '__main__':
run_migration()
+149
View File
@@ -0,0 +1,149 @@
"""
Integration and Unit Tests for Phase 6 (Auth, Email & Moodle Backend)
Tests:
- CryptoService encryption and decryption
- SystemSetting encrypted values and masking
- MoodleQueueService (fault-tolerant queue, backoff, Dead Letter Queue DLQ)
- CacheService hybrid operation
- Hybrid Auth routes (providers, Google OAuth with domain checks, Moodle delegated auth)
- EmailService dynamic configuration
"""
import pytest
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
from app.services.crypto_service import crypto_service
from app.services.cache_service import cache_service
from app.services.moodle_queue_service import moodle_queue_service
from app.services.email_service import email_service
from app.models.setting import SystemSetting
from app.models.sync_task import MoodleSyncTask
from app.models.user import User
from app.models.role import Role
def test_crypto_service_encryption_and_decryption(app_context):
secret = "SuperSecretPassword123!"
encrypted = crypto_service.encrypt(secret)
assert encrypted != secret
assert len(encrypted) > 20
decrypted = crypto_service.decrypt(encrypted)
assert decrypted == secret
def test_system_setting_encrypted_value(init_database, app_context):
key = "test_smtp_pass"
val = "MySmtpSecretPass2026*"
setting = SystemSetting.set_encrypted_value(key, val, "Test SMTP Password", category="smtp")
assert setting.is_encrypted is True
assert setting.value != val # Must be encrypted in DB
assert setting.get_decrypted_value() == val
assert setting.get_masked_value() == "••••••••••••"
def test_cache_service_fallback(app_context):
key = "test_unit_key"
val = {"foo": "bar", "num": 42}
cache_service.set(key, val, ttl_seconds=10)
retrieved = cache_service.get(key)
assert retrieved == val
cache_service.delete(key)
assert cache_service.get(key) is None
def test_moodle_queue_service_enqueue_and_dlq(init_database, app_context):
# 1. Enqueue task
task = moodle_queue_service.enqueue_task(
action="CREATE_USER",
entity_type="user",
entity_id=99,
payload={"username": "testuser", "email": "test@unicaba.edu.ar"},
max_attempts=2
)
assert task.status == 'PENDING'
assert task.attempts == 0
# 2. Simulate processing failure (attempt 1 -> RETRYING)
with patch('app.services.moodle_queue_service.MoodleQueueService._execute_task_action') as mock_exec:
mock_exec.side_effect = Exception("Moodle Server Connection Timeout (504)")
results1 = moodle_queue_service.process_pending_tasks(batch_size=10)
assert results1['processed'] == 1
assert results1['retrying'] == 1
assert results1['failed'] == 0
# Verify task is now RETRYING with exponential backoff next_retry_at
task_refreshed = MoodleSyncTask.query.get(task.id)
assert task_refreshed.status == 'RETRYING'
assert task_refreshed.attempts == 1
assert "504" in task_refreshed.error_message
assert task_refreshed.next_retry_at is not None
# 3. Simulate second failure with next_retry_at in the past -> moves to DLQ (FAILED)
task_refreshed.next_retry_at = datetime.utcnow() - timedelta(minutes=1)
init_database.session.commit()
results2 = moodle_queue_service.process_pending_tasks(batch_size=10)
assert results2['processed'] == 1
assert results2['failed'] == 1 # DLQ triggered
task_dlq = MoodleSyncTask.query.get(task.id)
assert task_dlq.status == 'FAILED'
# 4. Manual DLQ retry
res_retry = moodle_queue_service.retry_task(task.id)
assert res_retry is True
task_reset = MoodleSyncTask.query.get(task.id)
assert task_reset.status == 'PENDING'
assert task_reset.attempts == 0
def test_email_service_dynamic_config(init_database, app_context):
SystemSetting.set_value('smtp_host', 'mail.unicaba.edu.ar', 'Host', category='smtp')
SystemSetting.set_value('smtp_port', '465', 'Port', category='smtp')
SystemSetting.set_value('smtp_security', 'ssl', 'Sec', category='smtp')
SystemSetting.set_value('smtp_user', 'bedelia@unicaba.edu.ar', 'User', category='smtp')
SystemSetting.set_encrypted_value('smtp_password', 'BedeliaSecure2026!', 'Pass', category='smtp')
cfg = email_service.get_smtp_config()
assert cfg['host'] == 'mail.unicaba.edu.ar'
assert cfg['port'] == 465
assert cfg['security'] == 'ssl'
assert cfg['user'] == 'bedelia@unicaba.edu.ar'
assert cfg['password'] == 'BedeliaSecure2026!'
def test_auth_providers_public_endpoint(client, init_database):
SystemSetting.set_value('auth_local_enabled', 'true')
SystemSetting.set_value('auth_google_enabled', 'true')
SystemSetting.set_value('auth_moodle_enabled', 'false')
SystemSetting.set_value('google_client_id', 'google-test-id-123')
res = client.get('/api/v1/auth/providers')
assert res.status_code == 200
data = res.get_json()
assert data['status'] == 'success'
assert data['providers']['local'] is True
assert data['providers']['google'] is True
assert data['providers']['moodle'] is False
assert data['google_client_id'] == 'google-test-id-123'
def test_google_auth_domain_enforcement(client, init_database):
SystemSetting.set_value('auth_google_enabled', 'true')
SystemSetting.set_value('google_allowed_domains', 'unicaba.edu.ar,lasalle.edu.ar')
# Domain rejected (e.g., hacker@gmail.com)
res_bad = client.post('/api/v1/auth/google', json={
'email': 'hacker@gmail.com',
'name': 'Hacker Unauthorized',
'domain': 'gmail.com'
})
assert res_bad.status_code == 403
assert "no está autorizado" in res_bad.get_json()['message']
# Domain accepted (docente@unicaba.edu.ar)
res_good = client.post('/api/v1/auth/google', json={
'email': 'docente.nuevo@unicaba.edu.ar',
'name': 'Docente Nuevo',
'domain': 'unicaba.edu.ar'
})
assert res_good.status_code == 200
data_good = res_good.get_json()
assert 'access_token' in data_good
assert data_good['user']['email'] == 'docente.nuevo@unicaba.edu.ar'
assert data_good['user']['role'] == 'Docente'
+27
View File
@@ -1233,4 +1233,31 @@ const handleAuditLogs = async (req, res) => {
router.get(['/audit_logs', '/audit-logs', '/audit'], handleAuditLogs);
// ─── 11. CONFIGURACIÓN GLOBAL & MOODLE SYNC ────────────────────────────────────
const handleGlobalSettings = async (req, res) => {
let settings = {
smtp: { host: 'smtp.gmail.com', port: 587, security: 'tls' },
auth_providers: { local: true, google: false, moodle: false },
google_oauth: { allowed_domains: 'unicaba.edu.ar', callback_url: '/auth/google/callback' },
moodle: { server_url: 'http://10.0.0.207/moodle', auto_sync_enabled: true, sync_interval_minutes: 15 }
};
try {
const resp = await req.apiClient.get('/admin/settings/all');
if (resp.data && resp.data.data) {
settings = resp.data.data;
}
} catch (e) {
console.warn('Global settings fetch notice:', e.message);
}
res.render('admin/settings/global_config', {
title: 'Configuración Global del Sistema - Admin Edu-Space',
settings
});
};
router.get(['/settings', '/global-config', '/config'], handleGlobalSettings);
module.exports = router;
+71 -4
View File
@@ -2,8 +2,24 @@ const express = require('express');
const router = express.Router();
const apiClient = require('../services/apiClient');
router.get('/login', (req, res) => {
res.render('auth/login', { error: null });
router.get('/login', async (req, res) => {
let providers = { local: true, google: false, moodle: false };
let google_client_id = '';
try {
const resp = await apiClient.get('/auth/providers');
if (resp.data && resp.data.providers) {
providers = resp.data.providers;
google_client_id = resp.data.google_client_id || '';
}
} catch (e) {
// Fallback default
}
res.render('auth/login', {
error: req.query.error || null,
providers,
google_client_id
});
});
router.post('/login', async (req, res) => {
@@ -20,7 +36,6 @@ router.post('/login', async (req, res) => {
const token = response.data.access_token;
if (token) {
// Guardamos el JWT en una cookie httpOnly
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
@@ -34,7 +49,59 @@ router.post('/login', async (req, res) => {
} catch (error) {
console.error('Login error:', error.response?.data || error.message);
const errorMsg = error.response?.data?.message || 'Error en el servidor de autenticación';
res.render('auth/login', { error: errorMsg });
res.render('auth/login', {
error: errorMsg,
providers: { local: true, google: false, moodle: false }
});
}
});
// Proxy para Login Delegado Moodle
router.post('/moodle', async (req, res) => {
try {
const { username, password } = req.body;
const response = await apiClient.post('/auth/moodle', { username, password });
const token = response.data.access_token;
if (token) {
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000
});
return res.redirect('/dashboard');
}
res.redirect('/auth/login?error=moodle_auth_failed');
} catch (error) {
console.error('Moodle auth error:', error.response?.data || error.message);
const errorMsg = encodeURIComponent(error.response?.data?.message || 'Error de autenticación en Moodle.');
res.redirect(`/auth/login?error=${errorMsg}`);
}
});
// Proxy para Google OAuth
router.post('/google', async (req, res) => {
try {
const response = await apiClient.post('/auth/google', req.body);
const token = response.data.access_token;
if (token) {
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000
});
return res.json({ success: true, redirect: '/dashboard' });
}
return res.status(401).json({ success: false, message: 'Fallo al autenticar con Google.' });
} catch (error) {
console.error('Google auth error:', error.response?.data || error.message);
return res.status(error.response?.status || 500).json({
success: false,
message: error.response?.data?.message || 'Error en autenticación Google Workspace.'
});
}
});
@@ -0,0 +1,777 @@
{% 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>
</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">
<div class="card border-0 bg-body-tertiary p-3 rounded-3 mb-3">
<h6 class="fw-bold mb-3 d-flex align-items-center">
<i class="bi bi-toggle2-on text-primary me-2"></i>Métodos de Autenticación Habilitados
</h6>
<form id="providersForm" onsubmit="saveAuthProviders(event)">
<div class="d-flex align-items-center justify-content-between p-2 rounded mb-2 border bg-body">
<div>
<div class="fw-semibold small">Login Nativo con Contraseña</div>
<small class="text-muted" style="font-size: 11px;">Permite inicio de sesión local. Si se desactiva, queda reservado exclusivamente para rol ADMIN como contingencia.</small>
</div>
<div class="form-check form-switch fs-5 ms-3">
<input class="form-check-input" type="checkbox" id="auth_local_enabled" {% if not settings or not settings.auth_providers or settings.auth_providers.local %}checked{% endif %}>
</div>
</div>
<div class="d-flex align-items-center justify-content-between p-2 rounded mb-2 border bg-body">
<div>
<div class="fw-semibold small">Google OAuth 2.0 (Google Workspace)</div>
<small class="text-muted" style="font-size: 11px;">Inicio de sesión con cuentas institucionales @unicaba.edu.ar mediante SSO.</small>
</div>
<div class="form-check form-switch fs-5 ms-3">
<input class="form-check-input" type="checkbox" id="auth_google_enabled" {% if settings and settings.auth_providers and settings.auth_providers.google %}checked{% endif %}>
</div>
</div>
<div class="d-flex align-items-center justify-content-between p-2 rounded mb-3 border bg-body">
<div>
<div class="fw-semibold small">Moodle SSO Delegado</div>
<small class="text-muted" style="font-size: 11px;">Valida credenciales contra el servidor de Moodle 4.1 y aprovisiona perfil.</small>
</div>
<div class="form-check form-switch fs-5 ms-3">
<input class="form-check-input" type="checkbox" id="auth_moodle_enabled" {% if settings and settings.auth_providers and settings.auth_providers.moodle %}checked{% endif %}>
</div>
</div>
<button type="submit" class="btn btn-sm btn-primary" id="btnSaveProviders">
<i class="bi bi-save me-1"></i>Actualizar Métodos de Login
</button>
</form>
</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>
</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>
<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';
}
}
// -------------------------------------------------------------
// 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,
google_enabled: document.getElementById('auth_google_enabled').checked,
moodle_enabled: document.getElementById('auth_moodle_enabled').checked
};
try {
const 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) {
showAlert(data.message || 'Métodos de acceso actualizados.', '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>Actualizar Métodos de Login';
}
}
// -------------------------------------------------------------
// 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];
});
}
// Cargar estadísticas y tareas al iniciar
document.addEventListener('DOMContentLoaded', () => {
refreshQueueStats();
});
</script>
{% endblock %}
+173 -10
View File
@@ -22,6 +22,59 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Design System Theme -->
<link href="/css/theme.css" rel="stylesheet">
<style>
.btn-google {
background-color: #ffffff;
color: #3c4043;
border: 1px solid #dadce0;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-google:hover {
background-color: #f8f9fa;
border-color: #c6c9cc;
color: #202124;
box-shadow: 0 1px 3px rgba(60,64,67,.3);
}
[data-bs-theme="dark"] .btn-google {
background-color: #1f2937;
color: #f3f4f6;
border-color: #374151;
}
[data-bs-theme="dark"] .btn-google:hover {
background-color: #374151;
color: #ffffff;
}
.btn-moodle {
background-color: #f98012;
color: #ffffff;
border: 1px solid #f98012;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-moodle:hover {
background-color: #e06d07;
color: #ffffff;
box-shadow: 0 2px 6px rgba(249, 128, 18, 0.4);
}
.divider-text {
display: flex;
align-items: center;
text-align: center;
margin: 1.25rem 0;
color: var(--bs-secondary-color);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.divider-text::before, .divider-text::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--bs-border-color);
}
.divider-text::before { margin-right: 0.75em; }
.divider-text::after { margin-left: 0.75em; }
</style>
</head>
<body>
<div class="auth-wrapper">
@@ -32,7 +85,6 @@
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
<!-- Language Switcher could go here -->
</div>
<img src="/img/logo_unicaba.png" alt="UniCABA" class="auth-logo">
@@ -44,12 +96,60 @@
<!-- Flash Messages -->
{% if error %}
<div class="alert alert-danger alert-dismissible fade show rounded-3 py-2 small" role="alert">
{{ error }}
<i class="bi bi-exclamation-triangle-fill me-1"></i> {{ error }}
<button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form action="/auth/login" method="POST" novalidate>
<!-- Métodos de Autenticación SSO Externos -->
{% if providers and (providers.google or providers.moodle) %}
<div class="d-grid gap-2 mb-3">
{% if providers.google %}
<button type="button" class="btn btn-google py-2 d-flex align-items-center justify-content-center" onclick="handleGoogleLogin()">
<svg class="me-2" width="18" height="18" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"/>
</svg>
<span>Continuar con Google Workspace</span>
</button>
{% endif %}
{% if providers.moodle %}
<button type="button" class="btn btn-moodle py-2 d-flex align-items-center justify-content-center" data-bs-toggle="collapse" data-bs-target="#moodleLoginForm">
<i class="bi bi-mortarboard-fill me-2 fs-5"></i>
<span>Ingresar con Moodle UniCABA</span>
</button>
<div class="collapse mt-2" id="moodleLoginForm">
<div class="card p-3 border-warning-subtle bg-body-tertiary">
<form action="/auth/moodle" method="POST">
<div class="mb-2">
<label class="form-label small fw-semibold text-muted">Usuario Moodle</label>
<input type="text" class="form-control form-control-sm" name="username" placeholder="admin o usuario institucional" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold text-muted">Contraseña Moodle</label>
<input type="password" class="form-control form-control-sm" name="password" placeholder="Tu contraseña de Moodle" required>
</div>
<button type="submit" class="btn btn-warning btn-sm w-100 fw-semibold text-dark">
<i class="bi bi-box-arrow-in-right me-1"></i>Validar en Servidor Moodle
</button>
</form>
</div>
</div>
{% endif %}
</div>
{% if not providers or providers.local %}
<div class="divider-text">o con credenciales locales</div>
{% endif %}
{% endif %}
<!-- Formulario Login Nativo / Fallback -->
{% if not providers or providers.local %}
<form action="/auth/login" method="POST" id="localLoginForm" novalidate>
<div class="mb-3">
<label for="username" class="form-label small fw-semibold text-muted">Correo Electrónico</label>
<div class="input-group">
@@ -81,6 +181,14 @@
</button>
</div>
</form>
{% else %}
<div class="alert alert-info py-2 px-3 small rounded-3 mb-3 text-center">
<i class="bi bi-info-circle me-1"></i>El acceso con contraseña local está deshabilitado para usuarios generales.
<div class="mt-1">
<a href="#adminEmergencyModal" data-bs-toggle="modal" class="fw-semibold text-decoration-none">Acceso de Emergencia ADMIN</a>
</div>
</div>
{% endif %}
<!-- Accesos Rápidos Institucionales (Demo) -->
<div class="p-3 bg-body-tertiary rounded-3 border mt-3">
@@ -118,11 +226,38 @@
</div>
</div>
<!-- Modal Acceso de Emergencia ADMIN si local está inactivo -->
<div class="modal fade" id="adminEmergencyModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title fw-bold"><i class="bi bi-shield-exclamation text-warning me-1"></i>Acceso de Emergencia ADMIN</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form action="/auth/login" method="POST">
<div class="mb-2">
<label class="form-label small fw-semibold">Email Administrador</label>
<input type="email" name="username" class="form-control form-control-sm" required placeholder="admin@unicaba.edu.ar">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Contraseña</label>
<input type="password" name="password" class="form-control form-control-sm" required>
</div>
<button type="submit" class="btn btn-primary btn-sm w-100 fw-semibold">Ingresar como Admin</button>
</form>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/js/theme-toggle.js"></script>
<script>
// Toggle password visibility
document.getElementById('togglePasswordBtn').addEventListener('click', function() {
const toggleBtn = document.getElementById('togglePasswordBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
const pwdInput = document.getElementById('password');
const icon = document.getElementById('togglePasswordIcon');
if (pwdInput.type === 'password') {
@@ -133,19 +268,47 @@
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
});
}
// Demo login autofill buttons
document.querySelectorAll('.demo-fill-btn').forEach(btn => {
btn.addEventListener('click', function() {
const email = this.getAttribute('data-email');
document.getElementById('username').value = email;
document.getElementById('password').value = 'admin123';
// Highlight input briefly
const emailInput = document.getElementById('username');
emailInput.classList.add('is-valid');
setTimeout(() => emailInput.classList.remove('is-valid'), 1500);
const usrField = document.getElementById('username');
const pwdField = document.getElementById('password');
if (usrField && pwdField) {
usrField.value = email;
pwdField.value = 'admin123';
usrField.classList.add('is-valid');
setTimeout(() => usrField.classList.remove('is-valid'), 1500);
}
});
});
// Simulación / Integración de Google OAuth
function handleGoogleLogin() {
const emailPrompt = prompt('Ingrese su correo institucional de Google Workspace (@unicaba.edu.ar):', 'profesor@unicaba.edu.ar');
if (!emailPrompt) return;
fetch('/auth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: emailPrompt,
name: emailPrompt.split('@')[0],
domain: emailPrompt.split('@')[1] || 'unicaba.edu.ar'
})
})
.then(res => res.json())
.then(data => {
if (data.success && data.redirect) {
window.location.href = data.redirect;
} else {
alert('Error en login con Google: ' + (data.message || 'No autorizado'));
}
})
.catch(err => alert('Error de red: ' + err.message));
}
</script>
</body>
</html>
+1
View File
@@ -570,6 +570,7 @@
<li><a class="nav-link" href="/admin/milestone_types_list">Tipos Hitos</a></li>
<li><a class="nav-link" href="/admin/google_sheets_import">Sincro Sheets</a></li>
<li><a class="nav-link" href="/admin/audit_logs">Auditoría</a></li>
<li><a class="nav-link text-primary fw-semibold" href="/admin/settings"><i class="bi bi-gear-wide-connected me-1" style="min-width:auto; font-size:1rem;"></i>Config. Global</a></li>
</ul>
</div>
</li>