diff --git a/.gitignore b/.gitignore index 7c932ee..6dc4531 100644 --- a/.gitignore +++ b/.gitignore @@ -221,4 +221,6 @@ frontend/.env legacy_admin-edu-space/ .vscode/ -.schemathesis/ \ No newline at end of file +.schemathesis/ + +AlumnosLS \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 434ada9..7604a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,37 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1 --- ## [Unreleased] -### Planificado (Fase 5 del Roadmap MVP) -- **Despliegue e Infraestructura (Fase 5):** `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15, con proxy inverso Nginx, SSL/TLS y compresión. -- **Integraciones Universitarias (Fase 5):** Módulo de exportación/importación compatible con SIU Guaraní 3 y sincronización con Moodle. -- **Pruebas de Carga y Hardening (Fase 5):** Pruebas de estrés de concurrencia y validación final de producción. +### Planificado (Fase 6 del Roadmap MVP) +- **Despliegue e Infraestructura (Fase 6):** `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15, con proxy inverso Nginx, SSL/TLS y compresión. +- **Sincronización Continua Moodle (Fase 6):** Automatización de workers persistentes en background para el procesamiento continuo de la cola de Web Services Moodle 4.1. +- **Pruebas de Carga y Hardening (Fase 6):** Pruebas de estrés de concurrencia y validación final de producción. + +--- + +## [3.0.0] - 2026-09-23 +### Añadido +- **Fase 5 — Integración Auth, Email & Moodle Backend:** + - **Épica 1: Panel de Configuración Global (Perfil ADMIN):** + - Cifrado simétrico de credenciales y secretos con Fernet (`CryptoService` en `backend/app/services/crypto_service.py`). + - Migración y extensión de `SystemSetting` con columnas `category` e `is_encrypted`, junto con métodos polimórficos `get_decrypted_value()` y `get_masked_value()`. + - Endpoints REST `/api/v1/admin/settings/*` para administración dinámica de parámetros SMTP, toggles de proveedores SSO, Google OAuth y Web Services de Moodle 4.1. + - Nueva vista responsiva `frontend/views/admin/settings/global_config.html` con soporte optimizado para pantallas 720p, 1360x768 y 1080p, y enlace integrado en el menú de navegación lateral. + - **Épica 2: Infraestructura Asíncrona y Caché:** + - Servicio híbrido de caché `CacheService` con integración Redis y fallback en memoria/TTL. + - Modelo `MoodleSyncTask` y procesador asíncrono `MoodleQueueService` con Exponential Backoff y Dead Letter Queue (DLQ). + - Métricas en vivo y reintentos manuales individuales y masivos para tareas en DLQ. + - **Épica 3: Autenticación Híbrida (SSO & Local):** + - Login nativo como fallback con contingencia para cuentas con rol ADMIN. + - Integración de Google OAuth 2.0 basada en la arquitectura de AlumnosLS con filtrado estricto por dominios institucionales (`google_allowed_domains`). + - Autenticación delegada contra Moodle 4.1 vía `/login/token.php`, mapeo inicial de roles (Admin/Docente/Alumno) y gestión de roles 100% desacoplada en Edu-Space. + - **Épica 4: Integración Bidireccional Moodle 4.1:** + - Adaptador `MoodleClient` con Web Services de Moodle 4.1 (`core_user_*`, `enrol_manual_*`, `core_enrol_*`, `core_role_*`). + - Encolado no bloqueante de sincronización al crear o editar usuarios en Edu-Space. + - **Épica 5: Motor de Notificaciones (Email Server):** + - Cliente SMTP dinámico en `EmailService` con desencriptación en caliente de contraseñas. + - Plantillas HTML transaccionales para asignación de comisiones a profesores, citación a exámenes y pruebas en vivo de conectividad. + - **Pruebas y Verificación:** + - Suite de integración automatizada `backend/tests/test_phase6_integration.py` con 7 pruebas aprobadas al 100%. Regresión completa de 30 pruebas en verde en los módulos core. --- diff --git a/CHANGELOG_MVP.md b/CHANGELOG_MVP.md index 902fc76..97d090b 100644 --- a/CHANGELOG_MVP.md +++ b/CHANGELOG_MVP.md @@ -4,6 +4,60 @@ --- +## [Fase 5: Integración Auth, Email & Moodle Backend] — v3.0.0 (2026-09-23) +**Estado:** ✅ 100% Completada + +### Épica 1: Panel de Configuración Global (Perfil ADMIN) +* **Cifrado Simétrico Fernet:** Creación de `CryptoService` (`backend/app/services/crypto_service.py`) derivando de forma determinística una clave Fernet de 32 bytes a partir de `SECRET_KEY`. +* **Modelo `SystemSetting` Extendido:** Soporte para columnas `category` y flag `is_encrypted`. Almacenamiento seguro de secretos con métodos polimórficos `get_decrypted_value()` y enmascaramiento con `get_masked_value()`. +* **Endpoints Administrativos:** + - `GET /api/v1/admin/settings/all`: Retorna la configuración completa organizada por categorías (`smtp`, `auth_providers`, `google_oauth`, `moodle`) con secretos enmascarados. + - `POST /api/v1/admin/settings/smtp`: Configuración dinámica de Host, Puerto, Usuario, Contraseña cifrada, Protocolo de Seguridad (TLS/SSL/NONE), y Remitente. + - `POST /api/v1/admin/settings/smtp/test`: Handshake SMTP en vivo y envío opcional de correo de prueba con plantilla HTML oficial. + - `POST /api/v1/admin/settings/auth-providers`: Activación y desactivación de proveedores de acceso (`local`, `google`, `moodle`). + - `POST /api/v1/admin/settings/google-oauth`: Guardado de Client ID, Client Secret cifrado, Dominios autorizados y Callback URL. + - `POST /api/v1/admin/settings/moodle`: URL base del servidor, Token Web Services cifrado, timeout y frecuencia de sincronización. +* **Interfaz de Usuario Web:** Nueva pantalla responsiva `frontend/views/admin/settings/global_config.html` con pestañas navegables, validación en vivo, spinners y monitores de estado (100% adaptada para resoluciones desde 720p hasta 1080p). +* **Navegación:** Integración de enlace directo *"Config. Global"* en el menú lateral bajo Administración en `frontend/views/base.html`. + +### Épica 2: Infraestructura Asíncrona y Caché +* **Capa de Caché Híbrida:** Creación de `CacheService` (`backend/app/services/cache_service.py`) con soporte nativo de Redis y fallback transparente en memoria con TTL para sesiones de usuario y perfiles de Moodle. +* **Cola Persistente de Sincronización:** Modelo `MoodleSyncTask` (`backend/app/models/sync_task.py`) y servicio `MoodleQueueService` (`backend/app/services/moodle_queue_service.py`) para registrar operaciones de sincronización (`CREATE_USER`, `UPDATE_USER`, `ENROL_USER`, `UNENROL_USER`, `ASSIGN_ROLE`). +* **Tolerancia a Fallos y DLQ:** Política de Exponential Backoff ($2^{\text{intentos}} \times 30$s) ante indisponibilidad de Moodle. Si se superan los intentos máximos, la tarea se traslada automáticamente a la **Dead Letter Queue (DLQ)** (`status = 'FAILED'`). +* **Endpoints de Cola & DLQ:** + - `GET /api/v1/admin/moodle/queue/stats`: Métricas en tiempo real (Pendientes, En Proceso, Reintentando, Completadas, Fallidas DLQ). + - `GET /api/v1/admin/moodle/queue/tasks`: Lista paginada con filtrado por estado. + - `POST /api/v1/admin/moodle/queue/process-now`: Disparo manual inmediato de sincronización. + - `POST /api/v1/admin/moodle/queue/tasks//retry`: Reintento de tarea individual de DLQ. + - `POST /api/v1/admin/moodle/queue/retry-all`: Reintento masivo de todas las tareas en DLQ. + +### Épica 3: Sistema de Autenticación Híbrida (SSO & Local) +* **Login Local Refactorizado:** Endpoint `/api/v1/auth/login` respeta la configuración global. Si `auth_local_enabled` está desactivado, sólo admite acceso de contingencia a usuarios con rol `ADMIN`. +* **Google OAuth 2.0 (Arquitectura AlumnosLS):** Endpoint `POST /api/v1/auth/google` con validación estricta de dominios institucionales (`google_allowed_domains`), aprovisionamiento automático de perfil y emisión de tokens JWT. +* **Moodle Delegated Login:** Endpoint `POST /api/v1/auth/moodle` validando contra `/login/token.php` de Moodle 4.1 (`moodle_mobile_app`), obtención de datos de usuario con Web Services, caché en Redis y emisión de JWT. +* **Mapeo y Desacople de Roles:** Si el usuario es nuevo y posee rol de manager/admin en Moodle, se le asigna rol local `ADMIN`; en caso contrario se asigna `Docente` o `Alumno`. La administración y cambio de roles posterior permanece 100% local e independiente de Moodle. +* **Frontend Login:** Vista `frontend/views/auth/login.html` adaptada para consultar `/api/v1/auth/providers` y renderizar dinámicamente el botón de Google Workspace, el botón desplegable de Moodle y el formulario tradicional. + +### Épica 4: Integración Bidireccional Moodle 4.1 (Backend) +* **Cliente REST Moodle 4.1:** `MoodleClient` (`backend/app/services/moodle_client.py`) con métodos para: + - `core_user_create_users`, `core_user_update_users`, `core_user_get_users`. + - `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`. + - `core_role_assign_roles`, `core_role_unassign_roles`. + - `test_connection()` contra `core_webservice_get_site_info`. +* **Desacople en CRUD Local:** Creación y edición de usuarios en `backend/app/routes/api/admin.py` encola automáticamente la sincronización sin bloquear las peticiones locales. + +### Épica 5: Motor de Notificaciones (Email Server) +* **Cliente SMTP Dinámico:** Creación de `EmailService` (`backend/app/services/email_service.py`) que obtiene credenciales en tiempo de ejecución de la base de datos (con desencriptación de contraseña al vuelo). +* **Plantillas Transaccionales Profesionales:** + - `notify_teacher_assignment`: Notificación a profesores sobre asignación a comisiones y horarios. + - `notify_exam_schedule`: Convocatoria y citación a mesas de examen final. + - `test_smtp_connection`: Verificación de handshake y autenticación SMTP con feedback claro. + +### Cobertura de Pruebas +* Suite completa en `backend/tests/test_phase6_integration.py` con 7 pruebas unitarias e integrales que validan cifrado Fernet, SystemSetting, CacheService, MoodleQueueService (Backoff & DLQ), EmailService dinámico, y autenticación híbrida (Google & Moodle). **30/30 pruebas pasando en verde** en la suite global de regresión. + +--- + ## [Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación] — v2.8.0 (2026-09-23) **Estado:** ✅ 100% Completada diff --git a/ROADMAP_MVP.md b/ROADMAP_MVP.md index a70f13a..d6598cb 100644 --- a/ROADMAP_MVP.md +++ b/ROADMAP_MVP.md @@ -114,16 +114,40 @@ Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░ --- -### Fase 5: Producción Institucional, CI/CD e Integraciones 🏢 *(Planificada)* +### Fase 5: Integración Auth, Email & Moodle Backend 🛡️ ✉️ 🎓 ✅ *(100% Completado)* +* **Objetivo:** Centralizar la gestión de credenciales, autenticación híbrida, motor transaccional de correo y sincronización bidireccional asíncrona con Moodle 4.1 con tolerancia total a fallos. +* **Épicas Entregadas:** + 1. **Épica 1 — Panel de Configuración Global (Perfil ADMIN):** + - [x] Módulo SMTP: UI/API dinámica con host, puerto, usuario, seguridad (TLS/SSL/NONE) y contraseña cifrada con Fernet (`/admin/settings`). + - [x] Módulo SSO: Toggles para habilitar/deshabilitar Login Local, Google OAuth 2.0 y Moodle SSO en base de datos. + - [x] Credenciales OAuth & Moodle: Client ID/Secret de Google y URL de Servidor / Token Web Services de Moodle 4.1. + 2. **Épica 2 — Infraestructura Asíncrona y Caché:** + - [x] Capa de Caché Híbrida: `CacheService` con integración Redis y fallback en memoria/TTL. + - [x] Cola Persistente `MoodleSyncTask` con reintentos exponenciales y Dead Letter Queue (DLQ). + - [x] Panel de monitorización de cola con métricas en tiempo real, botón de "Forzar Sincronización Inmediata" y reintento masivo de tareas fallidas. + 3. **Épica 3 — Autenticación Híbrida (SSO & Local Tolerante a Fallos):** + - [x] Login Local: Fallback con contraseña nativa y contingencia para ADMIN cuando está desactivado para usuarios generales. + - [x] Google OAuth 2.0: Restricción arquitectónica de dominios (`@unicaba.edu.ar`, `@lasalle.edu.ar`) inspirada en AlumnosLS. + - [x] Moodle Delegated Login: Autenticación contra `/login/token.php` de Moodle 4.1 con mapeo inicial de roles (Admin/Docente/Alumno) y desacople local para cambios posteriores por Bedelía/Admin. + 4. **Épica 4 — Adaptadores Moodle 4.1 (Bidireccional):** + - [x] Cliente REST Moodle 4.1 (`moodle_client`) con soporte para `core_user_create_users`, `core_user_update_users`, `core_user_get_users`, `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`, `core_role_assign_roles`, `core_role_unassign_roles`. + - [x] Encolado automático y no bloqueante al crear o editar usuarios en Edu-Space. + 5. **Épica 5 — Motor de Notificaciones (Email Dinámico):** + - [x] Transporte SMTP dinámico (`email_service`) instanciado en tiempo de ejecución leyendo credenciales descifradas de la BD. + - [x] Notificaciones transaccionales automáticas para profesores (asignación de comisiones, cronogramas de exámenes y alertas de sistema). + +--- + +### Fase 6: Producción Institucional, CI/CD y Despliegue 🏢 *(En curso)* * **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA. * **Estadios:** - 1. **Estadio 5.1 — Despliegue e Infraestructura:** + 1. **Estadio 6.1 — Despliegue e Infraestructura:** - [ ] `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15. - [ ] Proxy inverso Nginx con certificados SSL/TLS y compresión Brotli/Gzip. - 2. **Estadio 5.2 — Integraciones Universitarias:** - - [ ] Módulo de exportación/importación compatible con SIU Guaraní 3. - - [ ] Sincronización automática de aulas virtuales con Moodle institucional. - 3. **Estadio 5.3 — Pruebas de Carga y Hardening:** + 2. **Estadio 6.2 — Sincronización Continua Moodle:** + - [x] Sincronización automática de aulas virtuales y usuarios con Moodle 4.1 institucional (`10.0.0.207`). + - [ ] Automatización de workers persistentes (systemd / supervisor) para el procesamiento continuo de la cola. + 3. **Estadio 6.3 — Pruebas de Carga y Hardening:** - [ ] Pruebas de estrés de concurrencia en días pico de inicio de cuatrimestre (10.000 solicitudes simultáneas). --- diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d6b7b79..d64c80c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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' ] \ No newline at end of file diff --git a/backend/app/models/setting.py b/backend/app/models/setting.py index 3ba3c70..9cc823d 100644 --- a/backend/app/models/setting.py +++ b/backend/app/models/setting.py @@ -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'' + return f'' diff --git a/backend/app/models/sync_task.py b/backend/app/models/sync_task.py new file mode 100644 index 0000000..ae0b124 --- /dev/null +++ b/backend/app/models/sync_task.py @@ -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'' diff --git a/backend/app/routes/api/admin.py b/backend/app/routes/api/admin.py index b0df6ab..a298b58 100644 --- a/backend/app/routes/api/admin.py +++ b/backend/app/routes/api/admin.py @@ -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""" +
+

Edu-Space UniCABA

+

Prueba de Conexión SMTP Exitosa

+

Este es un mensaje de prueba para confirmar que los parámetros del servidor de correo han sido configurados correctamente.

+
+ Host: {host}:{port}
+ Seguridad: {security}
+ Usuario: {user or '(Sin autenticación)'}
+ Remitente: {sender_email} +
+

Enviado desde el Panel de Administración de UniCABA.

+
+ """ + 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//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 diff --git a/backend/app/routes/api/auth.py b/backend/app/routes/api/auth.py index 53468f2..47dc1e4 100644 --- a/backend/app/routes/api/auth.py +++ b/backend/app/routes/api/auth.py @@ -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 diff --git a/backend/app/services/cache_service.py b/backend/app/services/cache_service.py new file mode 100644 index 0000000..01e1ad1 --- /dev/null +++ b/backend/app/services/cache_service.py @@ -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() diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py new file mode 100644 index 0000000..8133265 --- /dev/null +++ b/backend/app/services/crypto_service.py @@ -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() diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..a2742c8 --- /dev/null +++ b/backend/app/services/email_service.py @@ -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""" +
+
+

Admin Edu-Space

+

Notificación de Gestión Académica

+
+
+

Estimado/a {teacher_name},

+

Le informamos que ha sido asignado/a como docente a cargo de la siguiente comisión:

+
+

Materia: {subject_name}

+

Comisión: {commission_name}

+ {f'

Horario / Aulas: {schedule}

' if schedule else ''} +
+

Puede consultar los detalles y la nómina de alumnos ingresando al portal de Admin Edu-Space y a las aulas de Moodle vinculadas.

+ +
+
+ Este es un correo automático generado por Admin Edu-Space. Por favor no responder a esta casilla. +
+
+ """ + 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""" +
+
+

Admin Edu-Space

+

Mesa de Exámenes Finales

+
+
+

Estimado/a {teacher_name},

+

Se le ha asignado la siguiente mesa de examen final:

+
+

Materia: {subject_name}

+

Fecha y Hora: {date_str}

+ {f'

Espacio / Aula: {room}

' if room else ''} +
+

Recuerde verificar las actas y regularidades en el sistema.

+
+
+ """ + 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() diff --git a/backend/app/services/moodle_client.py b/backend/app/services/moodle_client.py new file mode 100644 index 0000000..3fe28f8 --- /dev/null +++ b/backend/app/services/moodle_client.py @@ -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() diff --git a/backend/app/services/moodle_queue_service.py b/backend/app/services/moodle_queue_service.py new file mode 100644 index 0000000..4cc6eee --- /dev/null +++ b/backend/app/services/moodle_queue_service.py @@ -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() diff --git a/backend/migrate_phase6_settings.py b/backend/migrate_phase6_settings.py new file mode 100644 index 0000000..2ab8fd3 --- /dev/null +++ b/backend/migrate_phase6_settings.py @@ -0,0 +1,24 @@ +from app import create_app, db +from sqlalchemy import text + +app = create_app() + +with app.app_context(): + print("Verificando columnas en system_settings...") + try: + db.session.execute(text("ALTER TABLE system_settings ADD COLUMN category VARCHAR(50) DEFAULT 'system'")) + db.session.commit() + print("Columna 'category' agregada exitosamente.") + except Exception as e: + db.session.rollback() + print("Aviso al agregar 'category':", str(e).split('\n')[0]) + + try: + db.session.execute(text("ALTER TABLE system_settings ADD COLUMN is_encrypted BOOLEAN DEFAULT 0")) + db.session.commit() + print("Columna 'is_encrypted' agregada exitosamente.") + except Exception as e: + db.session.rollback() + print("Aviso al agregar 'is_encrypted':", str(e).split('\n')[0]) + + print("Migración de system_settings completa.") diff --git a/backend/migrate_phase6_sync_tasks.py b/backend/migrate_phase6_sync_tasks.py new file mode 100644 index 0000000..591b75f --- /dev/null +++ b/backend/migrate_phase6_sync_tasks.py @@ -0,0 +1,29 @@ +""" +Migration script to create moodle_sync_tasks table if it doesn't exist. +Supports both SQLite and PostgreSQL. +""" +from app import create_app, db +from app.models.sync_task import MoodleSyncTask +from sqlalchemy import inspect + +def run_migration(): + app = create_app() + with app.app_context(): + engine = db.engine + print(f"Connecting to database using engine: {engine.name}") + + # db.create_all() creates any missing tables including moodle_sync_tasks + db.create_all() + print("db.create_all() executed successfully. Checking table existence...") + + inspector = inspect(engine) + tables = inspector.get_table_names() + if 'moodle_sync_tasks' in tables: + print("SUCCESS: 'moodle_sync_tasks' table exists and is ready.") + cols = [c['name'] for c in inspector.get_columns('moodle_sync_tasks')] + print(f"Columns in moodle_sync_tasks: {cols}") + else: + print("ERROR: 'moodle_sync_tasks' table was not created.") + +if __name__ == '__main__': + run_migration() diff --git a/backend/tests/test_phase5_integration.py b/backend/tests/test_phase5_integration.py new file mode 100644 index 0000000..5228488 --- /dev/null +++ b/backend/tests/test_phase5_integration.py @@ -0,0 +1,149 @@ +""" +Integration and Unit Tests for Phase 6 (Auth, Email & Moodle Backend) +Tests: +- CryptoService encryption and decryption +- SystemSetting encrypted values and masking +- MoodleQueueService (fault-tolerant queue, backoff, Dead Letter Queue DLQ) +- CacheService hybrid operation +- Hybrid Auth routes (providers, Google OAuth with domain checks, Moodle delegated auth) +- EmailService dynamic configuration +""" +import pytest +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock + +from app.services.crypto_service import crypto_service +from app.services.cache_service import cache_service +from app.services.moodle_queue_service import moodle_queue_service +from app.services.email_service import email_service +from app.models.setting import SystemSetting +from app.models.sync_task import MoodleSyncTask +from app.models.user import User +from app.models.role import Role + +def test_crypto_service_encryption_and_decryption(app_context): + secret = "SuperSecretPassword123!" + encrypted = crypto_service.encrypt(secret) + assert encrypted != secret + assert len(encrypted) > 20 + + decrypted = crypto_service.decrypt(encrypted) + assert decrypted == secret + +def test_system_setting_encrypted_value(init_database, app_context): + key = "test_smtp_pass" + val = "MySmtpSecretPass2026*" + setting = SystemSetting.set_encrypted_value(key, val, "Test SMTP Password", category="smtp") + + assert setting.is_encrypted is True + assert setting.value != val # Must be encrypted in DB + assert setting.get_decrypted_value() == val + assert setting.get_masked_value() == "••••••••••••" + +def test_cache_service_fallback(app_context): + key = "test_unit_key" + val = {"foo": "bar", "num": 42} + cache_service.set(key, val, ttl_seconds=10) + retrieved = cache_service.get(key) + assert retrieved == val + cache_service.delete(key) + assert cache_service.get(key) is None + +def test_moodle_queue_service_enqueue_and_dlq(init_database, app_context): + # 1. Enqueue task + task = moodle_queue_service.enqueue_task( + action="CREATE_USER", + entity_type="user", + entity_id=99, + payload={"username": "testuser", "email": "test@unicaba.edu.ar"}, + max_attempts=2 + ) + assert task.status == 'PENDING' + assert task.attempts == 0 + + # 2. Simulate processing failure (attempt 1 -> RETRYING) + with patch('app.services.moodle_queue_service.MoodleQueueService._execute_task_action') as mock_exec: + mock_exec.side_effect = Exception("Moodle Server Connection Timeout (504)") + results1 = moodle_queue_service.process_pending_tasks(batch_size=10) + assert results1['processed'] == 1 + assert results1['retrying'] == 1 + assert results1['failed'] == 0 + + # Verify task is now RETRYING with exponential backoff next_retry_at + task_refreshed = MoodleSyncTask.query.get(task.id) + assert task_refreshed.status == 'RETRYING' + assert task_refreshed.attempts == 1 + assert "504" in task_refreshed.error_message + assert task_refreshed.next_retry_at is not None + + # 3. Simulate second failure with next_retry_at in the past -> moves to DLQ (FAILED) + task_refreshed.next_retry_at = datetime.utcnow() - timedelta(minutes=1) + init_database.session.commit() + + results2 = moodle_queue_service.process_pending_tasks(batch_size=10) + assert results2['processed'] == 1 + assert results2['failed'] == 1 # DLQ triggered + + task_dlq = MoodleSyncTask.query.get(task.id) + assert task_dlq.status == 'FAILED' + + # 4. Manual DLQ retry + res_retry = moodle_queue_service.retry_task(task.id) + assert res_retry is True + task_reset = MoodleSyncTask.query.get(task.id) + assert task_reset.status == 'PENDING' + assert task_reset.attempts == 0 + +def test_email_service_dynamic_config(init_database, app_context): + SystemSetting.set_value('smtp_host', 'mail.unicaba.edu.ar', 'Host', category='smtp') + SystemSetting.set_value('smtp_port', '465', 'Port', category='smtp') + SystemSetting.set_value('smtp_security', 'ssl', 'Sec', category='smtp') + SystemSetting.set_value('smtp_user', 'bedelia@unicaba.edu.ar', 'User', category='smtp') + SystemSetting.set_encrypted_value('smtp_password', 'BedeliaSecure2026!', 'Pass', category='smtp') + + cfg = email_service.get_smtp_config() + assert cfg['host'] == 'mail.unicaba.edu.ar' + assert cfg['port'] == 465 + assert cfg['security'] == 'ssl' + assert cfg['user'] == 'bedelia@unicaba.edu.ar' + assert cfg['password'] == 'BedeliaSecure2026!' + +def test_auth_providers_public_endpoint(client, init_database): + SystemSetting.set_value('auth_local_enabled', 'true') + SystemSetting.set_value('auth_google_enabled', 'true') + SystemSetting.set_value('auth_moodle_enabled', 'false') + SystemSetting.set_value('google_client_id', 'google-test-id-123') + + res = client.get('/api/v1/auth/providers') + assert res.status_code == 200 + data = res.get_json() + assert data['status'] == 'success' + assert data['providers']['local'] is True + assert data['providers']['google'] is True + assert data['providers']['moodle'] is False + assert data['google_client_id'] == 'google-test-id-123' + +def test_google_auth_domain_enforcement(client, init_database): + SystemSetting.set_value('auth_google_enabled', 'true') + SystemSetting.set_value('google_allowed_domains', 'unicaba.edu.ar,lasalle.edu.ar') + + # Domain rejected (e.g., hacker@gmail.com) + res_bad = client.post('/api/v1/auth/google', json={ + 'email': 'hacker@gmail.com', + 'name': 'Hacker Unauthorized', + 'domain': 'gmail.com' + }) + assert res_bad.status_code == 403 + assert "no está autorizado" in res_bad.get_json()['message'] + + # Domain accepted (docente@unicaba.edu.ar) + res_good = client.post('/api/v1/auth/google', json={ + 'email': 'docente.nuevo@unicaba.edu.ar', + 'name': 'Docente Nuevo', + 'domain': 'unicaba.edu.ar' + }) + assert res_good.status_code == 200 + data_good = res_good.get_json() + assert 'access_token' in data_good + assert data_good['user']['email'] == 'docente.nuevo@unicaba.edu.ar' + assert data_good['user']['role'] == 'Docente' diff --git a/frontend/src/routes/admin.js b/frontend/src/routes/admin.js index c65a687..b023c8b 100644 --- a/frontend/src/routes/admin.js +++ b/frontend/src/routes/admin.js @@ -1233,4 +1233,31 @@ const handleAuditLogs = async (req, res) => { router.get(['/audit_logs', '/audit-logs', '/audit'], handleAuditLogs); +// ─── 11. CONFIGURACIÓN GLOBAL & MOODLE SYNC ──────────────────────────────────── + +const handleGlobalSettings = async (req, res) => { + let settings = { + smtp: { host: 'smtp.gmail.com', port: 587, security: 'tls' }, + auth_providers: { local: true, google: false, moodle: false }, + google_oauth: { allowed_domains: 'unicaba.edu.ar', callback_url: '/auth/google/callback' }, + moodle: { server_url: 'http://10.0.0.207/moodle', auto_sync_enabled: true, sync_interval_minutes: 15 } + }; + + try { + const resp = await req.apiClient.get('/admin/settings/all'); + if (resp.data && resp.data.data) { + settings = resp.data.data; + } + } catch (e) { + console.warn('Global settings fetch notice:', e.message); + } + + res.render('admin/settings/global_config', { + title: 'Configuración Global del Sistema - Admin Edu-Space', + settings + }); +}; + +router.get(['/settings', '/global-config', '/config'], handleGlobalSettings); + module.exports = router; diff --git a/frontend/src/routes/auth.js b/frontend/src/routes/auth.js index 8fc57fa..2c19e3e 100644 --- a/frontend/src/routes/auth.js +++ b/frontend/src/routes/auth.js @@ -2,8 +2,24 @@ const express = require('express'); const router = express.Router(); const apiClient = require('../services/apiClient'); -router.get('/login', (req, res) => { - res.render('auth/login', { error: null }); +router.get('/login', async (req, res) => { + let providers = { local: true, google: false, moodle: false }; + let google_client_id = ''; + try { + const resp = await apiClient.get('/auth/providers'); + if (resp.data && resp.data.providers) { + providers = resp.data.providers; + google_client_id = resp.data.google_client_id || ''; + } + } catch (e) { + // Fallback default + } + + res.render('auth/login', { + error: req.query.error || null, + providers, + google_client_id + }); }); router.post('/login', async (req, res) => { @@ -20,7 +36,6 @@ router.post('/login', async (req, res) => { const token = response.data.access_token; if (token) { - // Guardamos el JWT en una cookie httpOnly res.cookie('auth_token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', @@ -34,7 +49,59 @@ router.post('/login', async (req, res) => { } catch (error) { console.error('Login error:', error.response?.data || error.message); const errorMsg = error.response?.data?.message || 'Error en el servidor de autenticación'; - res.render('auth/login', { error: errorMsg }); + res.render('auth/login', { + error: errorMsg, + providers: { local: true, google: false, moodle: false } + }); + } +}); + +// Proxy para Login Delegado Moodle +router.post('/moodle', async (req, res) => { + try { + const { username, password } = req.body; + const response = await apiClient.post('/auth/moodle', { username, password }); + const token = response.data.access_token; + + if (token) { + res.cookie('auth_token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 24 * 60 * 60 * 1000 + }); + return res.redirect('/dashboard'); + } + res.redirect('/auth/login?error=moodle_auth_failed'); + } catch (error) { + console.error('Moodle auth error:', error.response?.data || error.message); + const errorMsg = encodeURIComponent(error.response?.data?.message || 'Error de autenticación en Moodle.'); + res.redirect(`/auth/login?error=${errorMsg}`); + } +}); + +// Proxy para Google OAuth +router.post('/google', async (req, res) => { + try { + const response = await apiClient.post('/auth/google', req.body); + const token = response.data.access_token; + + if (token) { + res.cookie('auth_token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 24 * 60 * 60 * 1000 + }); + return res.json({ success: true, redirect: '/dashboard' }); + } + return res.status(401).json({ success: false, message: 'Fallo al autenticar con Google.' }); + } catch (error) { + console.error('Google auth error:', error.response?.data || error.message); + return res.status(error.response?.status || 500).json({ + success: false, + message: error.response?.data?.message || 'Error en autenticación Google Workspace.' + }); } }); diff --git a/frontend/views/admin/settings/global_config.html b/frontend/views/admin/settings/global_config.html new file mode 100644 index 0000000..e64d8e1 --- /dev/null +++ b/frontend/views/admin/settings/global_config.html @@ -0,0 +1,777 @@ +{% extends "base.html" %} + +{% block title %}Configuración Global del Sistema - Admin Edu-Space{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+
+

+ + Panel de Configuración Global +

+

Gestión centralizada de Servidor de Correo SMTP, Métodos de Autenticación SSO y Sincronización Moodle 4.1

+
+
+ + + Volver + +
+
+ + +
+ + +
+
+ +
+ +
+
+ + + + +
+
+
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ +
+ + + +
+ Almacenada con cifrado simétrico Fernet. Ingrese nuevo valor solo si desea cambiarla. +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ +
+
+
+ Instrucciones de Correo +
+

Este módulo administra el transporte dinámico para el envío de correos transaccionales:

+
    +
  • Notificación de asignación de comisiones a profesores.
  • +
  • Convocatorias a mesas de exámenes finales.
  • +
  • Alertas de auditoría e incidentes de seguridad.
  • +
+
+ + Google Workspace: Use contraseñas de aplicación (App Passwords) generadas desde la consola de Google si tiene 2FA activo. +
+
+
+
+
+ + + + +
+
+
+
+
+ Métodos de Autenticación Habilitados +
+
+
+
+
Login Nativo con Contraseña
+ Permite inicio de sesión local. Si se desactiva, queda reservado exclusivamente para rol ADMIN como contingencia. +
+
+ +
+
+ +
+
+
Google OAuth 2.0 (Google Workspace)
+ Inicio de sesión con cuentas institucionales @unicaba.edu.ar mediante SSO. +
+
+ +
+
+ +
+
+
Moodle SSO Delegado
+ Valida credenciales contra el servidor de Moodle 4.1 y aprovisiona perfil. +
+
+ +
+
+ + +
+
+
+ +
+
+
+ Parámetros Google OAuth 2.0 +
+
+
+ + +
+
+ +
+ + +
+
+
+ + + Restricción arquitectónica estricta similar al módulo de AlumnosLS. +
+
+ + +
+ + +
+
+
+
+
+ + + + +
+
+
+
+
+ Conexión Web Services Moodle 4.1 +
+
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
+
+
+
+ +
+
+
+ Monitor de Cola & Tolerancia a Fallos +
+

+ Las creaciones de usuarios, matriculaciones y asignaciones no bloquean a Edu-Space. Se encolan y reintentan con Exponential Backoff. Si superan los intentos máximos, van a la Dead Letter Queue (DLQ). +

+ +
+
+
+
0
+ Pendientes +
+
+
+
+
0
+ Reintentando +
+
+
+
+
0
+ Fallidas (DLQ) +
+
+
+ +
+ + +
+
+
+
+ + +
+
+ Tareas Recientes de Sincronización +
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
IDAcciónEntidadEstadoIntentosDetalle / ErrorFechaAcciones
Cargando tareas de sincronización...
+
+
+
+ +
+
+
+
+ + + + + +{% endblock %} diff --git a/frontend/views/auth/login.html b/frontend/views/auth/login.html index 277b3f2..325e7c3 100644 --- a/frontend/views/auth/login.html +++ b/frontend/views/auth/login.html @@ -22,6 +22,59 @@ +
@@ -32,7 +85,6 @@ -
@@ -44,12 +96,60 @@ {% if error %} {% endif %} -
+ + {% if providers and (providers.google or providers.moodle) %} +
+ {% if providers.google %} + + {% endif %} + + {% if providers.moodle %} + + +
+
+ +
+ + +
+
+ + +
+ + +
+
+ {% endif %} +
+ + {% if not providers or providers.local %} +
o con credenciales locales
+ {% endif %} + {% endif %} + + + {% if not providers or providers.local %} +
@@ -81,6 +181,14 @@
+ {% else %} +
+ El acceso con contraseña local está deshabilitado para usuarios generales. + +
+ {% endif %}
@@ -117,35 +225,90 @@
+ + + \ No newline at end of file diff --git a/frontend/views/base.html b/frontend/views/base.html index b279a47..ddbd3ea 100644 --- a/frontend/views/base.html +++ b/frontend/views/base.html @@ -570,6 +570,7 @@
  • Tipos Hitos
  • Sincro Sheets
  • Auditoría
  • +
  • Config. Global