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
+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