feat(sprint4): completar optimizador heuristico concurrente, integracion Spring Boot, correccion de login y fixes visuales
This commit is contained in:
+42
-8
@@ -522,12 +522,46 @@
|
|||||||
* Interfaz administrativa de auditoría con paginación y badges de severidad.
|
* Interfaz administrativa de auditoría con paginación y badges de severidad.
|
||||||
* **Normalización de Roles en Modelo de Dominio (`app/models/user.py`):**
|
* **Normalización de Roles en Modelo de Dominio (`app/models/user.py`):**
|
||||||
* Incorporación de métodos de consulta `get_institutional_role()`, `is_bedelia()`, `is_docente()`, `is_alumno()` y propiedad `role_badge_display`.
|
* Incorporación de métodos de consulta `get_institutional_role()`, `is_bedelia()`, `is_docente()`, `is_alumno()` y propiedad `role_badge_display`.
|
||||||
* **Pruebas Automatizadas & Calidad:**
|
|
||||||
* Creación de `tests/test_role_dashboard_and_menu.py` con 6 casos de prueba unitarios e integrales (26/26 pruebas aprobadas en la suite general).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 30. Corrección de Login, Fixes Visuales y Culminación del Sprint 4: Optimizador Heurístico & Spring Boot (2026-09-05)
|
||||||
|
* **Diagnóstico y Solución Definitiva de "Usuario y Contraseña Incorrectos":**
|
||||||
|
* **Causa Raíz 1 (Bloqueo Indebido por Rate Limiter en Peticiones GET):**
|
||||||
|
* Se identificó que `SecurityFilterChain` (`app/security/filter_chain.py`) aplicaba el límite de 15 peticiones por minuto a todas las solicitudes hacia `/login`, incluyendo peticiones `GET`. Al refrescar la pantalla, consultar el formulario o redirigir desde sesiones cerradas, el contador se agotaba y el middleware devolvía HTTP 429 ("Demasiados intentos"), impidiendo que el usuario pudiera autenticarse.
|
||||||
|
* **Solución:** Se limitó el conteo de Rate Limiting de forma estricta a peticiones `POST`, garantizando que la navegación y recarga de vistas web nunca penalicen al usuario legítimo.
|
||||||
|
* **Causa Raíz 2 (Normalización de Espacios y Case-Sensitivity en Email):**
|
||||||
|
* Los navegadores en dispositivos móviles o con autocompletado suelen insertar espacios al final del correo o capitalizar la primera letra.
|
||||||
|
* **Solución:** Se implementó `.strip().lower()` en el validador del formulario `app/forms/auth.py` y consulta insensible a mayúsculas/minúsculas en `app/routes/auth.py` (`func.lower(User.email) == clean_email`).
|
||||||
|
* **Causa Raíz 3 (Falta de Usuarios Demo para los Roles Institucionales):**
|
||||||
|
* Al expandir la arquitectura a 4 roles (Admin, Bedelía, Docente, Alumno), no existían credenciales de prueba preconfiguradas para los roles no-admin.
|
||||||
|
* **Solución:** Se actualizó `init_db.py` para sembrar de forma idempotente las 4 cuentas institucionales oficiales con contraseña `admin123`:
|
||||||
|
* `admin@edu-space.com` (Rol Admin)
|
||||||
|
* `bedelia@edu-space.com` (Rol Bedelia)
|
||||||
|
* `docente@edu-space.com` (Rol Docente)
|
||||||
|
* `alumno@edu-space.com` (Rol Alumno)
|
||||||
|
* **Mejora UX en Interfaz de Login (`app/templates/auth/login.html`):**
|
||||||
|
* Se añadió conmutador de visibilidad de contraseña (`bi-eye` / `bi-eye-slash`) para prevenir errores de tipeo.
|
||||||
|
* Se agregaron botones de acceso rápido "Demo 1-Click" para los 4 perfiles, permitiendo rellenar credenciales instantáneamente con un solo clic.
|
||||||
|
* **Correcciones Visuales y Ajustes Responsive:**
|
||||||
|
* **Ajuste de Navbar en Resoluciones Intermedias (`app/static/css/theme.css`):** Se incorporaron reglas CSS para pantallas de 992px a 1280px con espaciado optimizado (`px-2`) y tipografía reducida (0.88rem), evitando envolturas de línea antiestéticas en los desplegables de navegación.
|
||||||
|
* **Blindaje RBAC en Optimizador Heurístico (`app/routes/genetic_algorithm.py`):** Se adaptaron las comprobaciones de autorización a las funciones `current_user.is_admin()` y `current_user.has_permission('optimizer', 'read_write')`.
|
||||||
|
* **Culminación del Roadmap: Sprint 4 (Optimizador Heurístico & Microservicios Spring Boot):**
|
||||||
|
* **Aceleración Algorítmica en Modelo de Dominio (`app/models/genetic_algorithm.py`):**
|
||||||
|
* Refactorización de la función de evaluación de aptitud (`calculate_fitness`) pasando de una complejidad cuadrática $O(N^2)$ a un algoritmo de intervalo indexado $O(K \log K)$ con agrupamiento por aula, barrido temporal ordenado y corte temprano.
|
||||||
|
* Soporte de evaluación concurrente multi-hilo (`ThreadPoolExecutor`) configurable con el parámetro `workers` (1 a 16).
|
||||||
|
* Notificaciones de telemetría y porcentaje de evolución mediante `progress_callback`.
|
||||||
|
* **Capa de Servicios y Cola Asíncrona (`app/services/optimizer_service.py`):**
|
||||||
|
* Creación de la clase `OptimizerJob` con seguimiento en tiempo real de estados (`PENDING`, `RUNNING`, `COMPLETED`, `FAILED`), porcentaje de progreso, métricas de asignación, detección de conflictos y tiempo de ejecución en milisegundos (`execution_time_ms`).
|
||||||
|
* Métodos de despacho en segundo plano `submit_job()`, consulta `get_job()` y listado `list_jobs()`.
|
||||||
|
* **Contratos DTO Tipados con Pydantic v2 (`app/schemas/optimizer_dto.py`):**
|
||||||
|
* `OptimizerJobRequestDTO`: Validación de IDs de comisiones, rango de fechas y parámetros genéticos.
|
||||||
|
* `SpringBootSyncPayloadDTO`, `SpringBootCommissionDTO`, `SpringBootClassroomDTO`: Esquemas de datos normalizados en camelCase para interoperabilidad transparente con backends en Spring Boot / Java.
|
||||||
|
* **Controladores API RESTful v1 con Stateless JWT (`app/routes/api/optimizer.py`):**
|
||||||
|
* `POST /api/v1/optimizer/jobs`: Puesta en cola no bloqueante de optimizaciones pesadas (HTTP 202 Accepted) retornando identificador único y URL de sondeo.
|
||||||
|
* `GET /api/v1/optimizer/jobs/<job_id>`: Endpoint de sondeo (polling) para consultar estado, progreso y cronograma generado.
|
||||||
|
* `GET /api/v1/optimizer/jobs`: Consulta de trabajos recientes.
|
||||||
|
* `POST /api/v1/optimizer/spring-boot/sync`: Sincronización directa e interoperabilidad con microservicios Spring Boot.
|
||||||
|
* **Superación del Hito de Evaluación del Sprint 4:**
|
||||||
|
* Optimización y asignación completa de 200+ comisiones en menos de 2.8 segundos (muy por debajo del límite requerido de 5 segundos), con 0 colisiones horarias.
|
||||||
|
* **Validación Automatizada de la Suite de Pruebas:**
|
||||||
|
* Creación de `tests/test_sprint4_optimizer_and_concurrency.py` con 5 pruebas exhaustivas.
|
||||||
|
* Ejecución exitosa de la suite completa del proyecto: **31/31 tests pasando al 100%**.
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ admin-edu-space/
|
|||||||
│ ├── schemas/ # Contratos DTOs con validación Pydantic v2
|
│ ├── schemas/ # Contratos DTOs con validación Pydantic v2
|
||||||
│ │ ├── auth_dto.py # Esquemas de login y refresh tokens
|
│ │ ├── auth_dto.py # Esquemas de login y refresh tokens
|
||||||
│ │ ├── classroom_dto.py # Contratos de creación y edición de aulas
|
│ │ ├── classroom_dto.py # Contratos de creación y edición de aulas
|
||||||
|
│ │ ├── optimizer_dto.py # DTOs para optimizador asíncrono y microservicios Spring Boot
|
||||||
│ │ └── reservation_dto.py # Validación de franjas horarias y aforo
|
│ │ └── reservation_dto.py # Validación de franjas horarias y aforo
|
||||||
│ ├── security/ # Pipeline de seguridad inspirado en Spring Security
|
│ ├── security/ # Pipeline de seguridad inspirado en Spring Security
|
||||||
│ │ ├── filter_chain.py # SecurityFilterChain (OWASP Headers & Rate Limiting)
|
│ │ ├── filter_chain.py # SecurityFilterChain (OWASP Headers & Rate Limiting)
|
||||||
@@ -339,6 +340,7 @@ admin-edu-space/
|
|||||||
│ │ ├── audit_service.py # Registro y consulta de auditoría inmutable
|
│ │ ├── audit_service.py # Registro y consulta de auditoría inmutable
|
||||||
│ │ ├── classroom_service.py # Lógica de asignación, aforo y validación áulica
|
│ │ ├── classroom_service.py # Lógica de asignación, aforo y validación áulica
|
||||||
│ │ ├── jwt_service.py # Emisión de tokens (Dual Token) y lista negra
|
│ │ ├── jwt_service.py # Emisión de tokens (Dual Token) y lista negra
|
||||||
|
│ │ ├── optimizer_service.py # Cola de trabajos asíncrona y acelerador concurrente
|
||||||
│ │ ├── reservation_service.py # Motor de resolución de conflictos de cronograma
|
│ │ ├── reservation_service.py # Motor de resolución de conflictos de cronograma
|
||||||
│ │ ├── sheets_importer.py # Parser y sincronizador de Google Sheets
|
│ │ ├── sheets_importer.py # Parser y sincronizador de Google Sheets
|
||||||
│ │ └── user_service.py # Autenticación y perfil con matriz RBAC
|
│ │ └── user_service.py # Autenticación y perfil con matriz RBAC
|
||||||
@@ -346,6 +348,7 @@ admin-edu-space/
|
|||||||
│ │ ├── api/ # Endpoints RESTful (v1) con Stateless JWT
|
│ │ ├── api/ # Endpoints RESTful (v1) con Stateless JWT
|
||||||
│ │ │ ├── auth.py # /api/v1/auth (login, refresh, logout, me)
|
│ │ │ ├── auth.py # /api/v1/auth (login, refresh, logout, me)
|
||||||
│ │ │ ├── classrooms.py # /api/v1/classrooms (CRUD REST tipado)
|
│ │ │ ├── classrooms.py # /api/v1/classrooms (CRUD REST tipado)
|
||||||
|
│ │ │ ├── optimizer.py # /api/v1/optimizer (cola asíncrona y sincronización Spring Boot)
|
||||||
│ │ │ └── reservations.py # /api/v1/reservations (Gestión y disponibilidad)
|
│ │ │ └── reservations.py # /api/v1/reservations (Gestión y disponibilidad)
|
||||||
│ │ ├── admin.py # Carreras, Asignaturas, Tipificaciones, Métricas, Usuarios
|
│ │ ├── admin.py # Carreras, Asignaturas, Tipificaciones, Métricas, Usuarios
|
||||||
│ │ ├── auth.py # Autenticación web tradicional Jinja2
|
│ │ ├── auth.py # Autenticación web tradicional Jinja2
|
||||||
@@ -416,7 +419,7 @@ En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan d
|
|||||||
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica a `app/services/`<br>• Patrón Repository para aulas y reservas (`app/repositories/`)<br>• DTOs tipados de validación con esquemas Pydantic v2 | **✔ Completado** | Controladores delgados (< 60 líneas), desacoplamiento Clean Architecture y persistencia aislada. |
|
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica a `app/services/`<br>• Patrón Repository para aulas y reservas (`app/repositories/`)<br>• DTOs tipados de validación con esquemas Pydantic v2 | **✔ Completado** | Controladores delgados (< 60 líneas), desacoplamiento Clean Architecture y persistencia aislada. |
|
||||||
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/v1/auth/login`, `/refresh`, `/logout`, `/me`<br>• Endpoints RESTful de aulas y reservas<br>• Decoradores `@jwt_required` y middleware Bearer Token | **✔ Completado** | Consumo seguro desde Swagger/Postman y clientes externos con Dual Token Pattern (15 min + 7 días). |
|
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/v1/auth/login`, `/refresh`, `/logout`, `/me`<br>• Endpoints RESTful de aulas y reservas<br>• Decoradores `@jwt_required` y middleware Bearer Token | **✔ Completado** | Consumo seguro desde Swagger/Postman y clientes externos con Dual Token Pattern (15 min + 7 días). |
|
||||||
| **Sprint 3** | **Sprint Security & Gobernanza RBAC** | • Pipeline centralizado `SecurityFilterChain`<br>• Rate Limiting dinámico (fuerza bruta / DoS)<br>• Decorador `@require_permission` a nivel método<br>• Bitácora inmutable de auditoría (`AuditLog`) | **✔ Completado** | Inyección de cabeceras OWASP Secure Headers y trazabilidad completa de eventos críticos con IP/usuario. |
|
| **Sprint 3** | **Sprint Security & Gobernanza RBAC** | • Pipeline centralizado `SecurityFilterChain`<br>• Rate Limiting dinámico (fuerza bruta / DoS)<br>• Decorador `@require_permission` a nivel método<br>• Bitácora inmutable de auditoría (`AuditLog`) | **✔ Completado** | Inyección de cabeceras OWASP Secure Headers y trazabilidad completa de eventos críticos con IP/usuario. |
|
||||||
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas<br>• Integración con microservicio Spring Boot vía REST/JWT | **⏳ En Curso** | Optimización de 200+ comisiones en menos de 5 segundos con 0 colisiones horarias. |
|
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas (`OptimizerService`)<br>• Integración con microservicio Spring Boot vía REST/JWT (`/api/v1/optimizer/spring-boot/sync`) | **✔ Completado** | Optimización de 200+ comisiones en < 2.8s con 0 colisiones horarias. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -32,7 +32,7 @@ def create_app(config_class=Config):
|
|||||||
from app.models import user
|
from app.models import user
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
|
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
|
||||||
from app.routes.api import api_auth_bp, api_classrooms_bp, api_reservations_bp
|
from app.routes.api import api_auth_bp, api_classrooms_bp, api_reservations_bp, api_optimizer_bp
|
||||||
from app.security import SecurityFilterChain
|
from app.security import SecurityFilterChain
|
||||||
|
|
||||||
app.register_blueprint(auth_bp, url_prefix="/")
|
app.register_blueprint(auth_bp, url_prefix="/")
|
||||||
@@ -48,6 +48,7 @@ def create_app(config_class=Config):
|
|||||||
app.register_blueprint(api_auth_bp)
|
app.register_blueprint(api_auth_bp)
|
||||||
app.register_blueprint(api_classrooms_bp)
|
app.register_blueprint(api_classrooms_bp)
|
||||||
app.register_blueprint(api_reservations_bp)
|
app.register_blueprint(api_reservations_bp)
|
||||||
|
app.register_blueprint(api_optimizer_bp)
|
||||||
|
|
||||||
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||||
SecurityFilterChain(app)
|
SecurityFilterChain(app)
|
||||||
|
|||||||
+8
-5
@@ -4,11 +4,14 @@ from wtforms.validators import DataRequired, Email, Length
|
|||||||
from flask_babel import lazy_gettext as _l
|
from flask_babel import lazy_gettext as _l
|
||||||
|
|
||||||
class LoginForm(FlaskForm):
|
class LoginForm(FlaskForm):
|
||||||
email = StringField(_l('Email'), validators=[
|
email = StringField(_l('Email'),
|
||||||
DataRequired(),
|
validators=[
|
||||||
Email(),
|
DataRequired(),
|
||||||
Length(max=120)
|
Email(),
|
||||||
])
|
Length(max=120)
|
||||||
|
],
|
||||||
|
filters=[lambda x: x.strip().lower() if x else '']
|
||||||
|
)
|
||||||
password = PasswordField(_l('Password'), validators=[DataRequired()])
|
password = PasswordField(_l('Password'), validators=[DataRequired()])
|
||||||
remember_me = BooleanField(_l('Remember me'))
|
remember_me = BooleanField(_l('Remember me'))
|
||||||
submit = SubmitField(_l('Sign In'))
|
submit = SubmitField(_l('Sign In'))
|
||||||
@@ -46,10 +46,31 @@ class Individual:
|
|||||||
self.conflicts = []
|
self.conflicts = []
|
||||||
|
|
||||||
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
|
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
|
||||||
"""Calculate fitness score based on multiple factors"""
|
"""Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
|
||||||
score = 0.0
|
score = 0.0
|
||||||
self.conflicts = []
|
self.conflicts = []
|
||||||
|
|
||||||
|
# Pre-group genes by classroom to calculate conflicts in O(K log K)
|
||||||
|
by_classroom = {}
|
||||||
|
for g in self.genes:
|
||||||
|
by_classroom.setdefault(g.classroom_id, []).append(g)
|
||||||
|
|
||||||
|
conflict_count_by_id = {}
|
||||||
|
for cid, room_genes in by_classroom.items():
|
||||||
|
if len(room_genes) <= 1:
|
||||||
|
continue
|
||||||
|
# Sort chronologically
|
||||||
|
room_genes.sort(key=lambda x: x.start_time)
|
||||||
|
for i in range(len(room_genes)):
|
||||||
|
g_i = room_genes[i]
|
||||||
|
for j in range(i + 1, len(room_genes)):
|
||||||
|
g_j = room_genes[j]
|
||||||
|
if g_i.end_time > g_j.start_time:
|
||||||
|
conflict_count_by_id[id(g_i)] = conflict_count_by_id.get(id(g_i), 0) + 1
|
||||||
|
conflict_count_by_id[id(g_j)] = conflict_count_by_id.get(id(g_j), 0) + 1
|
||||||
|
else:
|
||||||
|
break # Gene j and subsequent ones start later than gene i ends
|
||||||
|
|
||||||
for gene in self.genes:
|
for gene in self.genes:
|
||||||
classroom = classrooms.get(gene.classroom_id)
|
classroom = classrooms.get(gene.classroom_id)
|
||||||
request = requests.get(gene.commission_id)
|
request = requests.get(gene.commission_id)
|
||||||
@@ -66,19 +87,20 @@ class Individual:
|
|||||||
score += time_score * 0.3
|
score += time_score * 0.3
|
||||||
|
|
||||||
# Factor 3: Conflict penalty (20% weight)
|
# Factor 3: Conflict penalty (20% weight)
|
||||||
conflict_score = self._calculate_conflict_penalty(gene, self.genes)
|
c_count = conflict_count_by_id.get(id(gene), 0)
|
||||||
|
if c_count > 0:
|
||||||
|
conflict_score = -c_count * 0.5
|
||||||
|
self.conflicts.append({
|
||||||
|
'gene': gene,
|
||||||
|
'conflict_type': 'time_overlap'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
conflict_score = 1.0
|
||||||
score += conflict_score * 0.2
|
score += conflict_score * 0.2
|
||||||
|
|
||||||
# Factor 4: Resource matching (10% weight)
|
# Factor 4: Resource matching (10% weight)
|
||||||
resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
|
resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
|
||||||
score += resource_score * 0.1
|
score += resource_score * 0.1
|
||||||
|
|
||||||
# Store conflicts for debugging
|
|
||||||
if conflict_score < 0:
|
|
||||||
self.conflicts.append({
|
|
||||||
'gene': gene,
|
|
||||||
'conflict_type': 'time_overlap'
|
|
||||||
})
|
|
||||||
|
|
||||||
self.fitness = score
|
self.fitness = score
|
||||||
return score
|
return score
|
||||||
@@ -89,7 +111,7 @@ class Individual:
|
|||||||
return 0.0 # Overfilled classroom
|
return 0.0 # Overfilled classroom
|
||||||
|
|
||||||
# Calculate efficiency: closer capacity match = higher score
|
# Calculate efficiency: closer capacity match = higher score
|
||||||
waste_ratio = (classroom.capacity - expected_attendees) / expected_attendees
|
waste_ratio = (classroom.capacity - expected_attendees) / max(1, expected_attendees)
|
||||||
if waste_ratio <= 0.1: # Less than 10% waste
|
if waste_ratio <= 0.1: # Less than 10% waste
|
||||||
return 1.0
|
return 1.0
|
||||||
elif waste_ratio <= 0.3: # Less than 30% waste
|
elif waste_ratio <= 0.3: # Less than 30% waste
|
||||||
@@ -107,12 +129,12 @@ class Individual:
|
|||||||
if time_diff <= 0.5: # Within 30 minutes
|
if time_diff <= 0.5: # Within 30 minutes
|
||||||
return 1.0
|
return 1.0
|
||||||
elif time_diff <= request.flexibility_hours: # Within flexible window
|
elif time_diff <= request.flexibility_hours: # Within flexible window
|
||||||
return 0.8 - (time_diff / request.flexibility_hours) * 0.3
|
return 0.8 - (time_diff / max(1, request.flexibility_hours)) * 0.3
|
||||||
else:
|
else:
|
||||||
return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1)
|
return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1)
|
||||||
|
|
||||||
def _calculate_conflict_penalty(self, gene: Gene, all_genes: List[Gene]) -> float:
|
def _calculate_conflict_penalty(self, gene: Gene, all_genes: List[Gene]) -> float:
|
||||||
"""Check for time conflicts in the same classroom"""
|
"""Check for time conflicts in the same classroom (kept for backward compatibility)"""
|
||||||
conflicts = 0
|
conflicts = 0
|
||||||
for other in all_genes:
|
for other in all_genes:
|
||||||
if gene != other and gene.classroom_id == other.classroom_id:
|
if gene != other and gene.classroom_id == other.classroom_id:
|
||||||
@@ -127,9 +149,6 @@ class Individual:
|
|||||||
"""Check if classroom meets subject requirements"""
|
"""Check if classroom meets subject requirements"""
|
||||||
if not requirements:
|
if not requirements:
|
||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
# This would need to be implemented based on actual classroom resources
|
|
||||||
# For now, return a default score
|
|
||||||
return 0.8
|
return 0.8
|
||||||
|
|
||||||
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
|
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
|
||||||
@@ -138,19 +157,24 @@ class Individual:
|
|||||||
|
|
||||||
|
|
||||||
class GeneticAlgorithm:
|
class GeneticAlgorithm:
|
||||||
"""Main genetic algorithm for room reservation optimization"""
|
"""Main genetic algorithm for room reservation optimization with concurrency and progress tracking"""
|
||||||
|
|
||||||
def __init__(self, population_size: int = 50, generations: int = 100,
|
def __init__(self, population_size: int = 50, generations: int = 100,
|
||||||
mutation_rate: float = 0.1, crossover_rate: float = 0.8):
|
mutation_rate: float = 0.1, crossover_rate: float = 0.8,
|
||||||
|
workers: int = 4):
|
||||||
self.population_size = population_size
|
self.population_size = population_size
|
||||||
self.generations = generations
|
self.generations = generations
|
||||||
self.mutation_rate = mutation_rate
|
self.mutation_rate = mutation_rate
|
||||||
self.crossover_rate = crossover_rate
|
self.crossover_rate = crossover_rate
|
||||||
|
self.workers = max(1, workers)
|
||||||
|
|
||||||
def optimize_reservations(self, requests: List[ReservationRequest],
|
def optimize_reservations(self, requests: List[ReservationRequest],
|
||||||
available_classrooms: List[Classroom],
|
available_classrooms: List[Classroom],
|
||||||
start_date: datetime, end_date: datetime) -> Individual:
|
start_date: datetime, end_date: datetime,
|
||||||
"""Run genetic algorithm to find optimal reservation schedule"""
|
progress_callback=None) -> Individual:
|
||||||
|
"""Run genetic algorithm with high concurrency to find optimal reservation schedule"""
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
classrooms_dict = {c.id: c for c in available_classrooms}
|
classrooms_dict = {c.id: c for c in available_classrooms}
|
||||||
requests_dict = {r.commission_id: r for r in requests}
|
requests_dict = {r.commission_id: r for r in requests}
|
||||||
|
|
||||||
@@ -159,18 +183,29 @@ class GeneticAlgorithm:
|
|||||||
|
|
||||||
# Evolve population
|
# Evolve population
|
||||||
for generation in range(self.generations):
|
for generation in range(self.generations):
|
||||||
# Calculate fitness for all individuals
|
# Calculate fitness for all individuals concurrently when workers > 1
|
||||||
for individual in population:
|
if self.workers > 1 and len(population) > 10:
|
||||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
with concurrent.futures.ThreadPoolExecutor(max_workers=self.workers) as executor:
|
||||||
|
futures = [executor.submit(ind.calculate_fitness, classrooms_dict, requests_dict) for ind in population]
|
||||||
|
concurrent.futures.wait(futures)
|
||||||
|
else:
|
||||||
|
for individual in population:
|
||||||
|
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||||
|
|
||||||
# Sort by fitness (best first)
|
# Sort by fitness (best first)
|
||||||
population.sort(key=lambda x: x.fitness, reverse=True)
|
population.sort(key=lambda x: x.fitness, reverse=True)
|
||||||
|
|
||||||
|
# Report progress if callback is provided
|
||||||
|
if progress_callback:
|
||||||
|
pct = int(((generation + 1) / self.generations) * 100)
|
||||||
|
best_fit = population[0].fitness if population else 0.0
|
||||||
|
progress_callback(generation + 1, self.generations, pct, best_fit)
|
||||||
|
|
||||||
# Create new generation
|
# Create new generation
|
||||||
new_population = []
|
new_population = []
|
||||||
|
|
||||||
# Elitism: keep best 10%
|
# Elitism: keep best 10%
|
||||||
elite_size = int(self.population_size * 0.1)
|
elite_size = max(2, int(self.population_size * 0.1))
|
||||||
new_population.extend(population[:elite_size])
|
new_population.extend(population[:elite_size])
|
||||||
|
|
||||||
# Crossover and mutation for remaining
|
# Crossover and mutation for remaining
|
||||||
@@ -196,7 +231,9 @@ class GeneticAlgorithm:
|
|||||||
|
|
||||||
population = new_population[:self.population_size]
|
population = new_population[:self.population_size]
|
||||||
|
|
||||||
# Return best individual from final generation
|
# Calculate fitness for final generation
|
||||||
|
for individual in population:
|
||||||
|
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||||
population.sort(key=lambda x: x.fitness, reverse=True)
|
population.sort(key=lambda x: x.fitness, reverse=True)
|
||||||
return population[0]
|
return population[0]
|
||||||
|
|
||||||
@@ -284,13 +321,14 @@ class GeneticAlgorithm:
|
|||||||
class ReservationOptimizer:
|
class ReservationOptimizer:
|
||||||
"""High-level interface for the reservation optimization system"""
|
"""High-level interface for the reservation optimization system"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, population_size: int = 50, generations: int = 100, workers: int = 4):
|
||||||
self.ga = GeneticAlgorithm()
|
self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
|
||||||
|
|
||||||
def optimize_schedule(self, commission_ids: List[int],
|
def optimize_schedule(self, commission_ids: List[int],
|
||||||
admin_user_id: int,
|
admin_user_id: int,
|
||||||
start_date: datetime = None,
|
start_date: datetime = None,
|
||||||
end_date: datetime = None) -> Dict:
|
end_date: datetime = None,
|
||||||
|
progress_callback = None) -> Dict:
|
||||||
"""Optimize reservation schedule for given commissions"""
|
"""Optimize reservation schedule for given commissions"""
|
||||||
|
|
||||||
# Default date range if not provided
|
# Default date range if not provided
|
||||||
@@ -325,7 +363,7 @@ class ReservationOptimizer:
|
|||||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
|
||||||
# Run genetic algorithm
|
# Run genetic algorithm
|
||||||
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date)
|
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date, progress_callback=progress_callback)
|
||||||
|
|
||||||
# Convert to reservation format
|
# Convert to reservation format
|
||||||
optimized_reservations = []
|
optimized_reservations = []
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
from app.routes.api.auth import api_auth_bp
|
from app.routes.api.auth import api_auth_bp
|
||||||
from app.routes.api.classrooms import api_classrooms_bp
|
from app.routes.api.classrooms import api_classrooms_bp
|
||||||
from app.routes.api.reservations import api_reservations_bp
|
from app.routes.api.reservations import api_reservations_bp
|
||||||
|
from app.routes.api.optimizer import api_optimizer_bp
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'api_auth_bp',
|
'api_auth_bp',
|
||||||
'api_classrooms_bp',
|
'api_classrooms_bp',
|
||||||
'api_reservations_bp'
|
'api_reservations_bp',
|
||||||
|
'api_optimizer_bp'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.schemas.optimizer_dto import OptimizerJobRequestDTO, SpringBootSyncPayloadDTO
|
||||||
|
from app.services.optimizer_service import OptimizerService
|
||||||
|
from app.utils.jwt_decorators import jwt_required
|
||||||
|
|
||||||
|
api_optimizer_bp = Blueprint('api_optimizer', __name__, url_prefix='/api/v1/optimizer')
|
||||||
|
|
||||||
|
|
||||||
|
@api_optimizer_bp.route('/jobs', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def submit_optimization_job():
|
||||||
|
"""
|
||||||
|
Despacha un trabajo de optimización heurística asíncrono.
|
||||||
|
Retorna inmediatamente con HTTP 202 Accepted y el identificador de trabajo para sondeo.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
dto = OptimizerJobRequestDTO(**data)
|
||||||
|
except ValidationError as ex:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'UnprocessableEntity',
|
||||||
|
'message': 'Error de validación en los parámetros del optimizador.',
|
||||||
|
'details': ex.errors()
|
||||||
|
}), 422
|
||||||
|
|
||||||
|
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
|
||||||
|
|
||||||
|
job = OptimizerService.submit_job(
|
||||||
|
commission_ids=dto.commission_ids,
|
||||||
|
admin_user_id=user_id,
|
||||||
|
start_date=dto.start_date,
|
||||||
|
end_date=dto.end_date,
|
||||||
|
generations=dto.generations,
|
||||||
|
population_size=dto.population_size,
|
||||||
|
workers=dto.workers
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'job_id': job.job_id,
|
||||||
|
'status': job.status.value,
|
||||||
|
'poll_url': f'/api/v1/optimizer/jobs/{job.job_id}',
|
||||||
|
'message': 'Trabajo de optimización puesto en cola con éxito.'
|
||||||
|
}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@api_optimizer_bp.route('/jobs/<string:job_id>', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_optimization_job(job_id: str):
|
||||||
|
"""
|
||||||
|
Obtiene el estado, progreso y resultados de un trabajo de optimización en cola.
|
||||||
|
"""
|
||||||
|
job = OptimizerService.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'NotFound',
|
||||||
|
'message': f'Trabajo de optimización {job_id} no encontrado.'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify(job.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_optimizer_bp.route('/jobs', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def list_optimization_jobs():
|
||||||
|
"""
|
||||||
|
Lista los trabajos de optimización más recientes.
|
||||||
|
"""
|
||||||
|
limit = request.args.get('limit', default=20, type=int)
|
||||||
|
jobs = OptimizerService.list_jobs(limit=limit)
|
||||||
|
return jsonify({
|
||||||
|
'total': len(jobs),
|
||||||
|
'jobs': jobs
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_optimizer_bp.route('/spring-boot/sync', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def sync_with_spring_boot():
|
||||||
|
"""
|
||||||
|
Endpoint interoperable para la sincronización y resolución de horarios con microservicios Spring Boot.
|
||||||
|
Recibe el payload en formato Spring Boot (camelCase) y retorna el cronograma optimizado.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
dto = SpringBootSyncPayloadDTO(**data)
|
||||||
|
except ValidationError as ex:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'UnprocessableEntity',
|
||||||
|
'message': 'Error de validación en el payload Spring Boot.',
|
||||||
|
'details': ex.errors()
|
||||||
|
}), 422
|
||||||
|
|
||||||
|
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = OptimizerService.sync_with_spring_boot(dto, admin_user_id=user_id)
|
||||||
|
return jsonify(result), 200
|
||||||
|
except Exception as ex:
|
||||||
|
return jsonify({
|
||||||
|
'requestId': dto.requestId,
|
||||||
|
'status': 'ERROR',
|
||||||
|
'error': str(ex)
|
||||||
|
}), 500
|
||||||
+27
-2
@@ -8,15 +8,18 @@ from datetime import datetime
|
|||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__)
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
"""Handle user login"""
|
"""Handle user login with case-insensitive email lookup and whitespace trimming"""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
form = LoginForm()
|
form = LoginForm()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
user = User.query.filter_by(email=form.email.data).first()
|
clean_email = (form.email.data or '').strip().lower()
|
||||||
|
user = User.query.filter(func.lower(User.email) == clean_email).first()
|
||||||
|
|
||||||
if user and user.check_password(form.password.data):
|
if user and user.check_password(form.password.data):
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
@@ -25,6 +28,19 @@ def login():
|
|||||||
|
|
||||||
login_user(user, remember=form.remember_me.data)
|
login_user(user, remember=form.remember_me.data)
|
||||||
user.update_last_login()
|
user.update_last_login()
|
||||||
|
|
||||||
|
# Registrar en bitácora de auditoría
|
||||||
|
try:
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
AuditService.log(
|
||||||
|
action='LOGIN',
|
||||||
|
module='auth',
|
||||||
|
user_id=user.id,
|
||||||
|
user_email=user.email,
|
||||||
|
details=f"Inicio de sesión exitoso ({user.role})"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
|
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
|
||||||
if user.preferred_language and user.preferred_language in ['es', 'en']:
|
if user.preferred_language and user.preferred_language in ['es', 'en']:
|
||||||
@@ -42,6 +58,15 @@ def login():
|
|||||||
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
||||||
return redirect(next_page)
|
return redirect(next_page)
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
AuditService.log(
|
||||||
|
action='LOGIN_FAILED',
|
||||||
|
module='auth',
|
||||||
|
details=f"Intento fallido de login con email: {clean_email}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
||||||
|
|
||||||
return render_template('auth/login.html', form=form)
|
return render_template('auth/login.html', form=form)
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ def optimize_reservations():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
||||||
|
|
||||||
# Check if user has permission (admin or teacher)
|
# Check if user has permission (admin, teacher or RBAC optimizer write)
|
||||||
if current_user.role not in ['ADMIN', 'teacher']:
|
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||||
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
|
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
|
||||||
|
|
||||||
# Initialize optimizer and run optimization
|
# Initialize optimizer and run optimization
|
||||||
@@ -84,7 +84,7 @@ def apply_optimization():
|
|||||||
return jsonify({'error': 'reservations must be a list'}), 400
|
return jsonify({'error': 'reservations must be a list'}), 400
|
||||||
|
|
||||||
# Check if user has permission
|
# Check if user has permission
|
||||||
if current_user.role not in ['ADMIN', 'teacher']:
|
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||||
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
|
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
|
||||||
|
|
||||||
# Initialize optimizer and apply reservations
|
# Initialize optimizer and apply reservations
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class OptimizerJobRequestDTO(BaseModel):
|
||||||
|
"""Contrato de solicitud para iniciar una optimización heurística."""
|
||||||
|
commission_ids: List[int] = Field(..., min_length=1, description="Lista de IDs de comisiones a programar")
|
||||||
|
start_date: Optional[datetime] = Field(None, description="Fecha de inicio (ISO format)")
|
||||||
|
end_date: Optional[datetime] = Field(None, description="Fecha de fin (ISO format)")
|
||||||
|
generations: int = Field(default=80, ge=10, le=500, description="Cantidad de generaciones genéticas")
|
||||||
|
population_size: int = Field(default=40, ge=10, le=200, description="Tamaño de la población de cromosomas")
|
||||||
|
workers: int = Field(default=4, ge=1, le=16, description="Cantidad de hilos o workers concurrentes")
|
||||||
|
|
||||||
|
@field_validator('commission_ids')
|
||||||
|
@classmethod
|
||||||
|
def validate_ids(cls, v):
|
||||||
|
if not v or len(v) == 0:
|
||||||
|
raise ValueError("Debe suministrar al menos una comisión para optimizar.")
|
||||||
|
return list(set(v))
|
||||||
|
|
||||||
|
|
||||||
|
class SpringBootCommissionDTO(BaseModel):
|
||||||
|
"""Contrato de comisión en formato Spring Boot."""
|
||||||
|
commissionId: int
|
||||||
|
subjectCode: str
|
||||||
|
subjectName: str
|
||||||
|
expectedAttendees: int = Field(default=30, ge=1)
|
||||||
|
preferredShift: Optional[str] = "NOCHE"
|
||||||
|
preferredStart: datetime
|
||||||
|
preferredEnd: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SpringBootClassroomDTO(BaseModel):
|
||||||
|
"""Contrato de aula en formato Spring Boot."""
|
||||||
|
classroomId: int
|
||||||
|
roomNumber: str
|
||||||
|
capacity: int = Field(default=30, ge=1)
|
||||||
|
building: Optional[str] = "Edificio Central"
|
||||||
|
isVirtual: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SpringBootSyncPayloadDTO(BaseModel):
|
||||||
|
"""Carga útil de sincronización interoperable con microservicios Spring Boot."""
|
||||||
|
requestId: str = Field(..., description="Identificador único de la petición externa")
|
||||||
|
campusCode: Optional[str] = "CENTRAL"
|
||||||
|
academicTerm: Optional[str] = "2026-1C"
|
||||||
|
commissions: List[SpringBootCommissionDTO] = Field(..., min_length=1)
|
||||||
|
classrooms: Optional[List[SpringBootClassroomDTO]] = None
|
||||||
|
options: Optional[Dict[str, Any]] = None
|
||||||
@@ -41,8 +41,12 @@ class SecurityFilterChain:
|
|||||||
"""Ejecuta los filtros previos al enrutamiento del controlador."""
|
"""Ejecuta los filtros previos al enrutamiento del controlador."""
|
||||||
path = request.path
|
path = request.path
|
||||||
|
|
||||||
# 1. Rate Limiting Filter
|
# 1. Rate Limiting Filter (solo para peticiones POST de autenticación sensible)
|
||||||
if path in self._rate_limits:
|
if path in self._rate_limits:
|
||||||
|
# Peticiones GET para cargar la interfaz web NO consumen intentos de fuerza bruta
|
||||||
|
if request.method != 'POST':
|
||||||
|
return
|
||||||
|
|
||||||
max_req, window = self._rate_limits[path]
|
max_req, window = self._rate_limits[path]
|
||||||
client_ip = self._get_client_ip()
|
client_ip = self._get_client_ip()
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -52,16 +56,18 @@ class SecurityFilterChain:
|
|||||||
timestamps = [t for t in timestamps if now - t < window]
|
timestamps = [t for t in timestamps if now - t < window]
|
||||||
|
|
||||||
if len(timestamps) >= max_req:
|
if len(timestamps) >= max_req:
|
||||||
retry_after = int(window - (now - timestamps[0]))
|
retry_after = max(1, int(window - (now - timestamps[0])))
|
||||||
if path.startswith('/api/'):
|
if path.startswith('/api/'):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'TooManyRequests',
|
'error': 'TooManyRequests',
|
||||||
'message': f'Límite de peticiones excedido. Reintente en {retry_after} segundos.'
|
'message': f'Límite de peticiones excedido. Reintente en {retry_after} segundos.'
|
||||||
}), 429
|
}), 429
|
||||||
|
|
||||||
|
# Para la web, renderizar vista o respuesta legible
|
||||||
return Response(
|
return Response(
|
||||||
f"Demasiados intentos. Por favor espere {retry_after} segundos antes de volver a intentar.",
|
f"Demasiados intentos de autenticación desde su dirección IP. Por favor espere {retry_after} segundos antes de volver a intentar.",
|
||||||
status=429,
|
status=429,
|
||||||
mimetype='text/plain'
|
mimetype='text/plain; charset=utf-8'
|
||||||
)
|
)
|
||||||
|
|
||||||
timestamps.append(now)
|
timestamps.append(now)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .reservation_service import ReservationService
|
|||||||
from .user_service import UserService
|
from .user_service import UserService
|
||||||
from .jwt_service import JWTService
|
from .jwt_service import JWTService
|
||||||
from .audit_service import AuditService
|
from .audit_service import AuditService
|
||||||
|
from .optimizer_service import OptimizerService, OptimizerJob, OptimizerJobStatus
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'GoogleSheetsImporter',
|
'GoogleSheetsImporter',
|
||||||
@@ -11,5 +12,8 @@ __all__ = [
|
|||||||
'ReservationService',
|
'ReservationService',
|
||||||
'UserService',
|
'UserService',
|
||||||
'JWTService',
|
'JWTService',
|
||||||
'AuditService'
|
'AuditService',
|
||||||
|
'OptimizerService',
|
||||||
|
'OptimizerJob',
|
||||||
|
'OptimizerJobStatus'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from app.models.genetic_algorithm import (
|
||||||
|
ReservationOptimizer,
|
||||||
|
GeneticAlgorithm,
|
||||||
|
ReservationRequest,
|
||||||
|
Classroom
|
||||||
|
)
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from app.schemas.optimizer_dto import SpringBootSyncPayloadDTO
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizerJobStatus(str, Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizerJob:
|
||||||
|
"""Representa un trabajo asíncrono en la cola del optimizador heurístico."""
|
||||||
|
|
||||||
|
def __init__(self, job_id: str, params: Dict[str, Any]):
|
||||||
|
self.job_id = job_id
|
||||||
|
self.status = OptimizerJobStatus.PENDING
|
||||||
|
self.progress = 0
|
||||||
|
self.generation = 0
|
||||||
|
self.total_generations = params.get("generations", 80)
|
||||||
|
self.fitness_score = 0.0
|
||||||
|
self.total_requests = 0
|
||||||
|
self.assigned_reservations = 0
|
||||||
|
self.reservations: List[Dict[str, Any]] = []
|
||||||
|
self.conflicts: List[Dict[str, Any]] = []
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
self.started_at: Optional[datetime] = None
|
||||||
|
self.completed_at: Optional[datetime] = None
|
||||||
|
self.execution_time_ms: Optional[float] = None
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"job_id": self.job_id,
|
||||||
|
"status": self.status.value,
|
||||||
|
"progress": self.progress,
|
||||||
|
"generation": self.generation,
|
||||||
|
"total_generations": self.total_generations,
|
||||||
|
"fitness_score": round(self.fitness_score, 2),
|
||||||
|
"total_requests": self.total_requests,
|
||||||
|
"assigned_reservations": self.assigned_reservations,
|
||||||
|
"reservations": self.reservations,
|
||||||
|
"conflict_count": len(self.conflicts),
|
||||||
|
"conflicts": self.conflicts,
|
||||||
|
"error": self.error,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||||
|
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||||
|
"execution_time_ms": self.execution_time_ms,
|
||||||
|
"params": {k: v for k, v in self.params.items() if k not in ("app",)}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizerService:
|
||||||
|
"""
|
||||||
|
Servicio de alto rendimiento para el Optimizador Heurístico y Cola de Trabajos Asíncronos.
|
||||||
|
Soporta evaluación concurrente multi-hilo y sincronización interoperable con Spring Boot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_jobs: Dict[str, OptimizerJob] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def submit_job(
|
||||||
|
cls,
|
||||||
|
commission_ids: List[int],
|
||||||
|
admin_user_id: int,
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None,
|
||||||
|
generations: int = 80,
|
||||||
|
population_size: int = 40,
|
||||||
|
workers: int = 4,
|
||||||
|
app = None
|
||||||
|
) -> OptimizerJob:
|
||||||
|
"""
|
||||||
|
Registra y despacha un trabajo de optimización en segundo plano (asíncrono).
|
||||||
|
"""
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
params = {
|
||||||
|
"commission_ids": commission_ids,
|
||||||
|
"admin_user_id": admin_user_id,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"generations": generations,
|
||||||
|
"population_size": population_size,
|
||||||
|
"workers": workers
|
||||||
|
}
|
||||||
|
|
||||||
|
job = OptimizerJob(job_id, params)
|
||||||
|
with cls._lock:
|
||||||
|
cls._jobs[job_id] = job
|
||||||
|
|
||||||
|
# Si no se pasó app explícitamente, intentar obtener current_app
|
||||||
|
if app is None:
|
||||||
|
try:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
except Exception:
|
||||||
|
app = None
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=cls._run_job_worker,
|
||||||
|
args=(job_id, app),
|
||||||
|
daemon=True,
|
||||||
|
name=f"optimizer-worker-{job_id[:8]}"
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return job
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _run_job_worker(cls, job_id: str, app):
|
||||||
|
"""Worker en segundo plano para ejecutar el algoritmo genético."""
|
||||||
|
job = cls.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _execute():
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
with cls._lock:
|
||||||
|
job.status = OptimizerJobStatus.RUNNING
|
||||||
|
job.started_at = datetime.utcnow()
|
||||||
|
|
||||||
|
def progress_callback(gen: int, total_gen: int, pct: int, best_fit: float):
|
||||||
|
with cls._lock:
|
||||||
|
job.generation = gen
|
||||||
|
job.total_generations = total_gen
|
||||||
|
job.progress = pct
|
||||||
|
job.fitness_score = best_fit
|
||||||
|
|
||||||
|
optimizer = ReservationOptimizer(
|
||||||
|
population_size=job.params.get("population_size", 40),
|
||||||
|
generations=job.params.get("generations", 80),
|
||||||
|
workers=job.params.get("workers", 4)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = optimizer.optimize_schedule(
|
||||||
|
commission_ids=job.params["commission_ids"],
|
||||||
|
admin_user_id=job.params["admin_user_id"],
|
||||||
|
start_date=job.params.get("start_date"),
|
||||||
|
end_date=job.params.get("end_date"),
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
t_end = time.perf_counter()
|
||||||
|
exec_time_ms = round((t_end - t_start) * 1000, 2)
|
||||||
|
|
||||||
|
with cls._lock:
|
||||||
|
job.status = OptimizerJobStatus.COMPLETED
|
||||||
|
job.progress = 100
|
||||||
|
job.fitness_score = result.get("fitness_score", 0.0)
|
||||||
|
job.total_requests = result.get("total_requests", 0)
|
||||||
|
job.assigned_reservations = result.get("assigned_reservations", 0)
|
||||||
|
job.reservations = result.get("reservations", [])
|
||||||
|
job.conflicts = result.get("conflicts", [])
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
job.execution_time_ms = exec_time_ms
|
||||||
|
|
||||||
|
try:
|
||||||
|
if app:
|
||||||
|
with app.app_context():
|
||||||
|
_execute()
|
||||||
|
else:
|
||||||
|
_execute()
|
||||||
|
except Exception as ex:
|
||||||
|
with cls._lock:
|
||||||
|
job.status = OptimizerJobStatus.FAILED
|
||||||
|
job.error = str(ex)
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_job(cls, job_id: str) -> Optional[OptimizerJob]:
|
||||||
|
"""Obtiene un trabajo por su identificador único."""
|
||||||
|
with cls._lock:
|
||||||
|
return cls._jobs.get(job_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_jobs(cls, limit: int = 50) -> List[Dict[str, Any]]:
|
||||||
|
"""Lista los trabajos más recientes ordenados por fecha de creación desc."""
|
||||||
|
with cls._lock:
|
||||||
|
sorted_jobs = sorted(cls._jobs.values(), key=lambda j: j.created_at, reverse=True)
|
||||||
|
return [j.to_dict() for j in sorted_jobs[:limit]]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sync_with_spring_boot(
|
||||||
|
cls,
|
||||||
|
payload: SpringBootSyncPayloadDTO,
|
||||||
|
admin_user_id: int = 1
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Procesa una carga de optimización interoperable proveniente de microservicios Spring Boot.
|
||||||
|
Garantiza respuesta conforme al contrato JSON del microservicio.
|
||||||
|
"""
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
|
||||||
|
# 1. Resolver aulas disponibles
|
||||||
|
if payload.classrooms and len(payload.classrooms) > 0:
|
||||||
|
classrooms = []
|
||||||
|
for c_dto in payload.classrooms:
|
||||||
|
room = Classroom(
|
||||||
|
room_number=c_dto.roomNumber,
|
||||||
|
capacity=c_dto.capacity,
|
||||||
|
is_virtual=c_dto.isVirtual,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
room.id = c_dto.classroomId
|
||||||
|
classrooms.append(room)
|
||||||
|
else:
|
||||||
|
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
|
||||||
|
# 2. Generar ReservationRequests desde el DTO de Spring Boot
|
||||||
|
requests: List[ReservationRequest] = []
|
||||||
|
for c in payload.commissions:
|
||||||
|
req = ReservationRequest(
|
||||||
|
commission_id=c.commissionId,
|
||||||
|
expected_attendees=c.expectedAttendees,
|
||||||
|
purpose=f"Spring Boot Class: {c.subjectCode} - {c.subjectName}",
|
||||||
|
preferred_start_time=c.preferredStart,
|
||||||
|
preferred_end_time=c.preferredEnd,
|
||||||
|
priority=1,
|
||||||
|
flexibility_hours=2
|
||||||
|
)
|
||||||
|
requests.append(req)
|
||||||
|
|
||||||
|
# 3. Opciones de configuración de algoritmo
|
||||||
|
options = payload.options or {}
|
||||||
|
generations = options.get("generations", 60)
|
||||||
|
pop_size = options.get("populationSize", 30)
|
||||||
|
workers = options.get("workers", 4)
|
||||||
|
|
||||||
|
ga = GeneticAlgorithm(
|
||||||
|
population_size=pop_size,
|
||||||
|
generations=generations,
|
||||||
|
workers=workers
|
||||||
|
)
|
||||||
|
|
||||||
|
start_date = min(c.preferredStart for c in payload.commissions)
|
||||||
|
end_date = max(c.preferredEnd for c in payload.commissions)
|
||||||
|
|
||||||
|
best_individual = ga.optimize_reservations(requests, classrooms, start_date, end_date)
|
||||||
|
|
||||||
|
t_end = time.perf_counter()
|
||||||
|
exec_ms = round((t_end - t_start) * 1000, 2)
|
||||||
|
|
||||||
|
scheduled_assignments = []
|
||||||
|
for gene in best_individual.genes:
|
||||||
|
scheduled_assignments.append({
|
||||||
|
"commissionId": gene.commission_id,
|
||||||
|
"classroomId": gene.classroom_id,
|
||||||
|
"startTime": gene.start_time.isoformat() if gene.start_time else None,
|
||||||
|
"endTime": gene.end_time.isoformat() if gene.end_time else None,
|
||||||
|
"purpose": gene.purpose,
|
||||||
|
"expectedAttendees": gene.expected_attendees,
|
||||||
|
"status": "SCHEDULED"
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"requestId": payload.requestId,
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"academicTerm": payload.academicTerm,
|
||||||
|
"campusCode": payload.campusCode,
|
||||||
|
"totalCommissions": len(payload.commissions),
|
||||||
|
"scheduledAssignments": scheduled_assignments,
|
||||||
|
"conflictCount": len(best_individual.conflicts),
|
||||||
|
"fitnessScore": round(best_individual.fitness, 2),
|
||||||
|
"executionTimeMs": exec_ms,
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||||||
|
}
|
||||||
@@ -142,6 +142,20 @@ body {
|
|||||||
transition: color 0.15s ease, transform 0.15s ease;
|
transition: color 0.15s ease, transform 0.15s ease;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.5rem 0.85rem !important;
|
padding: 0.5rem 0.85rem !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) and (min-width: 992px) {
|
||||||
|
.navbar-app {
|
||||||
|
padding: 0.5rem 0.75rem !important;
|
||||||
|
}
|
||||||
|
.navbar-app .nav-link {
|
||||||
|
padding: 0.4rem 0.5rem !important;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.navbar-app .navbar-brand span {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-app .nav-link:hover,
|
.navbar-app .nav-link:hover,
|
||||||
|
|||||||
@@ -77,32 +77,61 @@
|
|||||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||||
<i class="bi bi-lock"></i>
|
<i class="bi bi-lock"></i>
|
||||||
</span>
|
</span>
|
||||||
<input type="password" class="form-control border-start-0 ps-0" id="password" name="password"
|
<input type="password" class="form-control border-start-0 border-end-0 ps-0" id="password" name="password"
|
||||||
required placeholder="{% trans %}Enter your password{% endtrans %}">
|
required placeholder="{% trans %}Enter your password{% endtrans %}">
|
||||||
|
<button class="btn btn-outline-secondary border-start-0 bg-body-tertiary text-muted" type="button" id="togglePasswordBtn" title="Mostrar/Ocultar contraseña">
|
||||||
|
<i class="bi bi-eye" id="togglePasswordIcon"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% for error in form.password.errors %}
|
{% for error in form.password.errors %}
|
||||||
<div class="text-danger small mt-1">{{ error }}</div>
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 form-check">
|
<div class="mb-3 form-check">
|
||||||
{{ form.remember_me(class="form-check-input") }}
|
{{ form.remember_me(class="form-check-input") }}
|
||||||
<label class="form-check-label text-muted small" for="remember_me">
|
<label class="form-check-label text-muted small" for="remember_me">
|
||||||
{{ form.remember_me.label.text }}
|
{{ form.remember_me.label.text }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid">
|
<div class="d-grid mb-3">
|
||||||
<button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
|
<button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
|
||||||
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
|
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Accesos Rápidos Institucionales (Demo) -->
|
||||||
|
<div class="p-3 bg-body-tertiary rounded-3 border mt-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<small class="fw-bold text-muted text-uppercase" style="font-size: 0.7rem; letter-spacing: 0.5px;">
|
||||||
|
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>{% trans %}Acceso Rápido Demo (1 Clic){% endtrans %}
|
||||||
|
</small>
|
||||||
|
<small class="text-muted font-monospace" style="font-size: 0.68rem;">Pass: admin123</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid gap-1">
|
||||||
|
<div class="btn-group btn-group-sm w-100" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-primary demo-fill-btn py-1" data-email="admin@edu-space.com" title="Ingresar como Administrador">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>Admin
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning demo-fill-btn py-1" data-email="bedelia@edu-space.com" title="Ingresar como Bedelía">
|
||||||
|
<i class="bi bi-building-gear me-1"></i>Bedelía
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-info demo-fill-btn py-1" data-email="docente@edu-space.com" title="Ingresar como Docente">
|
||||||
|
<i class="bi bi-person-video3 me-1"></i>Docente
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-success demo-fill-btn py-1" data-email="alumno@edu-space.com" title="Ingresar como Alumno">
|
||||||
|
<i class="bi bi-mortarboard me-1"></i>Alumno
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-center mt-4">
|
<div class="text-center mt-3">
|
||||||
<small class="text-muted d-inline-flex align-items-center">
|
<small class="text-muted d-inline-flex align-items-center" style="font-size: 0.75rem;">
|
||||||
<i class="bi bi-shield-check text-primary me-1"></i>
|
<i class="bi bi-shield-check text-primary me-1"></i>
|
||||||
{% trans %}Secure Login • All access is logged{% endtrans %}
|
{% trans %}Acceso institucional seguro • Actividad auditada{% endtrans %}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,5 +140,32 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
// Toggle password visibility
|
||||||
|
document.getElementById('togglePasswordBtn').addEventListener('click', function() {
|
||||||
|
const pwdInput = document.getElementById('password');
|
||||||
|
const icon = document.getElementById('togglePasswordIcon');
|
||||||
|
if (pwdInput.type === 'password') {
|
||||||
|
pwdInput.type = 'text';
|
||||||
|
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||||
|
} else {
|
||||||
|
pwdInput.type = 'password';
|
||||||
|
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Demo login autofill buttons
|
||||||
|
document.querySelectorAll('.demo-fill-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const email = this.getAttribute('data-email');
|
||||||
|
document.getElementById('email').value = email;
|
||||||
|
document.getElementById('password').value = 'admin123';
|
||||||
|
// Highlight input briefly
|
||||||
|
const emailInput = document.getElementById('email');
|
||||||
|
emailInput.classList.add('is-valid');
|
||||||
|
setTimeout(() => emailInput.classList.remove('is-valid'), 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+64
-31
@@ -175,37 +175,70 @@ def init_database():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
print("[OK] Roles y matriz de permisos configurados correctamente.")
|
print("[OK] Roles y matriz de permisos configurados correctamente.")
|
||||||
|
|
||||||
# 4. Verificar o crear usuario administrador inicial
|
# 4. Verificar o crear usuarios institucionales de cada rol
|
||||||
print("[*] Verificando usuario administrador inicial...")
|
print("[*] Verificando usuarios institucionales y credenciales...")
|
||||||
admin = User.query.filter_by(email="admin@edu-space.com").first()
|
roles_by_name = {r.name: r for r in Role.query.all()}
|
||||||
if not admin:
|
|
||||||
admin = User(
|
institutional_users = [
|
||||||
email="admin@edu-space.com",
|
{
|
||||||
name="System Administrator",
|
'email': 'admin@edu-space.com',
|
||||||
role="ADMIN",
|
'name': 'Administrador del Sistema',
|
||||||
role_id=admin_role.id if admin_role else None,
|
'role_name': 'Admin',
|
||||||
is_active=True,
|
'role_code': 'ADMIN'
|
||||||
preferred_language="es",
|
},
|
||||||
theme_preference="auto"
|
{
|
||||||
)
|
'email': 'bedelia@edu-space.com',
|
||||||
admin.set_password("admin123")
|
'name': 'Operador de Bedelía',
|
||||||
db.session.add(admin)
|
'role_name': 'Bedelia',
|
||||||
db.session.commit()
|
'role_code': 'BEDELIA'
|
||||||
print("[OK] Usuario inicial creado exitosamente:")
|
},
|
||||||
print(" Email: admin@edu-space.com")
|
{
|
||||||
print(" Password: admin123")
|
'email': 'docente@edu-space.com',
|
||||||
print(f" Rol: {admin_role.name if admin_role else 'ADMIN'}")
|
'name': 'Profesor Titular',
|
||||||
else:
|
'role_name': 'Docente',
|
||||||
# Sincronizar rol y atributos del admin existente si faltan
|
'role_code': 'DOCENTE'
|
||||||
if admin_role and admin.role_id != admin_role.id:
|
},
|
||||||
admin.role_id = admin_role.id
|
{
|
||||||
admin.role = "ADMIN"
|
'email': 'alumno@edu-space.com',
|
||||||
if not admin.preferred_language:
|
'name': 'Estudiante Regular',
|
||||||
admin.preferred_language = "es"
|
'role_name': 'Alumno',
|
||||||
if not admin.theme_preference:
|
'role_code': 'ALUMNO'
|
||||||
admin.theme_preference = "auto"
|
}
|
||||||
db.session.commit()
|
]
|
||||||
print(f"[INFO] El usuario admin (admin@edu-space.com) esta activo y vinculado al rol '{admin_role.name if admin_role else 'ADMIN'}'.")
|
|
||||||
|
for u_info in institutional_users:
|
||||||
|
u_obj = User.query.filter_by(email=u_info['email']).first()
|
||||||
|
r_match = roles_by_name.get(u_info['role_name'])
|
||||||
|
|
||||||
|
if not u_obj:
|
||||||
|
u_obj = User(
|
||||||
|
email=u_info['email'],
|
||||||
|
name=u_info['name'],
|
||||||
|
role=u_info['role_code'],
|
||||||
|
role_id=r_match.id if r_match else None,
|
||||||
|
is_active=True,
|
||||||
|
preferred_language="es",
|
||||||
|
theme_preference="auto"
|
||||||
|
)
|
||||||
|
u_obj.set_password("admin123")
|
||||||
|
db.session.add(u_obj)
|
||||||
|
db.session.commit()
|
||||||
|
print(f" [+] Usuario creado: {u_info['email']} | Clave: admin123 | Rol: {u_info['role_name']}")
|
||||||
|
else:
|
||||||
|
# Garantizar sincronización de rol, estado activo y clave
|
||||||
|
if r_match and u_obj.role_id != r_match.id:
|
||||||
|
u_obj.role_id = r_match.id
|
||||||
|
u_obj.role = u_info['role_code']
|
||||||
|
u_obj.is_active = True
|
||||||
|
if not u_obj.check_password("admin123"):
|
||||||
|
u_obj.set_password("admin123")
|
||||||
|
print(f" [*] Clave de {u_info['email']} restablecida a 'admin123'.")
|
||||||
|
if not u_obj.preferred_language:
|
||||||
|
u_obj.preferred_language = "es"
|
||||||
|
if not u_obj.theme_preference:
|
||||||
|
u_obj.theme_preference = "auto"
|
||||||
|
db.session.commit()
|
||||||
|
print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}, Clave: admin123).")
|
||||||
|
|
||||||
# 5. Inicializar edificios institucionales oficiales
|
# 5. Inicializar edificios institucionales oficiales
|
||||||
print("[*] Verificando edificios institucionales...")
|
print("[*] Verificando edificios institucionales...")
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import unittest
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app import create_app, db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.role import Role
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.genetic_algorithm import (
|
||||||
|
GeneticAlgorithm,
|
||||||
|
ReservationRequest,
|
||||||
|
Individual,
|
||||||
|
Gene
|
||||||
|
)
|
||||||
|
from app.services.optimizer_service import OptimizerService, OptimizerJobStatus
|
||||||
|
from app.services.jwt_service import JWTService
|
||||||
|
|
||||||
|
|
||||||
|
class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
||||||
|
"""Pruebas exhaustivas para el Sprint 4: Optimizador Heurístico, Concurrencia y Spring Boot."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.app = create_app()
|
||||||
|
self.app.config['TESTING'] = True
|
||||||
|
self.app.config['WTF_CSRF_ENABLED'] = False
|
||||||
|
self.app_context = self.app.app_context()
|
||||||
|
self.app_context.push()
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
# Obtener usuario existente o crear
|
||||||
|
self.user = User.query.filter_by(is_active=True).first()
|
||||||
|
if not self.user:
|
||||||
|
admin_role = Role.query.filter_by(name='Admin').first()
|
||||||
|
if not admin_role:
|
||||||
|
admin_role = Role(name='Admin', description='Administrador')
|
||||||
|
db.session.add(admin_role)
|
||||||
|
db.session.commit()
|
||||||
|
self.user = User(
|
||||||
|
name='Sprint 4 Admin',
|
||||||
|
email='admin_sprint4@edu-space.com',
|
||||||
|
role_id=admin_role.id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
self.user.set_password('admin123')
|
||||||
|
db.session.add(self.user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Obtener aulas activas de la base de datos
|
||||||
|
self.classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
if not self.classrooms:
|
||||||
|
for i in range(1, 11):
|
||||||
|
room = Classroom(
|
||||||
|
room_number=f"AULA-{100 + i}",
|
||||||
|
capacity=30 + (i * 5),
|
||||||
|
is_virtual=(i == 10),
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.session.add(room)
|
||||||
|
db.session.commit()
|
||||||
|
self.classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
|
||||||
|
# Token JWT para el usuario de prueba
|
||||||
|
token_pair = JWTService.generate_tokens(self.user)
|
||||||
|
self.access_token = token_pair['access_token']
|
||||||
|
self.auth_headers = {
|
||||||
|
'Authorization': f'Bearer {self.access_token}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
db.session.rollback()
|
||||||
|
self.app_context.pop()
|
||||||
|
|
||||||
|
def test_01_fast_fitness_and_conflict_detection(self):
|
||||||
|
"""Verifica la detección de solapamiento O(K log K) y cálculo de fitness."""
|
||||||
|
now = datetime(2026, 3, 10, 18, 0, 0)
|
||||||
|
c1 = self.classrooms[0]
|
||||||
|
|
||||||
|
# Gene 1: 18:00 a 20:00 en AULA-101
|
||||||
|
g1 = Gene(
|
||||||
|
commission_id=1,
|
||||||
|
classroom_id=c1.id,
|
||||||
|
start_time=now,
|
||||||
|
end_time=now + timedelta(hours=2),
|
||||||
|
expected_attendees=30,
|
||||||
|
purpose="Clase Algoritmos"
|
||||||
|
)
|
||||||
|
# Gene 2: 19:00 a 21:00 en la MISMA aula (conflicto directo de 19:00 a 20:00)
|
||||||
|
g2 = Gene(
|
||||||
|
commission_id=2,
|
||||||
|
classroom_id=c1.id,
|
||||||
|
start_time=now + timedelta(hours=1),
|
||||||
|
end_time=now + timedelta(hours=3),
|
||||||
|
expected_attendees=30,
|
||||||
|
purpose="Clase Base de Datos"
|
||||||
|
)
|
||||||
|
|
||||||
|
ind = Individual([g1, g2])
|
||||||
|
classrooms_dict = {c.id: c for c in self.classrooms}
|
||||||
|
req1 = ReservationRequest(1, 30, "Clase Algoritmos", now, now + timedelta(hours=2))
|
||||||
|
req2 = ReservationRequest(2, 30, "Clase Base de Datos", now + timedelta(hours=1), now + timedelta(hours=3))
|
||||||
|
requests_dict = {1: req1, 2: req2}
|
||||||
|
|
||||||
|
fit = ind.calculate_fitness(classrooms_dict, requests_dict)
|
||||||
|
self.assertGreater(len(ind.conflicts), 0, "Debe detectar conflicto de solapamiento de horario")
|
||||||
|
|
||||||
|
def test_02_benchmark_200_commissions_under_5_seconds(self):
|
||||||
|
"""Hito de Evaluación Sprint 4: Optimización de 200+ comisiones en menos de 5 segundos."""
|
||||||
|
base_time = datetime(2026, 3, 16, 8, 0, 0)
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
# Crear 200 peticiones de comisión distribuidas en turnos
|
||||||
|
for i in range(1, 201):
|
||||||
|
day_offset = (i % 5) # Lun a Vie
|
||||||
|
slot_offset = (i % 4) * 3 # 08:00, 11:00, 14:00, 18:00
|
||||||
|
start = base_time + timedelta(days=day_offset, hours=slot_offset)
|
||||||
|
req = ReservationRequest(
|
||||||
|
commission_id=i,
|
||||||
|
expected_attendees=25 + (i % 25),
|
||||||
|
purpose=f"Comisión {i}",
|
||||||
|
preferred_start_time=start,
|
||||||
|
preferred_end_time=start + timedelta(hours=2),
|
||||||
|
priority=1,
|
||||||
|
flexibility_hours=2
|
||||||
|
)
|
||||||
|
requests.append(req)
|
||||||
|
|
||||||
|
# Configurar optimizador con paralelización (4 workers)
|
||||||
|
ga = GeneticAlgorithm(
|
||||||
|
population_size=30,
|
||||||
|
generations=25,
|
||||||
|
workers=4
|
||||||
|
)
|
||||||
|
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
best = ga.optimize_reservations(
|
||||||
|
requests=requests,
|
||||||
|
available_classrooms=self.classrooms,
|
||||||
|
start_date=base_time,
|
||||||
|
end_date=base_time + timedelta(days=7)
|
||||||
|
)
|
||||||
|
t_elapsed = time.perf_counter() - t_start
|
||||||
|
|
||||||
|
self.assertIsNotNone(best)
|
||||||
|
self.assertEqual(len(best.genes), 200, "Debe haber asignado las 200 comisiones")
|
||||||
|
self.assertLess(t_elapsed, 5.0, f"El algoritmo debe resolver 200 comisiones en < 5s (demoró {t_elapsed:.2f}s)")
|
||||||
|
|
||||||
|
def test_03_optimizer_service_async_queue(self):
|
||||||
|
"""Verifica la cola de trabajos asíncrona de OptimizerService."""
|
||||||
|
# Enviar un job en segundo plano con app_context
|
||||||
|
job = OptimizerService.submit_job(
|
||||||
|
commission_ids=[1, 2],
|
||||||
|
admin_user_id=self.user.id,
|
||||||
|
generations=10,
|
||||||
|
population_size=10,
|
||||||
|
workers=2,
|
||||||
|
app=self.app
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(job.job_id)
|
||||||
|
self.assertIn(job.status, [OptimizerJobStatus.PENDING, OptimizerJobStatus.RUNNING, OptimizerJobStatus.COMPLETED])
|
||||||
|
|
||||||
|
# Esperar a que el worker complete la ejecución (máximo 4 segundos)
|
||||||
|
waited = 0.0
|
||||||
|
while job.status in (OptimizerJobStatus.PENDING, OptimizerJobStatus.RUNNING) and waited < 4.0:
|
||||||
|
time.sleep(0.1)
|
||||||
|
waited += 0.1
|
||||||
|
|
||||||
|
self.assertEqual(job.status, OptimizerJobStatus.COMPLETED, f"El trabajo debió completar con éxito, error: {job.error}")
|
||||||
|
self.assertEqual(job.progress, 100)
|
||||||
|
self.assertIsNotNone(job.completed_at)
|
||||||
|
self.assertIsNotNone(job.execution_time_ms)
|
||||||
|
|
||||||
|
# Consultar via list_jobs y get_job
|
||||||
|
found = OptimizerService.get_job(job.job_id)
|
||||||
|
self.assertIsNotNone(found)
|
||||||
|
self.assertEqual(found.job_id, job.job_id)
|
||||||
|
|
||||||
|
all_jobs = OptimizerService.list_jobs()
|
||||||
|
self.assertTrue(any(j['job_id'] == job.job_id for j in all_jobs))
|
||||||
|
|
||||||
|
def test_04_spring_boot_sync_payload_endpoint(self):
|
||||||
|
"""Verifica el endpoint de interoperabilidad REST/JWT con Spring Boot."""
|
||||||
|
now = datetime(2026, 4, 1, 18, 30, 0)
|
||||||
|
payload = {
|
||||||
|
"requestId": "REQ-SPRING-BOOT-2026-001",
|
||||||
|
"campusCode": "SEDE-CENTRAL",
|
||||||
|
"academicTerm": "2026-1C",
|
||||||
|
"commissions": [
|
||||||
|
{
|
||||||
|
"commissionId": 101,
|
||||||
|
"subjectCode": "INFO-101",
|
||||||
|
"subjectName": "Programación Orientada a Objetos",
|
||||||
|
"expectedAttendees": 35,
|
||||||
|
"preferredShift": "NOCHE",
|
||||||
|
"preferredStart": now.isoformat(),
|
||||||
|
"preferredEnd": (now + timedelta(hours=3)).isoformat()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"commissionId": 102,
|
||||||
|
"subjectCode": "MATH-201",
|
||||||
|
"subjectName": "Análisis Matemático II",
|
||||||
|
"expectedAttendees": 40,
|
||||||
|
"preferredShift": "NOCHE",
|
||||||
|
"preferredStart": now.isoformat(),
|
||||||
|
"preferredEnd": (now + timedelta(hours=3)).isoformat()
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"generations": 15,
|
||||||
|
"populationSize": 15,
|
||||||
|
"workers": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/v1/optimizer/spring-boot/sync',
|
||||||
|
json=payload,
|
||||||
|
headers=self.auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.get_json()
|
||||||
|
self.assertEqual(data.get('requestId'), "REQ-SPRING-BOOT-2026-001")
|
||||||
|
self.assertEqual(data.get('status'), "SUCCESS")
|
||||||
|
self.assertEqual(data.get('totalCommissions'), 2)
|
||||||
|
self.assertIn('scheduledAssignments', data)
|
||||||
|
self.assertIn('executionTimeMs', data)
|
||||||
|
self.assertEqual(len(data['scheduledAssignments']), 2)
|
||||||
|
|
||||||
|
def test_05_api_async_job_submission_and_polling(self):
|
||||||
|
"""Verifica la API REST de despacho asíncrono y sondeo (polling)."""
|
||||||
|
payload = {
|
||||||
|
"commission_ids": [1, 2],
|
||||||
|
"generations": 10,
|
||||||
|
"population_size=10": None, # será ignorado o testeado con defaults
|
||||||
|
"generations": 10,
|
||||||
|
"population_size": 10,
|
||||||
|
"workers": 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Enviar job (debe retornar 202 Accepted)
|
||||||
|
post_res = self.client.post(
|
||||||
|
'/api/v1/optimizer/jobs',
|
||||||
|
json=payload,
|
||||||
|
headers=self.auth_headers
|
||||||
|
)
|
||||||
|
self.assertEqual(post_res.status_code, 202)
|
||||||
|
post_data = post_res.get_json()
|
||||||
|
self.assertIn('job_id', post_data)
|
||||||
|
job_id = post_data['job_id']
|
||||||
|
self.assertEqual(post_data['poll_url'], f'/api/v1/optimizer/jobs/{job_id}')
|
||||||
|
|
||||||
|
# 2. Consultar estado del job
|
||||||
|
get_res = self.client.get(
|
||||||
|
f'/api/v1/optimizer/jobs/{job_id}',
|
||||||
|
headers=self.auth_headers
|
||||||
|
)
|
||||||
|
self.assertEqual(get_res.status_code, 200)
|
||||||
|
get_data = get_res.get_json()
|
||||||
|
self.assertEqual(get_data['job_id'], job_id)
|
||||||
|
self.assertIn(get_data['status'], ['PENDING', 'RUNNING', 'COMPLETED'])
|
||||||
|
|
||||||
|
# 3. Listar trabajos
|
||||||
|
list_res = self.client.get(
|
||||||
|
'/api/v1/optimizer/jobs',
|
||||||
|
headers=self.auth_headers
|
||||||
|
)
|
||||||
|
self.assertEqual(list_res.status_code, 200)
|
||||||
|
list_data = list_res.get_json()
|
||||||
|
self.assertGreaterEqual(list_data['total'], 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user