feat(phase5): integracion auth hibrida, email dinamico y moodle 4.1 backend con tolerancia a fallos
This commit is contained in:
@@ -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'
|
||||
]
|
||||
@@ -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}>'
|
||||
|
||||
@@ -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}]>'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user