feat(sprint4): completar optimizador heuristico concurrente, integracion Spring Boot, correccion de login y fixes visuales
This commit is contained in:
+2
-1
@@ -32,7 +32,7 @@ def create_app(config_class=Config):
|
||||
from app.models import user
|
||||
# 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.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
|
||||
|
||||
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_classrooms_bp)
|
||||
app.register_blueprint(api_reservations_bp)
|
||||
app.register_blueprint(api_optimizer_bp)
|
||||
|
||||
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||
SecurityFilterChain(app)
|
||||
|
||||
+8
-5
@@ -4,11 +4,14 @@ from wtforms.validators import DataRequired, Email, Length
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField(_l('Email'), validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
email = StringField(_l('Email'),
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
],
|
||||
filters=[lambda x: x.strip().lower() if x else '']
|
||||
)
|
||||
password = PasswordField(_l('Password'), validators=[DataRequired()])
|
||||
remember_me = BooleanField(_l('Remember me'))
|
||||
submit = SubmitField(_l('Sign In'))
|
||||
@@ -46,10 +46,31 @@ class Individual:
|
||||
self.conflicts = []
|
||||
|
||||
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
|
||||
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:
|
||||
classroom = classrooms.get(gene.classroom_id)
|
||||
request = requests.get(gene.commission_id)
|
||||
@@ -66,19 +87,20 @@ class Individual:
|
||||
score += time_score * 0.3
|
||||
|
||||
# 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
|
||||
|
||||
# Factor 4: Resource matching (10% weight)
|
||||
resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
|
||||
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
|
||||
return score
|
||||
@@ -89,7 +111,7 @@ class Individual:
|
||||
return 0.0 # Overfilled classroom
|
||||
|
||||
# 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
|
||||
return 1.0
|
||||
elif waste_ratio <= 0.3: # Less than 30% waste
|
||||
@@ -107,12 +129,12 @@ class Individual:
|
||||
if time_diff <= 0.5: # Within 30 minutes
|
||||
return 1.0
|
||||
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:
|
||||
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:
|
||||
"""Check for time conflicts in the same classroom"""
|
||||
"""Check for time conflicts in the same classroom (kept for backward compatibility)"""
|
||||
conflicts = 0
|
||||
for other in all_genes:
|
||||
if gene != other and gene.classroom_id == other.classroom_id:
|
||||
@@ -127,9 +149,6 @@ class Individual:
|
||||
"""Check if classroom meets subject requirements"""
|
||||
if not requirements:
|
||||
return 1.0
|
||||
|
||||
# This would need to be implemented based on actual classroom resources
|
||||
# For now, return a default score
|
||||
return 0.8
|
||||
|
||||
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
|
||||
@@ -138,19 +157,24 @@ class Individual:
|
||||
|
||||
|
||||
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,
|
||||
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.generations = generations
|
||||
self.mutation_rate = mutation_rate
|
||||
self.crossover_rate = crossover_rate
|
||||
self.workers = max(1, workers)
|
||||
|
||||
def optimize_reservations(self, requests: List[ReservationRequest],
|
||||
available_classrooms: List[Classroom],
|
||||
start_date: datetime, end_date: datetime) -> Individual:
|
||||
"""Run genetic algorithm to find optimal reservation schedule"""
|
||||
start_date: datetime, end_date: datetime,
|
||||
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}
|
||||
requests_dict = {r.commission_id: r for r in requests}
|
||||
|
||||
@@ -159,18 +183,29 @@ class GeneticAlgorithm:
|
||||
|
||||
# Evolve population
|
||||
for generation in range(self.generations):
|
||||
# Calculate fitness for all individuals
|
||||
for individual in population:
|
||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
# Calculate fitness for all individuals concurrently when workers > 1
|
||||
if self.workers > 1 and len(population) > 10:
|
||||
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)
|
||||
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
|
||||
new_population = []
|
||||
|
||||
# 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])
|
||||
|
||||
# Crossover and mutation for remaining
|
||||
@@ -196,7 +231,9 @@ class GeneticAlgorithm:
|
||||
|
||||
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)
|
||||
return population[0]
|
||||
|
||||
@@ -284,13 +321,14 @@ class GeneticAlgorithm:
|
||||
class ReservationOptimizer:
|
||||
"""High-level interface for the reservation optimization system"""
|
||||
|
||||
def __init__(self):
|
||||
self.ga = GeneticAlgorithm()
|
||||
def __init__(self, population_size: int = 50, generations: int = 100, workers: int = 4):
|
||||
self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
|
||||
|
||||
def optimize_schedule(self, commission_ids: List[int],
|
||||
admin_user_id: int,
|
||||
start_date: datetime = None,
|
||||
end_date: datetime = None) -> Dict:
|
||||
end_date: datetime = None,
|
||||
progress_callback = None) -> Dict:
|
||||
"""Optimize reservation schedule for given commissions"""
|
||||
|
||||
# Default date range if not provided
|
||||
@@ -325,7 +363,7 @@ class ReservationOptimizer:
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# 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
|
||||
optimized_reservations = []
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
from app.routes.api.auth import api_auth_bp
|
||||
from app.routes.api.classrooms import api_classrooms_bp
|
||||
from app.routes.api.reservations import api_reservations_bp
|
||||
from app.routes.api.optimizer import api_optimizer_bp
|
||||
|
||||
__all__ = [
|
||||
'api_auth_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__)
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""Handle user login"""
|
||||
"""Handle user login with case-insensitive email lookup and whitespace trimming"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
form = LoginForm()
|
||||
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 not user.is_active:
|
||||
@@ -25,6 +28,19 @@ def login():
|
||||
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
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
|
||||
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')
|
||||
return redirect(next_page)
|
||||
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')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@@ -39,8 +39,8 @@ def optimize_reservations():
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
||||
|
||||
# Check if user has permission (admin or teacher)
|
||||
if current_user.role not in ['ADMIN', 'teacher']:
|
||||
# Check if user has permission (admin, teacher or RBAC optimizer write)
|
||||
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
|
||||
|
||||
# Initialize optimizer and run optimization
|
||||
@@ -84,7 +84,7 @@ def apply_optimization():
|
||||
return jsonify({'error': 'reservations must be a list'}), 400
|
||||
|
||||
# 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
|
||||
|
||||
# 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."""
|
||||
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:
|
||||
# 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]
|
||||
client_ip = self._get_client_ip()
|
||||
now = time.time()
|
||||
@@ -52,16 +56,18 @@ class SecurityFilterChain:
|
||||
timestamps = [t for t in timestamps if now - t < window]
|
||||
|
||||
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/'):
|
||||
return jsonify({
|
||||
'error': 'TooManyRequests',
|
||||
'message': f'Límite de peticiones excedido. Reintente en {retry_after} segundos.'
|
||||
}), 429
|
||||
|
||||
# Para la web, renderizar vista o respuesta legible
|
||||
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,
|
||||
mimetype='text/plain'
|
||||
mimetype='text/plain; charset=utf-8'
|
||||
)
|
||||
|
||||
timestamps.append(now)
|
||||
|
||||
@@ -4,6 +4,7 @@ from .reservation_service import ReservationService
|
||||
from .user_service import UserService
|
||||
from .jwt_service import JWTService
|
||||
from .audit_service import AuditService
|
||||
from .optimizer_service import OptimizerService, OptimizerJob, OptimizerJobStatus
|
||||
|
||||
__all__ = [
|
||||
'GoogleSheetsImporter',
|
||||
@@ -11,5 +12,8 @@ __all__ = [
|
||||
'ReservationService',
|
||||
'UserService',
|
||||
'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;
|
||||
border-radius: 8px;
|
||||
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,
|
||||
|
||||
@@ -77,32 +77,61 @@
|
||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||
<i class="bi bi-lock"></i>
|
||||
</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 %}">
|
||||
<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>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check">
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
<label class="form-check-label text-muted small" for="remember_me">
|
||||
{{ form.remember_me.label.text }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<div class="d-grid mb-3">
|
||||
<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 }}
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
<small class="text-muted d-inline-flex align-items-center">
|
||||
<div class="text-center mt-3">
|
||||
<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>
|
||||
{% trans %}Secure Login • All access is logged{% endtrans %}
|
||||
{% trans %}Acceso institucional seguro • Actividad auditada{% endtrans %}
|
||||
</small>
|
||||
</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="{{ 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>
|
||||
</html>
|
||||
Reference in New Issue
Block a user